Version in base suite: 6.4.41+dfsg-0+deb13u1 Version in overlay suite: 6.4.49+dfsg-0+deb13u1 Base version: symfony_6.4.49+dfsg-0+deb13u1 Target version: symfony_6.4.50+dfsg-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/s/symfony/symfony_6.4.49+dfsg-0+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/s/symfony/symfony_6.4.50+dfsg-0+deb13u1.dsc .agents/skills/pr-review-merge-prep/SKILL.md | 10 .github/composer-config.json | 3 .github/dependabot.yml | 6 .github/expected-missing-return-types.diff | 6 .github/sync-maintained-versions.php | 21 .github/workflows/scorecards.yml | 63 .github/workflows/zizmor.yml | 2 CHANGELOG-6.4.md | 206 ++ debian/changelog | 55 debian/patches/Assume-php-async-aws-sqs-2.patch | 4 debian/patches/Cache-Disable-Test-failing-tests-Error-Class-not-found.patch | 55 debian/patches/Drop-composer-runtime-api-that-confuses-phpabtpl.patch | 2 debian/patches/Filesystem-Drop-currently-failing-tests.patch | 114 + debian/patches/Group-nophpunit11-for-tests-failing-with-PHPUnit-11.patch | 46 debian/patches/HttpClient-Skip-tests-relying-on-amphp-http-client.patch | 4 debian/patches/Lock-Drop-test-currently-failing-on-buildd.patch | 4 debian/patches/Remove-calls-to-getExpectedException.patch | 2 debian/patches/Yaml-Drop-data-set-currently-failing-on-i386.patch | 6 src/Symfony/Bundle/FrameworkBundle/Tests/Controller/ControllerResolverTest.php | 2 src/Symfony/Bundle/FrameworkBundle/Tests/Translation/TranslatorTest.php | 2 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php | 48 src/Symfony/Bundle/SecurityBundle/composer.json | 2 src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php | 6 src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php | 5 src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.php | 2 src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php | 46 src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php | 14 src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php | 1 src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php | 6 src/Symfony/Bundle/WebProfilerBundle/Tests/Twig/WebProfilerExtensionTest.php | 21 src/Symfony/Bundle/WebProfilerBundle/Twig/WebProfilerExtension.php | 6 src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php | 4 src/Symfony/Component/AssetMapper/ImportMap/RemotePackageStorage.php | 5 src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php | 13 src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php | 8 src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php | 29 src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php | 17 src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php | 110 + src/Symfony/Component/BrowserKit/Cookie.php | 2 src/Symfony/Component/BrowserKit/CookieJar.php | 29 src/Symfony/Component/BrowserKit/Tests/CookieJarTest.php | 106 + src/Symfony/Component/BrowserKit/Tests/CookieTest.php | 7 src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php | 50 src/Symfony/Component/Cache/Adapter/AbstractAdapter.php | 2 src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php | 2 src/Symfony/Component/Cache/Adapter/CouchbaseBucketAdapter.php | 2 src/Symfony/Component/Cache/Adapter/CouchbaseCollectionAdapter.php | 2 src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php | 2 src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php | 16 src/Symfony/Component/Cache/Adapter/MemcachedAdapter.php | 2 src/Symfony/Component/Cache/Adapter/PdoAdapter.php | 2 src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php | 11 src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php | 8 src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php | 40 src/Symfony/Component/Cache/Messenger/EarlyExpirationResult.php | 24 src/Symfony/Component/Cache/Tests/Adapter/AbstractAdapterCreateConnectionTest.php | 59 src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTestCase.php | 18 src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php | 18 src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php | 38 src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php | 14 src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php | 18 src/Symfony/Component/Cache/Tests/Adapter/PredisReplicationAdapterTest.php | 11 src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php | 106 + src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php | 274 +++ src/Symfony/Component/Cache/Traits/RedisTrait.php | 19 src/Symfony/Component/Cache/Traits/Relay/Relay61Trait.php | 32 src/Symfony/Component/Cache/Traits/RelayProxy.php | 2 src/Symfony/Component/Config/Tests/Util/XmlUtilsTest.php | 26 src/Symfony/Component/Config/Util/XmlUtils.php | 1 src/Symfony/Component/Console/Helper/OutputWrapper.php | 4 src/Symfony/Component/Console/Helper/ProcessHelper.php | 3 src/Symfony/Component/Console/Helper/ProgressBar.php | 37 src/Symfony/Component/Console/Helper/QuestionHelper.php | 4 src/Symfony/Component/Console/Helper/SymfonyQuestionHelper.php | 4 src/Symfony/Component/Console/Helper/Table.php | 64 src/Symfony/Component/Console/Logger/ConsoleLogger.php | 3 src/Symfony/Component/Console/Style/SymfonyStyle.php | 2 src/Symfony/Component/Console/Tests/Helper/OutputWrapperTest.php | 10 src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php | 2 src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php | 335 ++++ src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php | 25 src/Symfony/Component/Console/Tests/Helper/SymfonyQuestionHelperTest.php | 17 src/Symfony/Component/Console/Tests/Helper/TableTest.php | 225 ++ src/Symfony/Component/Console/Tests/Logger/ConsoleLoggerTest.php | 7 src/Symfony/Component/Console/Tests/Style/SymfonyStyleTest.php | 35 src/Symfony/Component/Filesystem/Filesystem.php | 60 src/Symfony/Component/Filesystem/Path.php | 22 src/Symfony/Component/Filesystem/Tests/FilesystemTest.php | 309 +++ src/Symfony/Component/Filesystem/Tests/Fixtures/remove_collision.php | 32 src/Symfony/Component/Filesystem/Tests/Fixtures/remove_without_rename.php | 29 src/Symfony/Component/Filesystem/Tests/PathTest.php | 63 src/Symfony/Component/Finder/Glob.php | 16 src/Symfony/Component/Finder/Tests/GlobTest.php | 17 src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php | 2 src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php | 9 src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php | 2 src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php | 92 - src/Symfony/Component/Form/NativeRequestHandler.php | 2 src/Symfony/Component/Form/Resources/translations/validators.da.xlf | 28 src/Symfony/Component/Form/Resources/translations/validators.de.xlf | 26 src/Symfony/Component/Form/Resources/translations/validators.el.xlf | 42 src/Symfony/Component/Form/Resources/translations/validators.et.xlf | 28 src/Symfony/Component/Form/Resources/translations/validators.hr.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.it.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.ja.xlf | 22 src/Symfony/Component/Form/Resources/translations/validators.lb.xlf | 26 src/Symfony/Component/Form/Resources/translations/validators.nl.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.pl.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.sk.xlf | 30 src/Symfony/Component/Form/Resources/translations/validators.sl.xlf | 28 src/Symfony/Component/Form/Resources/translations/validators.sr_Cyrl.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.sr_Latn.xlf | 24 src/Symfony/Component/Form/Resources/translations/validators.tr.xlf | 26 src/Symfony/Component/Form/Resources/translations/validators.uk.xlf | 28 src/Symfony/Component/Form/Resources/translations/validators.vi.xlf | 40 src/Symfony/Component/Form/Resources/translations/validators.zh_CN.xlf | 42 src/Symfony/Component/Form/Resources/translations/validators.zh_TW.xlf | 28 src/Symfony/Component/Form/Tests/AbstractRequestHandlerTestCase.php | 18 src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToStringTransformerTest.php | 1 src/Symfony/Component/Form/Tests/Extension/Core/Type/CollectionTypeTest.php | 69 src/Symfony/Component/Form/Tests/Extension/HttpFoundation/HttpFoundationRequestHandlerTest.php | 28 src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorFunctionalTest.php | 136 + src/Symfony/Component/Form/Tests/Extension/Validator/Type/CollectionTypeValidatorExtensionTest.php | 28 src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php | 256 +++ src/Symfony/Component/Form/Util/ServerParams.php | 2 src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php | 339 ++++ src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php | 200 ++ src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php | 21 src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php | 65 src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php | 109 + src/Symfony/Component/HttpClient/CurlHttpClient.php | 11 src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php | 2 src/Symfony/Component/HttpClient/Response/AmpResponse.php | 19 src/Symfony/Component/HttpClient/Response/AsyncResponse.php | 9 src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php | 30 src/Symfony/Component/HttpClient/Response/CurlResponse.php | 39 src/Symfony/Component/HttpClient/Response/NativeResponse.php | 19 src/Symfony/Component/HttpClient/Response/TraceableResponse.php | 4 src/Symfony/Component/HttpClient/ScopingHttpClient.php | 8 src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php | 26 src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php | 18 src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress.php | 24 src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress_in_stream.php | 33 src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php | 62 src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php | 5 src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php | 23 src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php | 19 src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php | 97 + src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php | 30 src/Symfony/Component/HttpFoundation/BinaryFileResponse.php | 2 src/Symfony/Component/HttpFoundation/HeaderUtils.php | 2 src/Symfony/Component/HttpFoundation/IpUtils.php | 36 src/Symfony/Component/HttpFoundation/Request.php | 36 src/Symfony/Component/HttpFoundation/RequestMatcher.php | 5 src/Symfony/Component/HttpFoundation/RequestMatcher/MethodRequestMatcher.php | 5 src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php | 4 src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php | 16 src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php | 49 src/Symfony/Component/HttpFoundation/Tests/RequestMatcher/MethodRequestMatcherTest.php | 10 src/Symfony/Component/HttpFoundation/Tests/RequestMatcherTest.php | 9 src/Symfony/Component/HttpFoundation/Tests/RequestTest.php | 179 ++ src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php | 32 src/Symfony/Component/HttpFoundation/Tests/UrlHelperTest.php | 67 src/Symfony/Component/HttpFoundation/UrlHelper.php | 24 src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php | 4 src/Symfony/Component/HttpKernel/EventListener/RouterListener.php | 35 src/Symfony/Component/HttpKernel/Kernel.php | 6 src/Symfony/Component/HttpKernel/Profiler/FileProfilerStorage.php | 2 src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php | 33 src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php | 23 src/Symfony/Component/HttpKernel/Tests/Fragment/InlineFragmentRendererTest.php | 56 src/Symfony/Component/HttpKernel/Tests/Profiler/FileProfilerStorageTest.php | 13 src/Symfony/Component/Lock/Store/DoctrineDbalStore.php | 2 src/Symfony/Component/Lock/Store/MongoDbStore.php | 8 src/Symfony/Component/Lock/Store/StoreFactory.php | 2 src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php | 18 src/Symfony/Component/Lock/Tests/Store/MongoDbStoreDsnTest.php | 37 src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php | 12 src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportFactoryTest.php | 21 src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php | 32 src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php | 4 src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php | 3 src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpSenderTest.php | 35 src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpTransportFactoryTest.php | 19 src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php | 22 src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpFactory.php | 2 src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpStamp.php | 7 src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php | 4 src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php | 6 src/Symfony/Component/Messenger/Bridge/Beanstalkd/Tests/Transport/ConnectionTest.php | 23 src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/Connection.php | 4 src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/ConnectionTest.php | 57 src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php | 18 src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php | 112 + src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisReceiverTest.php | 48 src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisTransportFactoryTest.php | 20 src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php | 39 src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisReceiver.php | 6 src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php | 4 src/Symfony/Component/Messenger/Handler/BatchHandlerTrait.php | 13 src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php | 16 src/Symfony/Component/Messenger/Tests/Handler/BatchHandlerTest.php | 39 src/Symfony/Component/Messenger/Tests/Middleware/HandleMessageMiddlewareTest.php | 104 + src/Symfony/Component/Messenger/Tests/Transport/Serialization/PhpSerializerTest.php | 10 src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php | 39 src/Symfony/Component/Messenger/Tests/Transport/TransportFactoryTest.php | 39 src/Symfony/Component/Messenger/Tests/WorkerTest.php | 791 ++++++++++ src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php | 2 src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php | 21 src/Symfony/Component/Messenger/Transport/TransportFactory.php | 4 src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php | 4 src/Symfony/Component/Messenger/Worker.php | 191 ++ src/Symfony/Component/Notifier/Bridge/Mailjet/Tests/MailjetTransportFactoryTest.php | 2 src/Symfony/Component/Notifier/Tests/Transport/AbstractTransportFactoryTest.php | 47 src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php | 2 src/Symfony/Component/PropertyAccess/PropertyAccessor.php | 6 src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php | 55 src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php | 11 src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyIntersectionType.php | 45 src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php | 16 src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php | 7 src/Symfony/Component/RateLimiter/Policy/Rate.php | 4 src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php | 4 src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php | 28 src/Symfony/Component/RateLimiter/Tests/Policy/RateTest.php | 16 src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php | 16 src/Symfony/Component/Routing/Matcher/Dumper/CompiledUrlMatcherDumper.php | 11 src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher1.php | 30 src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher13.php | 10 src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher2.php | 30 src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php | 64 src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php | 2 src/Symfony/Component/Security/Core/Resources/translations/security.vi.xlf | 4 src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php | 52 src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php | 2 src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php | 2 src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php | 2 src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php | 6 src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php | 86 - src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php | 3 src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php | 3 src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php | 26 src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php | 23 src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php | 27 src/Symfony/Component/Security/Http/Tests/Authenticator/LoginLinkAuthenticatorTest.php | 25 src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php | 150 + src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php | 33 src/Symfony/Component/Security/Http/Tests/RememberMe/RememberMeDetailsTest.php | 83 + src/Symfony/Component/Semaphore/Store/StoreFactory.php | 2 src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php | 12 src/Symfony/Component/Serializer/Encoder/CsvEncoder.php | 26 src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php | 21 src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php | 7 src/Symfony/Component/Serializer/Serializer.php | 2 src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php | 37 src/Symfony/Component/Serializer/Tests/Fixtures/property-path-mapping-without-groups.yaml | 8 src/Symfony/Component/Serializer/Tests/Normalizer/DataUriNormalizerTest.php | 182 ++ src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php | 73 src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php | 9 src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php | 58 src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php | 5 src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php | 21 src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php | 5 src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php | 20 src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php | 6 src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php | 50 src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php | 2 src/Symfony/Component/Translation/Tests/Provider/AbstractProviderFactoryTest.php | 47 src/Symfony/Component/Translation/Tests/TranslatorCacheTest.php | 2 src/Symfony/Component/Validator/Constraints/IpValidator.php | 95 + src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php | 9 src/Symfony/Component/Validator/Resources/translations/validators.az.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.da.xlf | 38 src/Symfony/Component/Validator/Resources/translations/validators.de.xlf | 34 src/Symfony/Component/Validator/Resources/translations/validators.et.xlf | 36 src/Symfony/Component/Validator/Resources/translations/validators.id.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.lb.xlf | 82 - src/Symfony/Component/Validator/Resources/translations/validators.my.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.sk.xlf | 38 src/Symfony/Component/Validator/Resources/translations/validators.sl.xlf | 50 src/Symfony/Component/Validator/Resources/translations/validators.th.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.tl.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.tr.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.uk.xlf | 38 src/Symfony/Component/Validator/Resources/translations/validators.uz.xlf | 4 src/Symfony/Component/Validator/Resources/translations/validators.vi.xlf | 142 - src/Symfony/Component/Validator/Resources/translations/validators.zh_CN.xlf | 166 +- src/Symfony/Component/Validator/Resources/translations/validators.zh_TW.xlf | 86 - src/Symfony/Component/Validator/Tests/Constraints/IpValidatorTest.php | 249 +++ src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php | 59 src/Symfony/Component/Validator/Tests/Resources/TranslationFilesTest.php | 39 src/Symfony/Component/Yaml/Escaper.php | 2 src/Symfony/Component/Yaml/Inline.php | 145 + src/Symfony/Component/Yaml/Parser.php | 367 ++++ src/Symfony/Component/Yaml/Tests/DumperTest.php | 1 src/Symfony/Component/Yaml/Tests/InlineTest.php | 46 src/Symfony/Component/Yaml/Tests/ParserTest.php | 162 ++ 297 files changed, 10259 insertions(+), 1286 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmplds8r1cr/symfony_6.4.49+dfsg-0+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmplds8r1cr/symfony_6.4.50+dfsg-0+deb13u1.dsc: no acceptable signature found diff -Nru symfony-6.4.49+dfsg/.agents/skills/pr-review-merge-prep/SKILL.md symfony-6.4.50+dfsg/.agents/skills/pr-review-merge-prep/SKILL.md --- symfony-6.4.49+dfsg/.agents/skills/pr-review-merge-prep/SKILL.md 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.agents/skills/pr-review-merge-prep/SKILL.md 2026-11-04 11:23:00.000000000 +0000 @@ -13,13 +13,16 @@ - Report that attempt whichever way it went. When it failed, the report is what justifies the addition, and it is what makes the decision survive the discussion. When it succeeded, close with the working snippet rather than the refusal alone, so the author leaves with a solution instead of a no. - Give the same scrutiny to changed behavior of existing public API. It is riskier than an addition, because nobody opts into it: establish what depends on the current behavior before accepting the change. A diff that edits existing fixtures or expectations to make them pass is announcing such a change; treat that as the signal rather than as housekeeping. - Revert-verify every new test: put the changed source back in its base state, keep the tests, and confirm they fail with the expected error, not another one. A test that also passes on base protects existing behavior; it does not prove the fix. Be clear about the role of each test. +- When a fix has several parts, revert each part on its own and run the tests again. For example, remove the wiring but keep the method, or replace a helper with its simplest wrong version. Each part needs a test that fails without it. A test that fails on base only with "Call to undefined method" proves that the method exists, not that the fix works. - Undo a revert-verify without destroying the working tree. `git checkout -- ` writes the base version to the index as well, so restoring with `git checkout -- ` reads back from that index and silently discards every fix made during review. Commit or amend first and restore with `git checkout HEAD -- `, or copy the files aside and put them back by hand. Confirm with `git diff --stat` before trusting the next test run. - Use three-state verification to judge a rework: base (bug present), PR as submitted (what it really does), final (fixed). Run the same checks in the three states. The pattern of failures across the states is the proof. - Validate the test harness before trusting it: run it on the known-broken state and watch it fail. A check that cannot tell "read the right thing" from "fell back to a default" proves nothing. Give each outcome its own marker. - Check the branch target in both directions. Find the commit that introduced the flaw and probe the older maintained branches directly. Bugfixes go to the oldest affected maintained branch. Behavior changes and features go to the dev branch, and a released X.Y is not the dev branch anymore. The reported symptom and the underlying flaw can have different oldest branches: check both. +- During a feature freeze, a new feature opened on the frozen branch gets the milestone of the next version and no in-depth review. The exception is a feature that completes another feature new in the frozen branch. Performance work and refactors are not features. - A config tree must not depend on anything external. Reject a `Configuration` class, a `configure()` method or a security factory `addConfiguration()` that reads container parameters (`kernel.debug`), env vars, another extension's raw config or machine state (`extension_loaded()`, `isSupported()`) into node defaults, normalization or validation: the same tree must come out whatever the environment or the machine, because `config:dump-reference`, `config/reference.php` and `config/schema.json` are generated from it. Such a default belongs in the extension, behind a `defaultNull()` node whose `info()` names the fallback, or behind a static `'%kernel.debug%'` default that the extension resolves. - Generated files must be reproducible. The same sources at the same path must give the same bytes, whatever the machine, the clock or what else the process did before. This covers the dumped container and its service ids, the output of cache warmers, compiled templates and `.meta` files. Reject a change that puts any of the following into them, or into a hash used as an id or a file name: the current time, random bytes, `spl_object_id()`, a static counter, a file mtime or inode, or an order that comes from the filesystem (a `Finder` without a sort, `RecursiveDirectoryIterator`, `glob()` with `GLOB_NOSORT`). Take the build time from the `container.build_time` parameter, never from the clock. Keep absolute paths out of hashed ids, and make generated paths relative to the project or build directory where the generated code allows it, as the container dumper does. When a freshness check needs filesystem state, keep that state out of what is written without debug, and out of what is written when `SOURCE_DATE_EPOCH` is set. To settle a doubt, build the same app twice, once from a copy at another path, and diff the two `var/cache/prod` directories. - List the edge cases and run them. Do not accept plausible reasoning when a probe can answer: nullable, union and interface types, empty input, recursion, encodings, interaction with sibling state. +- Check state that outlives a request. Long-running runtimes, such as FrankenPHP worker mode, RoadRunner or Swoole, keep services between requests. A service that changes its own properties while handling a request must be reset before the next request, usually with the `kernel.reset` tag. Check when the reset runs: it must not happen in the middle of a request. Look for other services with the same flaw. A counter updated by an enter/leave pair without `try`/`finally` stays wrong after an exception. - Check every symbol the change borrows from another package against that package's declared version constraint. A symbol added in the current release cannot be satisfied by the oldest version the constraint allows, so the call site needs an existence guard, or the test belongs in the package that owns the code. The lowest-dependency job is only the symptom: the same code is broken at runtime for anyone on that older version. - Test composition: rebase onto the current base tip and rerun. When sibling PRs touch the same area, also run the combined result. - Account for every failing CI job: reproduce it when possible and classify it as caused by the PR or pre-existing. A red job nobody can explain is a finding. When comparing against a baseline run, check its date and not only its conclusion: a branch tip that saw no push for hours keeps a stale green run, while CI installs current dependencies on every run, which makes an unrelated change look like the regressor. @@ -41,7 +44,8 @@ - Preserve the contributor's authorship: amend into their commit, or rebuild their commits with the same author. Keep a commit structure that has meaning, such as a tests-then-fix pair. Update the commit message to cover what was added; a message must always match its diff. - Authorship is two fields. Amending keeps the author and rewrites the committer, and the committer is what the forge shows next to the commit and what stays in public history. Never pass a placeholder identity to `git commit` or `git rebase`: the repository's configured identity is already the right one. Check with `git log -1 --format='%an <%ae> / %cn <%ce>'` before every push, because merged history cannot be fixed. - Fixes added during review follow the same TDD rule as the original bug: failing test first, then the fix. -- Retargeting to another branch is a rewrite, not a copy: express the fix in the target branch's code shape and conventions (available APIs, test style, annotations or attributes, tool versions). Rebuild tests that depend on things the target branch lacks. Record the known merge-up resolution for the higher branch in the pull request description, under a `Merge-up to :` paragraph, so the merger does not have to rediscover it. +- When review finds a bug in what the pull request changes, amend the fix into the pull request. A bug that was there before, in code the pull request does not touch, gets its own pull request on its oldest affected branch. Start its description with "Found while reviewing #NNNNN". Do not make the reviewed pull request bigger. Several such bugs in the same file go in one pull request, one commit each. +- Retargeting to another branch is a rewrite, not a copy: express the fix in the target branch's code shape and conventions (available APIs, test style, annotations or attributes, tool versions). Rebuild tests that depend on things the target branch lacks. Record the known merge-up resolution for the higher branch in the pull request description, under a `Merge-up to :` paragraph, so the merger does not have to rediscover it. When the notes are long, put them at the end of the description in a `
Merge-up notes` block. Notes are long when they cover several branches or include a patch to apply after the merge-up. Put such a patch in a `diff` code block. - When the rework sits in local commits on top of the contributor's, squash before rebasing. Otherwise the rebase replays the original commit first and raises conflicts against content the rework already replaced. - A conflict-free rebase can still produce broken code: an import the source branch happened to have, a test runner that ignores newer test syntax. The test run decides, not the rebase. - Rebase onto the current base tip and confirm the parent commit equals it. Check again just before pushing: active repositories move. Run rebases and merges non-interactively (`GIT_EDITOR=true`). @@ -50,7 +54,7 @@ - Run scoped tests while iterating and the full suite of each touched component before calling it done; never run the whole monorepo at once. Suites are addressed by path: `./phpunit src/Symfony/Component/`, and the same form for `src/Symfony/Bridge/` and `src/Symfony/Bundle/`, while everything under `src/Symfony/Contracts` is a single suite. Check static analysis and lowest-dependency jobs when the change can affect them. If an assertion depends on the installed dependency version, use feature detection instead of assuming one version. - Retarget before pushing, never after. CI reads the base branch from the pull request event at push time, so pushing a branch rebased onto a new base while the PR still points at the old one makes every job diff against the wrong branch: a diff of thousands of files, style checks on code the PR never touched, and a wall of red that a three-file change cannot explain. Rerunning does not help, since the event is unchanged. - Push to the PR's real head with an explicit `HEAD:` refspec, since the work happens on a detached HEAD. The fork's repository name can differ from the upstream one; query it. -- Update the PR's metadata to match what it has become: title and component prefix, header table, description, labels, base and milestone. On a PR that sat, the `Branch?` row names a branch that has since shipped, the description keeps wording the review already corrected, and the component prefix can name the wrong package. Leave exactly one status label, and none the diff no longer justifies. +- Update the PR's metadata to match what it has become: title and component prefix, header table, description, labels, base and milestone. On a PR that sat, the `Branch?` row names a branch that has since shipped, the description keeps wording the review already corrected, and the component prefix can name the wrong package. Leave exactly one status label, and none the diff no longer justifies. A change that only touches tests is not a bug fix, even when it fixes a flaky test. Its `Bug fix?` row says `no`, and it gets no `Bug` label. Keep the milestone in sync with the base branch, including after a retarget. Carsonbot sets the milestone from the base branch when the pull request is opened, and replaces a milestone set at that moment. Set the milestone after the bot has labeled the pull request, then check it again. - Treat the description as the first draft of the documentation, not as a review log. Write it for someone who never saw the discussion: what the feature does, the public API it adds, the options and their defaults, which choice to make when, and the traps. For a feature, end with the points the documentation must carry, so whoever opens the symfony-docs pull request does not have to reconstruct them from the diff. - Apply corrections instead of suggesting them. The branch is being rewritten anyway, so a review comment asking for a one-line change is a pointless round trip. This covers the contributor's own code comments and test names when they describe something the code does not do. - Routine merge prep needs no comment at all: a rebase, a changelog fix, a constraint bump and a style fix are all visible in the diff. Comment when the reader has to decide or react. @@ -58,6 +62,7 @@ - Keep changelog entries to one line each, naming the new public surface and nothing more. Reasoning, comparison tables and usage guidance belong in the documentation. A changelog entry that has to explain itself is a sign the documentation entry is missing. - Apply the house rules to inherited content too: when amending someone else's commit, remove what the rules forbid instead of keeping it. Scan the added diff lines for violations before pushing. - Finish on GitHub: post review feedback as a real review with evidence, close replaced issues with a factual explanation, and correct wrong claims left in threads. +- Fetch the thread and the live description again right before posting a comment or replacing the description. The author or another maintainer may have answered or edited it while you worked, and a description rebuilt from an old copy drops their edits. - When closing on the merits, state the decision and the reason and stop. Do not invite a fresh pull request, since that reopens the same discussion under a new number. When closing a stale pull request nobody rejected, a short line welcoming a fresh take is the right tone instead. ## House rules @@ -70,6 +75,7 @@ - Keep a factual tone in everything published: findings and evidence, no self-promotion, no filler. - Never apologize for a late or missing review. Thank the author for their patience and stop there. Do not editorialize about the project's failure to review; credit what the author did well instead. - Comments are published under the maintainer's own account, so write their past review comments in the first person and everyone else's in the third. +- Refer to earlier changes by their pull request number, not by a commit sha. The number leads to the discussion. - Private methods go at the end of the class, after all public and protected ones. Do not move existing ones in the same patch. - Do not call a Symfony or third-party method or constructor with PHP named arguments, in a patch you write or in one you review. Parameter names are outside the backward compatibility promise except for the constructors of attribute classes (note 10 of https://symfony.com/doc/current/contributing/code/bc.html), so a renamed parameter breaks such a call in a minor release. Pass the intermediate defaults explicitly to reach a later optional parameter. Named arguments stay fine for attribute constructors and for PHP's own functions, whose parameter names are part of the language. - Use plain English: common words, short sentences, one idea per sentence. Avoid idioms, cultural references and rare vocabulary. Most readers are not native speakers. diff -Nru symfony-6.4.49+dfsg/.github/composer-config.json symfony-6.4.50+dfsg/.github/composer-config.json --- symfony-6.4.49+dfsg/.github/composer-config.json 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/composer-config.json 2026-11-04 11:23:00.000000000 +0000 @@ -14,6 +14,9 @@ }, "allow-plugins": { "symfony/flex": true + }, + "audit": { + "block-insecure": false } } } diff -Nru symfony-6.4.49+dfsg/.github/dependabot.yml symfony-6.4.50+dfsg/.github/dependabot.yml --- symfony-6.4.49+dfsg/.github/dependabot.yml 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/dependabot.yml 2026-11-04 11:23:00.000000000 +0000 @@ -1,6 +1,6 @@ # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference -# Generated by .github/sync-maintained-versions.php from https://symfony.com/maintained-versions.json +# Generated by .github/sync-maintained-versions.php from https://symfony.com/releases.json # One entry per maintained branch; cooldown increases in merge-up order so the lowest branch surfaces first. version: 2 @@ -40,7 +40,7 @@ patterns: - '*' cooldown: - default-days: 10 + default-days: 9 - package-ecosystem: 'github-actions' directory: '/' @@ -52,4 +52,4 @@ patterns: - '*' cooldown: - default-days: 11 + default-days: 10 diff -Nru symfony-6.4.49+dfsg/.github/expected-missing-return-types.diff symfony-6.4.50+dfsg/.github/expected-missing-return-types.diff --- symfony-6.4.49+dfsg/.github/expected-missing-return-types.diff 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/expected-missing-return-types.diff 2026-11-04 11:23:00.000000000 +0000 @@ -1428,7 +1428,7 @@ --- a/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php +++ b/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php @@ -38,5 +38,5 @@ class EarlyExpirationDispatcher - * @return mixed + * @return mixed The computed value, or the cached value when recomputation is deferred or skipped */ - public function __invoke(callable $callback, CacheItem $item, bool &$save, AdapterInterface $pool, \Closure $setMetadata, ?LoggerInterface $logger = null, ?float $beta = null) + public function __invoke(callable $callback, CacheItem $item, bool &$save, AdapterInterface $pool, \Closure $setMetadata, ?LoggerInterface $logger = null, ?float $beta = null): mixed @@ -1438,10 +1438,10 @@ --- a/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php +++ b/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php @@ -33,5 +33,5 @@ class EarlyExpirationHandler - * @return void + * @return EarlyExpirationResult|null */ - public function __invoke(EarlyExpirationMessage $message) -+ public function __invoke(EarlyExpirationMessage $message): void ++ public function __invoke(EarlyExpirationMessage $message): ?EarlyExpirationResult { $item = $message->getItem(); diff --git a/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php b/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php diff -Nru symfony-6.4.49+dfsg/.github/sync-maintained-versions.php symfony-6.4.50+dfsg/.github/sync-maintained-versions.php --- symfony-6.4.49+dfsg/.github/sync-maintained-versions.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/sync-maintained-versions.php 2026-11-04 11:23:00.000000000 +0000 @@ -1,7 +1,7 @@ high). +// https://symfony.com/releases.json (ordered low -> high). // // Standalone helper: run it by hand whenever the maintained branches change. // @@ -10,7 +10,6 @@ // branch; cooldown.default-days grows by one // in merge-up order so the lowest branch // surfaces first and the review queue is staggered. -// - .github/workflows/scorecards.yml push trigger pinned to the default (highest) branch. // - .github/workflows/unit-tests.yml LTS test versions for the current branch: cross-test // against the latest LTS at or below it, low-deps floor // against the LTS one major below that. @@ -25,8 +24,8 @@ } $self = basename(__FILE__); -$source = 'https://symfony.com/maintained-versions.json'; -$branches = json_decode(file_get_contents($source), true, 512, \JSON_THROW_ON_ERROR); +$source = 'https://symfony.com/releases.json'; +$branches = json_decode(file_get_contents($source), true, 512, \JSON_THROW_ON_ERROR)['maintained_versions'] ?? null; if (!\is_array($branches) || !$branches) { fwrite(\STDERR, "No maintained versions found at $source\n"); @@ -34,7 +33,6 @@ } $branches = array_values($branches); -$default = end($branches); // highest branch == dev == default $check = \in_array('--check', $argv, true); // Identify the checked-out branch from the source tree itself (robust to detached @@ -98,19 +96,6 @@ $dirty = $sync(__DIR__.'/dependabot.yml', rtrim($dependabot)."\n"); -// --- .github/workflows/scorecards.yml (push trigger only) ------------------- - -$scorecards = __DIR__.'/workflows/scorecards.yml'; -$yaml = file_get_contents($scorecards); -$patched = preg_replace('/^(\s*branches:\s*\[\s*")[^"]*("\s*\])/m', '${1}'.$default.'${2}', $yaml, 1, $count); - -if (1 !== $count) { - fwrite(\STDERR, "Could not find the push branch trigger in scorecards.yml\n"); - exit(1); -} - -$dirty = $sync($scorecards, $patched) || $dirty; - // --- .github/workflows/unit-tests.yml (LTS test versions) ------------------- // LTS releases are the *.4 maintained versions. Cross-test against the latest one diff -Nru symfony-6.4.49+dfsg/.github/workflows/scorecards.yml symfony-6.4.50+dfsg/.github/workflows/scorecards.yml --- symfony-6.4.49+dfsg/.github/workflows/scorecards.yml 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/workflows/scorecards.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,63 +0,0 @@ -name: Scorecards supply-chain security - -on: - # Only the default branch is supported. - branch_protection_rule: - schedule: - - cron: '34 4 * * 6' - push: - branches: [ "8.2" ] - -# Declare default permissions as read only. -permissions: read-all - -jobs: - analysis: - name: Scorecards analysis - runs-on: ubuntu-24.04 - permissions: - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Used to receive a badge. (Upcoming feature) - id-token: write - # Needs for private repositories. - contents: read - actions: read - - steps: - - name: "Checkout code" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 - with: - results_file: results.sarif - results_format: sarif - # (Optional) Read-only PAT token. Uncomment the `repo_token` line below if: - # - you want to enable the Branch-Protection check on a *public* repository, or - # - you are installing Scorecards on a *private* repository - # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. - # repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} - - # Publish the results for public repositories to enable scorecard badges. For more details, see - # https://github.com/ossf/scorecard-action#publishing-results. - # For private repositories, `publish_results` will automatically be set to `false`, regardless - # of the value entered here. - publish_results: true - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: "Upload artifact" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 - with: - sarif_file: results.sarif diff -Nru symfony-6.4.49+dfsg/.github/workflows/zizmor.yml symfony-6.4.50+dfsg/.github/workflows/zizmor.yml --- symfony-6.4.49+dfsg/.github/workflows/zizmor.yml 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/.github/workflows/zizmor.yml 2026-11-04 11:23:00.000000000 +0000 @@ -28,7 +28,7 @@ persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 + uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 with: advanced-security: false annotations: true diff -Nru symfony-6.4.49+dfsg/CHANGELOG-6.4.md symfony-6.4.50+dfsg/CHANGELOG-6.4.md --- symfony-6.4.49+dfsg/CHANGELOG-6.4.md 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/CHANGELOG-6.4.md 2026-11-04 11:23:00.000000000 +0000 @@ -7,6 +7,212 @@ 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.4.0...v6.4.1 +## 6.4.50 (2026-11-04) + +### AssetMapper + + * bug #67802 [AssetMapper] Fix the resolution of relative paths in downloaded packages (nicolas-grekas) + * bug #67750 [AssetMapper] Decode the version of a jsDelivr import before resolving it (mathieudz) + * bug #67621 [AssetMapper] Fix `importmap:remove` deleting the files of local entries (nicolas-grekas) + +### BrowserKit + + * bug #67685 [BrowserKit] Match cookies across equivalent IPv6 addresses (X-Coder264) + * bug #67684 [BrowserKit] Expire cookies dated at the Unix epoch (X-Coder264) + +### Cache + + * bug #67759 [Cache] Fix FilesystemTagAwareAdapter::deleteItems() returning true when some items could not be deleted (nicolas-grekas) + * bug #67741 [Cache] Fix Redis deleteItems() returning true when some items could not be deleted (nicolas-grekas) + * bug #67736 [Cache] Fix tag-aware deleteItems() returning false when the per-item retry succeeds (nicolas-grekas) + * bug #67720 [Cache] Retry failed early expiration callbacks (nicolas-grekas) + * bug #67703 [Cache] Honor the $save flag set by the callback in EarlyExpirationHandler (BenMacha) + * bug #67714 [Cache] Preserve null results from synchronous early recomputation (nicolas-grekas) + * bug #67681 [Cache] Add support for relay 0.61 (nicolas-grekas) + * minor #67744 [Cache] Mark connection options as #[\SensitiveParameter] (nicolas-grekas) + +### Config + + * security #ghsa-5jff-8h8q-r5qx [Config] Do not validate XML documents on parse (nicolas-grekas) + +### Console + + * bug #67831 [Console] Fix ProgressBar's percent after finish() on undecorated output without a max (nicolas-grekas) + * bug #67830 [Console] Fix ProgressBar::finish() with a lowered max and restarting a finished bar without a max (jack-worman) + * bug #67823 [Console] Fix ProgressBar frames on undecorated output when resuming, restarting or finishing (jack-worman) + * bug #67811 [Console] Fix ProgressBar::setMaxSteps() not updating the percentage (nicolas-grekas) + * bug #67805 [Console] Fix ProgressBar::start() computing the percentage against the previous max (jack-worman) + * bug #67769 [Console] Fix text ending with a backslash escaping the closing tag that follows it (nicolas-grekas) + * bug #67762 [Console] Keep an escaped tag delimiter with its backslash when wrapping text (initd-it) + * bug #67634 [Console] Fix `setRow()`, `setColumnMaxWidth()` and consecutive rowspans in `Table` (nicolas-grekas) + * bug #67633 [Console] Fix `Table` cells that span rows when they follow a cell that spans columns (ybai08) + +### Filesystem + + * security #ghsa-6prc-h956-hxjq [Filesystem] Only treat a valid URI scheme as the root of a path (nicolas-grekas) + * bug #67786 [Filesystem] Fix failure handling in remove() (nicolas-grekas) + * bug #67778 [Filesystem] Reserve temporary removal directories atomically (X-Coder264) + +### Finder + + * bug #67785 [Finder] Fix Glob::toRegex() producing an invalid regex for bracket expressions containing a slash (0xABCD01) + +### Form + + * bug #67776 [Form] Map data validation errors to the sibling form that has the field (nicolas-grekas) + * bug #67772 [Form] Keep validation errors on their originating form (X-Coder264) + * bug #67731 [Form] Fix `allow_delete` removing collection entries submitted as null (nicolas-grekas) + * bug #67700 [Form] Ignore collection entries with invalid names when allow_add is enabled (BenMacha) + * bug #67702 [Form] Fix submitting "0" to a non-compound root form (BenMacha) + * bug #67713 [Form] Skip unsubmitted collection entries with `delete_empty` (nicolas-grekas) + * bug #67701 [Form] Fix the post_max_size check when it is set to a negative value (BenMacha) + * bug #67650 [Form] Preserve the Unix epoch in date string transformation (ahbaqdadi) + * minor #67795 [Form] Review Greek translations (sogav21) + * minor #67773 [Form] Review Croatian translations (HypeMC) + * minor #67674 [Form] Review Polish translations (lukaszkrakowiak) + * minor #67660 [Form] Review Japanese translations (OTA-EITA) + * minor #67623 [Form][Validator] Review Slovak (sk) translations (tito10047) + * minor #67642 [Form][Validator] Review and improve Chinese (zh_CN) translations (siganushka) + * minor #67635 [Form][Validator] Review and fix Luxembourgish (lb) translations (mweimerskirch) + * minor #67632 [Form] Fix Dutch translations for validators (mdalikadar) + * minor #67631 [Form] Fix Italian translations for validators (mdalikadar) + * minor #67626 [Form][Security][Validator] Review Vietnamese translations (HongTin2104) + * minor #67622 [Form][Validator] review translations for Ukrainian (uk) (qconer) + * minor #67627 [Form][Validator] Review translations for Chinese (zh_TW) (HsukqiLee) + * minor #67447 [Form] Review Turkish (tr) validator translations (erenbati) + * minor #67617 [Form][Validator] Review German translations (Rommmoa) + +### HtmlSanitizer + + * security #ghsa-q8jq-xrf4-v5j8 [HtmlSanitizer] Apply the URL policy to srcset and imagesrcset (nicolas-grekas) + * security #cve-2026-118517 [HtmlSanitizer] Require special schemes and scheme-relative URLs to carry a non-empty authority (nicolas-grekas) + * security #ghsa-wm2w-4mgj-vw33 [HtmlSanitizer] Fix meta refresh URL sanitization bypass (nicolas-grekas) + +### HttpClient + + * security #ghsa-c7hm-wqwp-f9v8 [HttpClient] Pin the IP of cached hosts in NoPrivateNetworkHttpClient (nicolas-grekas) + * security #ghsa-gq53-v8w2-gjhj [HttpClient] Don't send the options of the default scope to other origins in ScopingHttpClient (nicolas-grekas) + * bug #67619 [HttpClient] Let PHP destruct the response decorated by TraceableResponse (nicolas-grekas) + * bug #67612 [HttpClient] Fix AsyncResponse throwing a LogicException when it is canceled while streaming (nicolas-grekas) + * bug #67610 [HttpClient] Fix getContent() returning a truncated body when the response is canceled while it reads it (nicolas-grekas) + * bug #67599 [HttpClient] Fix reading a traced CurlResponse as a stream after its decorator is destructed (PhilETaylor) + * bug #67609 [HttpClient] Fix CurlHttpClient writing the body of a response to stdout when it is canceled during stream() (nicolas-grekas) + * bug #67605 [HttpClient] Fix clearing the DNS cache of pending requests when a traced response is destructed (nicolas-grekas) + +### HttpFoundation + + * security #ghsa-xg2q-4rrq-657q [HttpFoundation] Add the list of private ranges usable as trusted proxies (nicolas-grekas) + * security #cve-2026-118514 [HttpFoundation] Match HEAD requests when GET is listed in a MethodRequestMatcher (nicolas-grekas) + * security #cve-2026-118514 [HttpFoundation] Match HEAD requests when GET is listed in a RequestMatcher (nicolas-grekas) + * security #ghsa-5mx8-mwrp-5693 [HttpFoundation] Don't let Request::getBaseUrl() start with a host chosen by the client (nicolas-grekas) + * security #ghsa-3h27-hjv7-fqjx [HttpFoundation] Don't match the script name in Request::getBaseUrl() when it's only the start of a path segment (jack-worman) + * bug #67829 [HttpFoundation] Fix Request::getBaseUrl() for a percent-encoded or wrong-case script name after a prefix, and behind an alias (jack-worman) + * bug #67826 [HttpFoundation] Don't use the script name as Request::getBaseUrl() when it's not in the URI path (jack-worman) + * bug #67804 [HttpFoundation] Don't match the script name in Request::getBaseUrl() when it's only the start of a path segment (jack-worman) + * bug #67814 [HttpFoundation] Fix UrlHelper for fragments behind a trusted X-Forwarded-Prefix and for an empty path (nicolas-grekas) + * bug #67800 [HttpFoundation] Preserve the base URL for query and fragment references (ahbaqdadi) + * minor #67760 [HttpFoundation][Lock][Semaphore] Do not expose the DSN in the exception thrown for unsupported connections (nicolas-grekas) + * minor #67746 [HttpFoundation] Mark the DSN and options of SessionHandlerFactory as #[\SensitiveParameter] (nicolas-grekas) + +### HttpKernel + + * security #ghsa-wg5q-m2ww-f3c9 [HttpKernel] Restore the router context of the parent request after a sub-request (nicolas-grekas) + * bug #67683 [HttpKernel] Compute #[Cache] durations without daylight saving time (nicolas-grekas) + * bug #67607 [HttpKernel] Fix searching profiles by status code 0 (chr-hertel) + +### Lock + + * minor #67745 [Lock] Mark the DSNs and options of the DBAL and MongoDB stores as #[\SensitiveParameter] (nicolas-grekas) + +### Messenger + + * bug #67792 [Messenger] Don't reuse a serialized body after replacing the message (dalerazimov) + * bug #67767 [Messenger] Fix the Redis transport throwing "Redis server went away" when connecting fails (nicolas-grekas) + * bug #67753 [Messenger] Treat Beanstalkd jobs that aren't Messenger envelopes as decoding failures (nicolas-grekas) + * bug #67724 [Messenger] Fix Redis DSN credential decoding and exception traces (nicolas-grekas) + * bug #67711 [Messenger] Do not resend the stamp headers of a received AMQP message (BenMacha) + * bug #67709 [Messenger] Restore the schema assets filter when the Doctrine transport setup fails (BenMacha) + * bug #67710 [Messenger] Do not expose the Redis DSN in the invalid host query parameter exception (BenMacha) + * bug #67654 [Messenger] Wait for all batch handlers before acknowledging a message (andreybolonin) + * bug #67655 [Messenger] Retry the messages whose batch was flushed during a failed dispatch (nicolas-grekas) + * bug #67651 [Messenger] Let the worker flush the batches that contain a failed message (nicolas-grekas) + * bug #67647 [Messenger] Don't hide handler failures when a batch handler acks the same message (nicolas-grekas) + * bug #67643 [Messenger] Preserve handled stamps when a batch handler nacks (dalerazimov) + * minor #67763 [Messenger] Treat encoded envelopes whose body or type isn't a string as decoding failures (nicolas-grekas) + * minor #67766 [Messenger] Ignore the "X-Symfony-Messenger" attribute of an SQS message when it isn't a JSON object (nicolas-grekas) + * minor #67754 [Messenger] Treat Redis stream entries that aren't Messenger envelopes as decoding failures (nicolas-grekas) + * minor #67739 [Messenger] Mark transport options as #[\SensitiveParameter] (nicolas-grekas) + * minor #67722 [Messenger] Ignore ids that are not integers when finding a message of the Doctrine transport (BenMacha) + +### Notifier + + * minor #67761 [Notifier][Translation] Do not expose the DSN in the exception thrown when its password is missing (nicolas-grekas) + +### PropertyAccess + + * bug #67733 [PropertyAccess] Fix parsing type errors thrown by anonymous classes (nicolas-grekas) + * bug #67682 [PropertyAccess] Handle uninitialized properties of anonymous classes on PHP 8.6 (nicolas-grekas) + +### PropertyInfo + + * bug #67788 [PropertyInfo] Don't turn intersection types into class names in PhpDocExtractor (nicolas-grekas) + +### RateLimiter + + * bug #67705 [RateLimiter] Validate the refill amount, the fixed window interval and the sliding window limit (BenMacha) + +### Routing + + * security #cve-2026-118516 [Routing] Prevent host placeholders from matching the path in compiled matchers (nicolas-grekas) + +### Security + + * security #cve-2026-118513 [Security] Parse the DN into RDNs before extracting the user identifier (nicolas-grekas) + * security #ghsa-8f57-m8wj-x65w [Security] Run checkPostAuth() of user checkers on programmatic logins (nicolas-grekas) + * minor #67738 [Security] Fix `Voter` with anonymous classes and nested `supports()` calls (nicolas-grekas) + * minor #67707 [Security] Reject login links with an out of range "expires" parameter (BenMacha) + * minor #67708 [Security] Ignore target and failure paths that are neither paths nor absolute URLs (BenMacha) + * minor #67716 [Security] Reject malformed login link user identifiers (nicolas-grekas) + * minor #67706 [Security] Reject remember-me cookies with an invalid expiration time (BenMacha) + +### Serializer + + * security #ghsa-hqp2-7rr8-hf26 [Serializer] Ignore undeclared property paths when denormalizing (nicolas-grekas) + * security #cve-2026-118515 [Serializer] Don't build UploadedFile and other file objects from denormalized data (nicolas-grekas) + * security #ghsa-pxj9-jh84-vhm5 [Serializer] Lowercase the scheme of data URIs before opening them (nicolas-grekas) + * bug #67752 [Serializer] Skip empty lines when decoding multi-column CSV documents (0xABCD01) + * bug #67665 [Serializer] Fix denormalizing a data URI ending with slashes to a File (ahmed-bhs) + +### Translation + + * bug #67669 [Translation] Don't read provider responses as file paths (nicolas-grekas) + * minor #67625 [Translation] Reviewed the Danish translation in issue #65554 (mcardle) + +### Validator + + * security #ghsa-4qpf-57w4-m3wc [Validator] Reject non-public IP addresses in the no-priv, no-res and only-public modes (nicolas-grekas) + * bug #67787 [Validator] Only extract property types when auto-mapping applies (paulferrett) + * minor #67644 [Validator] Show the count in the word count messages of locales with a single plural form (nicolas-grekas) + +### WebProfilerBundle + + * bug #67618 [WebProfilerBundle] Reset the CSP handler between requests (norkunas) + * bug #67620 [WebProfilerBundle] Reset the profiler Twig extension between requests (nicolas-grekas) + * bug #67606 [WebProfilerBundle] Return a 404 when opening the profiler file viewer without a file (kevinsleegerslameco, nicolas-grekas) + +### Yaml + + * security #ghsa-hwfg-qwvh-2wfx [Yaml] Fix exponential and quadratic parse times (nicolas-grekas) + * security #ghsa-hwfg-qwvh-2wfx [Yaml] Fix quadratic parse times (nicolas-grekas) + * bug #67735 [Yaml] Strip the byte order mark at the start of a stream (0xABCD01) + +### Other + + * bug #67614 Reject trailing newlines in Content-Disposition fallbacks (ahbaqdadi) + * minor #67675 translation: checked proposed serbian translations (Trysha-rbrn) + * minor #67676 review(translation): [#64516] reviewed and updated the translation (mikocevar) + * minor #67649 Review translations for Estonian (et) (erkia) + ## 6.4.49 (2026-10-28) ### AssetMapper diff -Nru symfony-6.4.49+dfsg/debian/changelog symfony-6.4.50+dfsg/debian/changelog --- symfony-6.4.49+dfsg/debian/changelog 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/changelog 2026-11-04 15:33:54.000000000 +0000 @@ -1,3 +1,58 @@ +symfony (6.4.50+dfsg-0+deb13u1) trixie-security; urgency=medium + + [ Fabien Potencier ] + * Update VERSION for 6.4.50 + + [ Nicolas Grekas ] + * [Config] Do not validate XML documents on parse + (GHSA-5jff-8h8q-r5qx) [CVE-2026-123824] + * [HtmlSanitizer] Fix meta refresh URL sanitization bypass + (GHSA-wm2w-4mgj-vw33) [CVE-2026-121373] + * [Validator] Reject non-public IP addresses in the no-priv, no-res + and only-public modes (GHSA-4qpf-57w4-m3wc) [CVE-2026-123125] + * [HtmlSanitizer] Require special schemes and scheme-relative URLs to + carry a non-empty authority [CVE-2026-118517] + * [HttpClient] Don't send the options of the default scope to other + origins in ScopingHttpClient (GHSA-gq53-v8w2-gjhj) [CVE-2026-121971] + * [Security] Run the checkPostAuth() method of user checkers when + authenticating users programmatically + (GHSA-8f57-m8wj-x65w) [CVE-2026-123746] + * [HttpClient] Pin the IP of cached hosts in NoPrivateNetworkHttpClient + (GHSA-c7hm-wqwp-f9v8) [CVE-2026-123119] + * [Serializer] Lowercase the scheme of data URIs before opening them + (GHSA-pxj9-jh84-vhm5) [CVE-2026-121623] + * [Security] Parse the DN into RDNs before extracting the user identifier + [CVE-2026-118513] + * [Serializer] Don't build UploadedFile and other file objects from + denormalized data [CVE-2026-118515] + * [HtmlSanitizer] Apply the URL policy to srcset and imagesrcset + (GHSA-q8jq-xrf4-v5j8) [CVE-2026-121949] + * [HttpFoundation] Add the list of private ranges usable as trusted proxies + (GHSA-xg2q-4rrq-657q) + * [HttpKernel] Restore the router context of the parent request after a + sub-request (GHSA-wg5q-m2ww-f3c9) + * [Routing] Prevent host placeholders from matching the path in compiled + matchers [CVE-2026-118516] + * [Filesystem] Only treat a valid URI scheme as the root of a path + (GHSA-6prc-h956-hxjq) + * [Yaml] Fix exponential and quadratic parse times + (GHSA-hwfg-qwvh-2wfx) [CVE-2026-123792] + * [Serializer] Ignore undeclared property paths when denormalizing + (GHSA-hqp2-7rr8-hf26) [CVE-2026-122053] + * [HttpFoundation] Match HEAD requests when GET is listed in a RequestMatcher + [CVE-2026-118514] + * [HttpFoundation] Don't let Request::getBaseUrl() start with a host chosen by + the client (GHSA-5mx8-mwrp-5693) + + [ Jack Worman ] + * [HttpFoundation] Don't match the script name in Request::getBaseUrl() when + it's only the start of a path segment (GHSA-3h27-hjv7-fqjx) + + [ David Prévot ] + * Refresh patches + + -- David Prévot Wed, 04 Nov 2026 16:33:54 +0100 + symfony (6.4.49+dfsg-0+deb13u1) trixie-security; urgency=medium [ Fabien Potencier ] diff -Nru symfony-6.4.49+dfsg/debian/patches/Assume-php-async-aws-sqs-2.patch symfony-6.4.50+dfsg/debian/patches/Assume-php-async-aws-sqs-2.patch --- symfony-6.4.49+dfsg/debian/patches/Assume-php-async-aws-sqs-2.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Assume-php-async-aws-sqs-2.patch 2026-11-04 15:33:54.000000000 +0000 @@ -10,10 +10,10 @@ 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php -index b147f32..6699a36 100644 +index d9add5b..b45553d 100644 --- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php +++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php -@@ -599,8 +599,6 @@ class ConnectionTest extends TestCase +@@ -631,8 +631,6 @@ class ConnectionTest extends TestCase private function isAsyncAwsSqsVersion2Installed(): bool { diff -Nru symfony-6.4.49+dfsg/debian/patches/Cache-Disable-Test-failing-tests-Error-Class-not-found.patch symfony-6.4.50+dfsg/debian/patches/Cache-Disable-Test-failing-tests-Error-Class-not-found.patch --- symfony-6.4.49+dfsg/debian/patches/Cache-Disable-Test-failing-tests-Error-Class-not-found.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Cache-Disable-Test-failing-tests-Error-Class-not-found.patch 2026-11-04 15:33:54.000000000 +0000 @@ -9,8 +9,9 @@ .../Cache/Tests/Adapter/TagAwareAdapterTest.php | 272 --------------------- .../TagAwareAndProxyAdapterIntegrationTest.php | 1 + .../Cache/Tests/Adapter/TagAwareTestTrait.php | 9 + - .../Tests/Messenger/EarlyExpirationHandlerTest.php | 1 + - 5 files changed, 23 insertions(+), 272 deletions(-) + .../Messenger/EarlyExpirationDispatcherTest.php | 1 + + .../Tests/Messenger/EarlyExpirationHandlerTest.php | 5 + + 6 files changed, 28 insertions(+), 272 deletions(-) delete mode 100644 src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php @@ -402,15 +403,59 @@ $pool = $this->createCachePool(); $pool->clear(); +diff --git a/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php b/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php +index fc61143..e4a7eac 100644 +--- a/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php ++++ b/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php +@@ -145,6 +145,7 @@ class EarlyExpirationDispatcherTest extends TestCase + */ + public function testSynchronousEarlyExpirationReturnsNullAndSkipsDuplicates() + { ++ $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); + $pool = new class extends FilesystemAdapter { + public bool $failSave = false; + diff --git a/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php b/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php -index 963215d..d4c3a82 100644 +index 76fdb31..49bba02 100644 --- a/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php +++ b/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php -@@ -34,6 +34,7 @@ class EarlyExpirationHandlerTest extends TestCase - */ +@@ -53,6 +53,7 @@ class EarlyExpirationHandlerTest extends TestCase + public function testHandle() { + $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); $pool = new FilesystemAdapter(); $item = $pool->getItem('foo'); $item->set(234); +@@ -122,6 +123,7 @@ class EarlyExpirationHandlerTest extends TestCase + + public function testWorkerRetriesFailedCallbacks() + { ++ $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); + foreach ([new \RuntimeException('Failed computation.'), new \Error('Failed computation.')] as $failure) { + $calls = 0; + $currentRetry = 0; +@@ -184,6 +186,7 @@ class EarlyExpirationHandlerTest extends TestCase + + public function testFailureDoesNotClearReplacementComputation() + { ++ $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); + foreach ([false, true] as $evictOriginal) { + $calls = 0; + $handler = $replacement = null; +@@ -262,6 +265,7 @@ class EarlyExpirationHandlerTest extends TestCase + + public function testExpiredMessageIsSkippedAfterFailure() + { ++ $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); + $calls = 0; + $callback = static function () use (&$calls) { + if (1 === ++$calls) { +@@ -287,6 +291,7 @@ class EarlyExpirationHandlerTest extends TestCase + + public function testFailureWithoutMetadataCanBeRetried() + { ++ $this->markTestSkipped('Test failing on current Debian (Error: Class "�" not found).'); + $calls = 0; + $callback = static function () use (&$calls) { + if (1 === ++$calls) { diff -Nru symfony-6.4.49+dfsg/debian/patches/Drop-composer-runtime-api-that-confuses-phpabtpl.patch symfony-6.4.50+dfsg/debian/patches/Drop-composer-runtime-api-that-confuses-phpabtpl.patch --- symfony-6.4.49+dfsg/debian/patches/Drop-composer-runtime-api-that-confuses-phpabtpl.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Drop-composer-runtime-api-that-confuses-phpabtpl.patch 2026-11-04 15:33:54.000000000 +0000 @@ -34,7 +34,7 @@ "symfony/cache": "^5.4|^6.0|^7.0", "symfony/config": "^6.1|^7.0", diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json -index cd7d7b4..c55d9db 100644 +index 511e2cc..835249e 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -17,7 +17,6 @@ diff -Nru symfony-6.4.49+dfsg/debian/patches/Filesystem-Drop-currently-failing-tests.patch symfony-6.4.50+dfsg/debian/patches/Filesystem-Drop-currently-failing-tests.patch --- symfony-6.4.49+dfsg/debian/patches/Filesystem-Drop-currently-failing-tests.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Filesystem-Drop-currently-failing-tests.patch 2026-11-04 15:33:54.000000000 +0000 @@ -4,12 +4,12 @@ TODO: investigate --- - src/Symfony/Component/Filesystem/Tests/FilesystemTest.php | 12 ++++++++++++ - src/Symfony/Component/Filesystem/Tests/PathTest.php | 11 ++--------- - 2 files changed, 14 insertions(+), 9 deletions(-) + .../Component/Filesystem/Tests/FilesystemTest.php | 24 ++++++++++++++++++++++ + .../Component/Filesystem/Tests/PathTest.php | 22 ++++++-------------- + 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php b/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php -index f73c58a..edcfa68 100644 +index fab133c..6c4b0af 100644 --- a/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php +++ b/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php @@ -253,6 +253,8 @@ class FilesystemTest extends FilesystemTestCase @@ -21,7 +21,61 @@ $file = $this->workspace.\DIRECTORY_SEPARATOR.'1'; $this->filesystem->touch($file, 0); -@@ -1229,6 +1231,8 @@ class FilesystemTest extends FilesystemTestCase +@@ -365,6 +367,8 @@ class FilesystemTest extends FilesystemTestCase + */ + public function testRemoveDoesNotDeleteExistingTemporaryDirectories() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; +@@ -409,6 +413,8 @@ class FilesystemTest extends FilesystemTestCase + */ + public function testRemoveRetriesTemporaryDirectoryCollisions() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; +@@ -448,6 +454,8 @@ class FilesystemTest extends FilesystemTestCase + */ + public function testRemoveDoesNotInterfereWithConcurrentRemoval() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + require __DIR__.'/Fixtures/remove_collision.php'; + + $first = $this->workspace.'/first'; +@@ -538,6 +546,8 @@ class FilesystemTest extends FilesystemTestCase + */ + public function testRemoveIgnoresDirectoryRemovedConcurrently() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; +@@ -646,6 +656,8 @@ class FilesystemTest extends FilesystemTestCase + + public function testRemoveRestoresDirectoryWhenNestedRemovalFails() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + $this->markAsSkippedIfChmodIsMissing(); + + $directory = $this->workspace.'/directory'; +@@ -669,6 +681,8 @@ class FilesystemTest extends FilesystemTestCase + + public function testRemoveThrowsExceptionOnUnreadableDirectory() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + $this->markAsSkippedIfChmodIsMissing(); + + $directory = $this->workspace.'/directory'; +@@ -1538,6 +1552,8 @@ class FilesystemTest extends FilesystemTestCase public function testMakePathRelativeWithExistingFile() { @@ -30,7 +84,7 @@ $dir = $this->workspace.\DIRECTORY_SEPARATOR.'foo'.\DIRECTORY_SEPARATOR.'bar'; mkdir($dir, 0o777, true); $file = $dir.\DIRECTORY_SEPARATOR.'test.txt'; -@@ -1282,6 +1286,8 @@ class FilesystemTest extends FilesystemTestCase +@@ -1591,6 +1607,8 @@ class FilesystemTest extends FilesystemTestCase public function testMirrorWithCustomIteratorAndDeleteOption() { @@ -39,7 +93,7 @@ $sourcePath = $this->workspace.\DIRECTORY_SEPARATOR.'source-with-a-longer-name'.\DIRECTORY_SEPARATOR; $targetPath = $this->workspace.\DIRECTORY_SEPARATOR.'target'.\DIRECTORY_SEPARATOR; -@@ -1505,6 +1511,8 @@ class FilesystemTest extends FilesystemTestCase +@@ -1814,6 +1832,8 @@ class FilesystemTest extends FilesystemTestCase public function testTempnamWithSuffixIsPrivate() { @@ -48,7 +102,7 @@ if ('\\' === \DIRECTORY_SEPARATOR) { $this->markTestSkipped('This test cannot run on Windows.'); } -@@ -1523,6 +1531,8 @@ class FilesystemTest extends FilesystemTestCase +@@ -1832,6 +1852,8 @@ class FilesystemTest extends FilesystemTestCase public function testTempnamTrimsTrailingWhitespaceFromTruncatedPrefix() { @@ -57,7 +111,7 @@ // PHP's tempnam() truncates the prefix to 63 characters; if that leaves a // trailing space, the file creation fails on Windows. See #64722. $prefix = str_repeat('a', 62).' suffix'; -@@ -1904,6 +1914,7 @@ class FilesystemTest extends FilesystemTestCase +@@ -2213,6 +2235,7 @@ class FilesystemTest extends FilesystemTestCase public function testCopyShouldKeepExecutionPermission() { $this->markAsSkippedIfChmodIsMissing(); @@ -65,7 +119,7 @@ $sourceFilePath = $this->workspace.\DIRECTORY_SEPARATOR.'copy_source_file'; $targetFilePath = $this->workspace.\DIRECTORY_SEPARATOR.'copy_target_file'; -@@ -1919,6 +1930,7 @@ class FilesystemTest extends FilesystemTestCase +@@ -2228,6 +2251,7 @@ class FilesystemTest extends FilesystemTestCase public function testCopyShouldKeepRestrictivePermissions() { $this->markAsSkippedIfChmodIsMissing(); @@ -74,7 +128,7 @@ $sourceFilePath = $this->workspace.\DIRECTORY_SEPARATOR.'copy_source_file'; $targetFilePath = $this->workspace.\DIRECTORY_SEPARATOR.'copy_target_file'; diff --git a/src/Symfony/Component/Filesystem/Tests/PathTest.php b/src/Symfony/Component/Filesystem/Tests/PathTest.php -index 8363c22..638c860 100644 +index a705802..7794aa2 100644 --- a/src/Symfony/Component/Filesystem/Tests/PathTest.php +++ b/src/Symfony/Component/Filesystem/Tests/PathTest.php @@ -379,9 +379,6 @@ class PathTest extends TestCase @@ -100,7 +154,43 @@ yield ['..', '/', '']; yield ['../..', '/', '']; yield ['..', 'phar:///', '']; -@@ -1151,6 +1142,8 @@ class PathTest extends TestCase +@@ -1127,6 +1118,8 @@ class PathTest extends TestCase + + public function testInvalidSchemeIsNotARoot() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + $this->assertFalse(Path::isBasePath('/base', '/base/../../etc/x://../passwd')); + $this->assertFalse(Path::isBasePath('/base', '/base/x://../../etc/passwd')); + $this->assertSame('/etc/passwd', Path::canonicalize('/base/../../etc/x://../passwd')); +@@ -1148,24 +1141,19 @@ class PathTest extends TestCase + $this->assertFalse(Path::isAbsolute('a/b:///c')); + $this->assertFalse(Path::isAbsolute('a_b:///c')); + $this->assertFalse(Path::isAbsolute(':///c')); +- +- $this->assertSame('/', Path::getRoot('/etc/x://y')); +- $this->assertSame('', Path::getRoot('a/b:///c')); +- +- $this->assertSame('/a/b:/x/c', Path::makeAbsolute('c', '/a/b://x')); + } + + public function testIsLocalIgnoresInvalidScheme() + { ++ $this->markTestSkipped('Test currently failing on Debian'); ++ + $this->assertTrue(Path::isLocal('/etc/x://y')); + $this->assertTrue(Path::isLocal('a/b://c')); + } + + public function testSingleLetterBeforeDoubleSlashIsNotAScheme() + { +- $this->assertSame(Path::canonicalize('x:/../y'), Path::canonicalize('x://../y')); + $this->assertSame(Path::getRoot('x:/y'), Path::getRoot('x://y')); +- $this->assertTrue(Path::isLocal('x://y')); + } + + public function testValidSchemeIsARoot() +@@ -1214,6 +1202,8 @@ class PathTest extends TestCase public function testNormalize() { diff -Nru symfony-6.4.49+dfsg/debian/patches/Group-nophpunit11-for-tests-failing-with-PHPUnit-11.patch symfony-6.4.50+dfsg/debian/patches/Group-nophpunit11-for-tests-failing-with-PHPUnit-11.patch --- symfony-6.4.49+dfsg/debian/patches/Group-nophpunit11-for-tests-failing-with-PHPUnit-11.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Group-nophpunit11-for-tests-failing-with-PHPUnit-11.patch 2026-11-04 15:33:54.000000000 +0000 @@ -27,7 +27,7 @@ .../Finder/Tests/FinderOpenBasedirTest.php | 9 ----- .../Form/Test/FormPerformanceTestCase.php | 4 --- .../Component/Form/Tests/VersionAwareTest.php | 24 ------------- - .../Tests/NoPrivateNetworkHttpClientTest.php | 2 ++ + .../Tests/NoPrivateNetworkHttpClientTest.php | 3 ++ .../Constraint/RequestAttributeValueSameTest.php | 3 ++ .../Constraint/ResponseCookieValueSameTest.php | 3 ++ .../Test/Constraint/ResponseFormatSameTest.php | 6 ++++ @@ -62,7 +62,7 @@ .../Tests/Constraints/RangeValidatorTest.php | 2 +- .../Mapping/Loader/PropertyInfoLoaderTest.php | 3 ++ .../VarDumper/Tests/Caster/DoctrineCasterTest.php | 3 ++ - 59 files changed, 179 insertions(+), 165 deletions(-) + 59 files changed, 180 insertions(+), 165 deletions(-) delete mode 100644 src/Symfony/Bridge/PhpUnit/Tests/expectdeprecationfail.phpt delete mode 100644 src/Symfony/Bridge/PhpUnit/Tests/expectnotrisky.phpt delete mode 100644 src/Symfony/Bridge/PhpUnit/Tests/expectrisky.phpt @@ -277,7 +277,7 @@ */ public function testMapRequestPayload(string $format, array $parameters, ?string $content, callable $responseAssertion, int $expectedStatusCode) diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php -index 6093348..479efc3 100644 +index 3952317..0c656d0 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php @@ -41,6 +41,9 @@ class RemotePackageStorageTest extends TestCase @@ -570,7 +570,7 @@ - } -} diff --git a/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php -index dd60a40..a286a62 100644 +index 43eff8f..6e0fb2a 100644 --- a/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php @@ -71,6 +71,7 @@ class NoPrivateNetworkHttpClientTest extends TestCase @@ -589,6 +589,14 @@ * @group dns-sensitive */ public function testExcludeByHost(string $ipAddr, $subnets, bool $mustThrow) +@@ -145,6 +147,7 @@ class NoPrivateNetworkHttpClientTest extends TestCase + } + + /** ++ * @group nophpunit11 + * @group dns-sensitive + */ + public function testCachedHostIsResolvedToTheCheckedIp() diff --git a/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/RequestAttributeValueSameTest.php b/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/RequestAttributeValueSameTest.php index c7ee239..7951bd6 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Test/Constraint/RequestAttributeValueSameTest.php @@ -953,10 +961,10 @@ */ public function testThrowsAnErrorIfTokenIsInvalid(string $token) diff --git a/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php b/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php -index 3004e8d..7fc73ea 100644 +index 1020934..47918a7 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php -@@ -329,6 +329,9 @@ class AuthenticatorManagerTest extends TestCase +@@ -355,6 +355,9 @@ class AuthenticatorManagerTest extends TestCase $this->assertSame($this->response, $response); } @@ -1054,10 +1062,10 @@ /** diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php -index d781993..a312229 100644 +index b2d8c69..c3cfd68 100644 --- a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php -@@ -60,6 +60,7 @@ class PhraseProviderTest extends TestCase +@@ -62,6 +62,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider readProvider @@ -1065,7 +1073,7 @@ */ public function testRead(string $locale, string $localeId, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) { -@@ -110,6 +111,7 @@ class PhraseProviderTest extends TestCase +@@ -112,6 +113,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider readProvider @@ -1073,7 +1081,7 @@ */ public function testReadCached(string $locale, string $localeId, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) { -@@ -262,6 +264,7 @@ class PhraseProviderTest extends TestCase +@@ -294,6 +296,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider cacheKeyProvider @@ -1081,7 +1089,7 @@ */ public function testCacheKeyOptionsSort(array $options, string $expectedKey) { -@@ -294,6 +297,7 @@ class PhraseProviderTest extends TestCase +@@ -326,6 +329,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider cacheItemProvider @@ -1089,7 +1097,7 @@ */ public function testGetCacheItem(mixed $cachedValue, bool $hasMatchHeader) { -@@ -378,6 +382,7 @@ class PhraseProviderTest extends TestCase +@@ -410,6 +414,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider readProviderExceptionsProvider @@ -1097,7 +1105,7 @@ */ public function testReadProviderExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) { -@@ -415,6 +420,7 @@ class PhraseProviderTest extends TestCase +@@ -447,6 +452,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider initLocalesExceptionsProvider @@ -1105,7 +1113,7 @@ */ public function testInitLocalesExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) { -@@ -664,6 +670,7 @@ class PhraseProviderTest extends TestCase +@@ -696,6 +702,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider createLocalesExceptionsProvider @@ -1113,7 +1121,7 @@ */ public function testCreateLocaleExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) { -@@ -753,6 +760,7 @@ class PhraseProviderTest extends TestCase +@@ -785,6 +792,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider deleteExceptionsProvider @@ -1121,7 +1129,7 @@ */ public function testDeleteProviderExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) { -@@ -796,6 +804,7 @@ class PhraseProviderTest extends TestCase +@@ -828,6 +836,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider writeProvider @@ -1129,7 +1137,7 @@ */ public function testWrite(string $locale, string $localeId, string $domain, string $content, TranslatorBag $bag) { -@@ -944,6 +953,7 @@ class PhraseProviderTest extends TestCase +@@ -976,6 +985,7 @@ class PhraseProviderTest extends TestCase /** * @dataProvider writeExceptionsProvider @@ -1250,10 +1258,10 @@ public function testThrowsOnInvalidStringDates($expectedMessage, $value, $min, $max) { diff --git a/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php -index 1ffb6be..7a0a55f 100644 +index 55e1505..ccb4714 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php -@@ -34,6 +34,9 @@ use Symfony\Component\Validator\Validation; +@@ -37,6 +37,9 @@ use Symfony\Component\Validator\Validation; */ class PropertyInfoLoaderTest extends TestCase { diff -Nru symfony-6.4.49+dfsg/debian/patches/HttpClient-Skip-tests-relying-on-amphp-http-client.patch symfony-6.4.50+dfsg/debian/patches/HttpClient-Skip-tests-relying-on-amphp-http-client.patch --- symfony-6.4.49+dfsg/debian/patches/HttpClient-Skip-tests-relying-on-amphp-http-client.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/HttpClient-Skip-tests-relying-on-amphp-http-client.patch 2026-11-04 15:33:54.000000000 +0000 @@ -8,10 +8,10 @@ 2 files changed, 14 insertions(+) diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php -index 5add806..e91eda8 100644 +index 91c7205..f8f4fa9 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php -@@ -35,6 +35,13 @@ The vulcain binary can be found at https://github.com/symfony/binary-utils/relea +@@ -36,6 +36,13 @@ The vulcain binary can be found at https://github.com/symfony/binary-utils/relea abstract class HttpClientTestCase extends BaseHttpClientTestCase { diff -Nru symfony-6.4.49+dfsg/debian/patches/Lock-Drop-test-currently-failing-on-buildd.patch symfony-6.4.50+dfsg/debian/patches/Lock-Drop-test-currently-failing-on-buildd.patch --- symfony-6.4.49+dfsg/debian/patches/Lock-Drop-test-currently-failing-on-buildd.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Lock-Drop-test-currently-failing-on-buildd.patch 2026-11-04 15:33:54.000000000 +0000 @@ -8,10 +8,10 @@ 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php -index 7d71810..2390963 100644 +index 8ad7265..74bc3e0 100644 --- a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php -@@ -272,7 +272,6 @@ class DoctrineDbalStoreTest extends AbstractStoreTestCase +@@ -273,7 +273,6 @@ class DoctrineDbalStoreTest extends AbstractStoreTestCase yield [\Doctrine\DBAL\Platforms\PostgreSQL94Platform::class]; } diff -Nru symfony-6.4.49+dfsg/debian/patches/Remove-calls-to-getExpectedException.patch symfony-6.4.50+dfsg/debian/patches/Remove-calls-to-getExpectedException.patch --- symfony-6.4.49+dfsg/debian/patches/Remove-calls-to-getExpectedException.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Remove-calls-to-getExpectedException.patch 2026-11-04 15:33:54.000000000 +0000 @@ -8,7 +8,7 @@ 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php -index 769bcad..25d5491 100644 +index a8bfc17..7c5f8c8 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php @@ -445,7 +445,7 @@ class UrlMatcherTest extends TestCase diff -Nru symfony-6.4.49+dfsg/debian/patches/Yaml-Drop-data-set-currently-failing-on-i386.patch symfony-6.4.50+dfsg/debian/patches/Yaml-Drop-data-set-currently-failing-on-i386.patch --- symfony-6.4.49+dfsg/debian/patches/Yaml-Drop-data-set-currently-failing-on-i386.patch 2026-10-28 19:28:35.000000000 +0000 +++ symfony-6.4.50+dfsg/debian/patches/Yaml-Drop-data-set-currently-failing-on-i386.patch 2026-11-04 15:33:54.000000000 +0000 @@ -7,10 +7,10 @@ 1 file changed, 2 deletions(-) diff --git a/src/Symfony/Component/Yaml/Tests/InlineTest.php b/src/Symfony/Component/Yaml/Tests/InlineTest.php -index e19a740..da4b28d 100644 +index 28ab52f..925efd6 100644 --- a/src/Symfony/Component/Yaml/Tests/InlineTest.php +++ b/src/Symfony/Component/Yaml/Tests/InlineTest.php -@@ -386,7 +386,6 @@ class InlineTest extends TestCase +@@ -425,7 +425,6 @@ class InlineTest extends TestCase ['2007-10-30T02:59:43Z', gmmktime(2, 59, 43, 10, 30, 2007)], ['2007-10-30 02:59:43 Z', gmmktime(2, 59, 43, 10, 30, 2007)], ['1960-10-30 02:59:43 Z', gmmktime(2, 59, 43, 10, 30, 1960)], @@ -18,7 +18,7 @@ ['"a \\"string\\" with \'quoted strings inside\'"', 'a "string" with \'quoted strings inside\''], ["'a \"string\" with ''quoted strings inside'''", 'a "string" with \'quoted strings inside\''], -@@ -464,7 +463,6 @@ class InlineTest extends TestCase +@@ -510,7 +509,6 @@ class InlineTest extends TestCase ['2007-10-30T02:59:43Z', gmmktime(2, 59, 43, 10, 30, 2007)], ['2007-10-30 02:59:43 Z', gmmktime(2, 59, 43, 10, 30, 2007)], ['1960-10-30 02:59:43 Z', gmmktime(2, 59, 43, 10, 30, 1960)], diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/ControllerResolverTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/ControllerResolverTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/ControllerResolverTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/ControllerResolverTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -99,7 +99,6 @@ $request->attributes->set('_controller', TestAbstractController::class.'::fooAction'); $this->assertSame([$controller, 'fooAction'], $resolver->getController($request)); - $this->assertSame($container, $controller->setContainer($container)); } public function testAbstractControllerServiceWithFqcnIdGetsContainerWhenNotSet() @@ -120,7 +119,6 @@ $request->attributes->set('_controller', DummyController::class.'::fooAction'); $this->assertSame([$controller, 'fooAction'], $resolver->getController($request)); - $this->assertSame($container, $controller->getContainer()); } public function testAbstractControllerGetsNoContainerWhenSet() diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/TranslatorTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/TranslatorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/TranslatorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/FrameworkBundle/Tests/Translation/TranslatorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -30,7 +30,7 @@ protected function setUp(): void { - $this->tmpDir = sys_get_temp_dir().'/sf_translation'; + $this->tmpDir = sys_get_temp_dir().'/sf_translator_test'; $this->deleteTmpDir(); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -28,6 +28,7 @@ use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestMatcher\PathRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Ldap\Ldap; @@ -549,6 +550,53 @@ $container->compile(); } + public function testAccessControlRuleRestrictedToGetAppliesToHeadRequests() + { + $container = $this->getRawContainer(); + $container->loadFromExtension('security', [ + 'providers' => [ + 'default' => ['id' => 'foo'], + ], + 'firewalls' => [ + 'main' => ['pattern' => '^/', 'http_basic' => []], + ], + 'access_control' => [ + ['path' => '^/admin', 'roles' => 'ROLE_ADMIN', 'methods' => ['GET']], + ['path' => '^/upload', 'roles' => 'ROLE_USER', 'methods' => ['POST']], + ], + ]); + $container->compile(); + + [$adminRule, $uploadRule] = $container->getDefinition('security.access_map')->getMethodCalls(); + + $matcher = $this->createRequestMatcher($container, $adminRule[1][0]); + $this->assertTrue($matcher->matches(Request::create('/admin', 'GET'))); + $this->assertTrue($matcher->matches(Request::create('/admin', 'HEAD'))); + + $matcher = $this->createRequestMatcher($container, $uploadRule[1][0]); + $this->assertFalse($matcher->matches(Request::create('/upload', 'HEAD'))); + $this->assertTrue($matcher->matches(Request::create('/upload', 'POST'))); + } + + private function createRequestMatcher(ContainerBuilder $container, $reference): RequestMatcherInterface + { + $definition = $container->getDefinition((string) $reference); + $class = $definition->getClass(); + $arguments = []; + + foreach ($definition->getArguments() as $argument) { + if (\is_array($argument)) { + $argument = array_map(fn ($child) => $child instanceof Reference ? $this->createRequestMatcher($container, $child) : $child, $argument); + } elseif ($argument instanceof Reference) { + $argument = $this->createRequestMatcher($container, $argument); + } + + $arguments[] = $argument; + } + + return new $class(...$arguments); + } + public function testRemovesExpressionCacheWarmerDefinitionIfNoExpressions() { $container = $this->getRawContainer(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/SecurityBundle/composer.json symfony-6.4.50+dfsg/src/Symfony/Bundle/SecurityBundle/composer.json --- symfony-6.4.49+dfsg/src/Symfony/Bundle/SecurityBundle/composer.json 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/SecurityBundle/composer.json 2026-11-04 11:23:00.000000000 +0000 @@ -25,7 +25,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^6.2", - "symfony/http-foundation": "^6.2|^7.0", + "symfony/http-foundation": "^6.4.50|^7.4.23", "symfony/password-hasher": "^5.4|^6.0|^7.0", "symfony/security-core": "^6.4.49|^7.4.22", "symfony/security-csrf": "^5.4|^6.0|^7.0", diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php 2026-11-04 11:23:00.000000000 +0000 @@ -369,12 +369,12 @@ throw new NotFoundHttpException('The base dir should be set.'); } - $file = $request->query->get('file'); - $line = $request->query->get('line'); + $file = $request->query->getString('file'); + $line = $request->query->getInt('line'); $filename = $this->baseDir.\DIRECTORY_SEPARATOR.$file; - if (preg_match("'(^|[/\\\\])\.'", $file) || !is_readable($filename)) { + if ('' === $file || preg_match("'(^|[/\\\\])\.'", $file) || !is_file($filename) || !is_readable($filename)) { throw new NotFoundHttpException(\sprintf('The file "%s" cannot be opened.', $file)); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -76,6 +76,11 @@ $this->cspDisabled = true; } + public function reset(): void + { + $this->cspDisabled = false; + } + /** * Cleanup temporary headers and updates Content-Security-Policy headers. * diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.php 2026-11-04 11:23:00.000000000 +0000 @@ -55,6 +55,7 @@ ->args([ inline_service(NonceGenerator::class), ]) + ->tag('kernel.reset', ['method' => 'reset']) ->set('twig.extension.webprofiler', WebProfilerExtension::class) ->args([ @@ -63,6 +64,7 @@ ->call('setDisplayOptions', [['maxStringLength' => 4096, 'fileLinkFormat' => service('debug.file_link_formatter')]]), ]) ->tag('twig.extension') + ->tag('kernel.reset', ['method' => 'reset']) ->set('debug.file_link_formatter', FileLinkFormatter::class) ->args([ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -98,6 +98,18 @@ $this->assertSame(404, $client->getResponse()->getStatusCode()); } + public function testProfilerPageDoesNotDisableCspOfLaterRequests() + { + $kernel = new WebProfilerBundleKernel(); + $client = new KernelBrowser($kernel); + $client->disableReboot(); + + $client->request('GET', '/_profiler/latest'); + $client->request('GET', '/csp'); + + $this->assertTrue($client->getResponse()->headers->has('Content-Security-Policy')); + } + public function testPanelActionWithValidPanelAndToken() { $kernel = new WebProfilerBundleKernel(); @@ -186,9 +198,43 @@ ['../TwigBundle/README.md', false], ['Controller/../README.md', false], ['Controller/./ProfilerController.php', false], + ['', false], + ['Controller', false], ]; } + public function testOpeningWithoutFile() + { + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $twig = $this->createStub(Environment::class); + $profiler = $this->createStub(Profiler::class); + + $controller = new ProfilerController($urlGenerator, $profiler, $twig, [], null, __DIR__.'/../..'); + + $this->expectException(NotFoundHttpException::class); + + $controller->openAction(Request::create('/_profiler/open')); + } + + public function testOpeningPassesTheLineAsAnInteger() + { + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $profiler = $this->createStub(Profiler::class); + $lines = []; + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturnCallback(static function (string $template, array $context) use (&$lines): string { + $lines[] = $context['line']; + + return ''; + }); + + $controller = new ProfilerController($urlGenerator, $profiler, $twig, [], null, __DIR__.'/../..'); + $controller->openAction(Request::create('/_profiler/open', Request::METHOD_GET, ['file' => 'README.md', 'line' => '12'])); + $controller->openAction(Request::create('/_profiler/open', Request::METHOD_GET, ['file' => 'README.md'])); + + $this->assertSame([12, 0], $lines); + } + /** * @dataProvider provideCspVariants */ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -219,4 +219,18 @@ return $generator; } + + public function testResetReEnablesCsp() + { + $cspHandler = new ContentSecurityPolicyHandler($this->createMock(NonceGenerator::class)); + + $cspHandler->disableCsp(); + $cspHandler->reset(); + + $response = new Response(); + $response->headers->set('Content-Security-Policy', "default-src 'self'"); + $cspHandler->updateResponseHeaders(new Request(), $response); + + $this->assertTrue($response->headers->has('Content-Security-Policy')); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -103,6 +103,7 @@ $this->container->removeDefinition('web_profiler.controller.exception'); $this->assertFalse($this->container->has('web_profiler.debug_toolbar')); + $this->assertSame([['method' => 'reset']], $this->container->getDefinition('twig.extension.webprofiler')->getTag('kernel.reset')); self::assertSaneContainer($this->getCompiledContainer()); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php 2026-11-04 11:23:00.000000000 +0000 @@ -46,6 +46,7 @@ $routes->import(__DIR__.'/../../Resources/config/routing/profiler.xml')->prefix('/_profiler'); $routes->import(__DIR__.'/../../Resources/config/routing/wdt.xml')->prefix('/_wdt'); $routes->add('_', '/')->controller('kernel::homepageController'); + $routes->add('csp', '/csp')->controller('kernel::cspController'); } protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void @@ -92,4 +93,9 @@ { return new Response('Homepage Controller.'); } + + public function cspController(): Response + { + return new Response('CSP Controller.', 200, ['Content-Security-Policy' => "default-src 'self'"]); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Twig/WebProfilerExtensionTest.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Twig/WebProfilerExtensionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Twig/WebProfilerExtensionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Tests/Twig/WebProfilerExtensionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -15,6 +15,7 @@ use Symfony\Bundle\WebProfilerBundle\Twig\WebProfilerExtension; use Symfony\Component\VarDumper\Cloner\VarCloner; use Twig\Environment; +use Twig\Error\LoaderError; use Twig\Loader\ArrayLoader; class WebProfilerExtensionTest extends TestCase @@ -38,6 +39,26 @@ self::assertSame($dump2HasHeader, str_contains($dump2, $needle)); } + public function testDumpHeaderIsDisplayedAfterResetFollowingATemplateError() + { + $extension = new WebProfilerExtension(); + $twig = new Environment(new ArrayLoader([ + 'dump' => '{{ profiler_dump(data) }}', + 'error' => '{{ include("missing.html.twig") }}', + ])); + $twig->addExtension($extension); + $data = (new VarCloner())->cloneVar([]); + + try { + $twig->render('error'); + } catch (LoaderError) { + } + $twig->render('dump', ['data' => $data]); + $extension->reset(); + + $this->assertStringContainsString('window.Sfdump', $twig->render('dump', ['data' => $data])); + } + public static function provideMessages(): iterable { yield ['Some message', ['foo' => 'foo', 'bar' => 'bar'], false, true]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Twig/WebProfilerExtension.php symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Twig/WebProfilerExtension.php --- symfony-6.4.49+dfsg/src/Symfony/Bundle/WebProfilerBundle/Twig/WebProfilerExtension.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Bundle/WebProfilerBundle/Twig/WebProfilerExtension.php 2026-11-04 11:23:00.000000000 +0000 @@ -60,6 +60,12 @@ } } + public function reset(): void + { + $this->stackLevel = 0; + $this->dumper->setOutput($this->output = fopen('php://memory', 'r+')); + } + public function getFunctions(): array { return [ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php 2026-11-04 11:23:00.000000000 +0000 @@ -205,6 +205,10 @@ private function cleanupPackageFiles(ImportMapEntry $entry): void { + if (!$entry->isRemotePackage()) { + return; + } + $asset = $this->findAsset($entry->path); if ($asset && is_file($asset->sourcePath)) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageStorage.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageStorage.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageStorage.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/RemotePackageStorage.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\AssetMapper\ImportMap; use Symfony\Component\AssetMapper\Exception\RuntimeException; +use Symfony\Component\Filesystem\Path; /** * Manages the local storage of remote/vendor importmap packages. @@ -93,11 +94,11 @@ $filename .= '.'.$importMapType->value; } - return $this->vendorDir.'/'.$filename; + return $this->vendorDir.'/'.ltrim(Path::canonicalize('/'.$filename), '/'); } private function getExtraFileDownloadPath(ImportMapEntry $entry, string $extraFilename): string { - return $this->vendorDir.'/'.$entry->getPackageName().'/'.ltrim($extraFilename, '/'); + return $this->vendorDir.'/'.$entry->getPackageName().'/'.ltrim(Path::canonicalize('/'.$extraFilename), '/'); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php 2026-11-04 11:23:00.000000000 +0000 @@ -284,9 +284,10 @@ preg_match_all(self::IMPORT_REGEX, $content, $matches); $dependencies = []; foreach ($matches[2] as $index => $packageName) { - $version = $matches[3][$index] ?: null; + $version = $matches[3][$index] ? rawurldecode($matches[3][$index]) : null; $packageName .= $matches[4][$index]; // add the path if any + self::checkPackagePath($packageName); $dependencies[] = new PackageRequireOptions($packageName, $version); } @@ -303,6 +304,7 @@ if (ImportMapType::JS === $type) { $content = preg_replace_callback(self::IMPORT_REGEX, static function ($matches) use (&$dependencies) { $packageName = $matches[2].$matches[4]; // add the path if any + self::checkPackagePath($packageName); $dependencies[] = $packageName; // replace the "/npm/package@version/+esm" with "package@version" @@ -328,12 +330,19 @@ continue; } - $extraFiles[] = Path::join(\dirname($sourceFilePath), $path); + $extraFiles[] = Path::join(\dirname('/'.$sourceFilePath), $path); } return preg_replace('{/\*# sourceMappingURL=[^ ]*+ \*/}', '', $content); } + private static function checkPackagePath(string $packageName): void + { + if (preg_match('{(?:^|/)\.\.?(?:/|$)}', $packageName)) { + throw new RuntimeException(\sprintf('Invalid import "%s": "." and ".." path segments are not allowed.', $packageName)); + } + } + /** * Determine the URL pattern to be used by the HTTP Client. */ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -470,16 +470,16 @@ if ('\\' !== \DIRECTORY_SEPARATOR) { $this->markTestSkipped('Must be on windows where dirname() understands backslashes'); } - $inputAsset = new MappedAsset('app.js', 'C:\\\\project\\assets\\app.js', publicPathWithoutDigest: '/assets/app.js'); + $inputAsset = new MappedAsset('app.js', 'C:\\project\\assets\\app.js', publicPathWithoutDigest: '/assets/app.js'); $assetMapper = $this->createStub(AssetMapperInterface::class); $assetMapper ->method('getAssetFromSourcePath') ->willReturnCallback(static function ($path) { return match ($path) { - 'C://project/assets/other.js' => new MappedAsset('other.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/other.js'), - 'C://project/assets/subdir/foo.js' => new MappedAsset('subdir/foo.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/subdir/foo.js'), - 'C://project/root_asset.js' => new MappedAsset('root_asset.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/root_asset.js'), + 'C:/project/assets/other.js' => new MappedAsset('other.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/other.js'), + 'C:/project/assets/subdir/foo.js' => new MappedAsset('subdir/foo.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/subdir/foo.js'), + 'C:/project/root_asset.js' => new MappedAsset('root_asset.js', '/can/be/anything.js', publicPathWithoutDigest: '/assets/root_asset.js'), default => throw new \RuntimeException(\sprintf('Unexpected source path "%s"', $path)), }; }); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -299,6 +299,35 @@ yield 'package listed twice' => [['lodash', 'lodash'], 'lodash']; } + public function testRemoveOnlyDeletesDownloadedFiles() + { + $manager = $this->createImportMapManager(); + $this->writeFile('assets/app.js', 'app contents'); + $this->writeFile('vendor/symfony/stimulus-bundle/assets/dist/loader.js', 'loader contents'); + $this->writeFile('assets/vendor/lodash.js', 'lodash contents'); + $this->assetMapper + ->method('getAssetFromSourcePath') + ->willReturnCallback(static fn (string $sourcePath) => new MappedAsset(basename($sourcePath), $sourcePath)) + ; + $this->configReader + ->method('convertPathToFilesystemPath') + ->willReturnCallback(static fn (string $path) => str_starts_with($path, './') ? self::$writableRoot.substr($path, 1) : $path) + ; + $this->mockImportMap([ + self::createLocalEntry('app', './assets/app.js'), + self::createLocalEntry('@symfony/stimulus-bundle', './vendor/symfony/stimulus-bundle/assets/dist/loader.js'), + self::createRemoteEntry('lodash', '1.2.3', self::$writableRoot.'/assets/vendor/lodash.js'), + ]); + + $this->configReader->expects($this->once())->method('writeEntries'); + + $manager->remove(['app', '@symfony/stimulus-bundle', 'lodash']); + + $this->assertFileExists(self::$writableRoot.'/assets/app.js'); + $this->assertFileExists(self::$writableRoot.'/vendor/symfony/stimulus-bundle/assets/dist/loader.js'); + $this->assertFileDoesNotExist(self::$writableRoot.'/assets/vendor/lodash.js'); + } + public function testUpdateAll() { $this->packageResolver = $this->createMock(PackageResolverInterface::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/RemotePackageStorageTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -105,6 +105,17 @@ $this->assertEquals('any content', file_get_contents($targetPath)); } + public function testSaveExtraFileKeepsItInThePackageDirectory() + { + $storage = new RemotePackageStorage(self::$writableRoot.'/assets/vendor'); + $entry = ImportMapEntry::createRemote('foo', ImportMapType::JS, '/does/not/matter', '1.0.0', 'module_specifier', false); + $storage->saveExtraFile($entry, '../../extra-file.woff2', 'any content'); + $targetPath = self::$writableRoot.'/assets/vendor/module_specifier/extra-file.woff2'; + $this->assertFileExists($targetPath); + $this->assertTrue($storage->isExtraFileDownloaded($entry, '../../extra-file.woff2')); + $this->assertFileDoesNotExist(self::$writableRoot.'/extra-file.woff2'); + } + /** * @dataProvider getDownloadPathTests */ @@ -139,5 +150,11 @@ 'importMapType' => ImportMapType::CSS, 'expectedPath' => '/assets/vendor/foo/bar.css', ]; + + yield 'javascript package with dot segments in its path' => [ + 'packageModuleSpecifier' => 'foo/../../../bar@1.0.0/bar.js', + 'importMapType' => ImportMapType::JS, + 'expectedPath' => '/assets/vendor/bar@1.0.0/bar.js', + ]; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/AssetMapper/Tests/ImportMap/Resolver/JsDelivrEsmResolverTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\AssetMapper\Tests\ImportMap\Resolver; use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Exception\RuntimeException; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapType; use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions; @@ -192,6 +193,43 @@ ], ]; + yield 'require package that imports another with an encoded version constraint' => [ + 'packages' => [new PackageRequireOptions('prosemirror-model', '^1.25')], + 'expectedRequests' => [ + [ + 'url' => '/v1/packages/npm/prosemirror-model/resolved?specifier=%5E1.25', + 'response' => ['body' => ['version' => '1.25.12']], + ], + [ + 'url' => '/prosemirror-model@1.25.12/+esm', + 'response' => ['body' => 'import t from"/npm/orderedmap@%5E2.0.0/+esm";function e(){}'], + ], + [ + 'url' => '/v1/packages/npm/prosemirror-model@1.25.12/entrypoints', + 'response' => ['body' => ['entrypoints' => []]], + ], + [ + 'url' => '/v1/packages/npm/orderedmap/resolved?specifier=%5E2.0.0', + 'response' => ['body' => ['version' => '2.1.1']], + ], + [ + 'url' => '/orderedmap@2.1.1/+esm', + ], + [ + 'url' => '/v1/packages/npm/orderedmap@2.1.1/entrypoints', + 'response' => ['body' => ['entrypoints' => []]], + ], + ], + 'expectedResolvedPackages' => [ + 'prosemirror-model' => [ + 'version' => '1.25.12', + ], + 'orderedmap' => [ + 'version' => '2.1.1', + ], + ], + ]; + yield 'require single CSS package' => [ 'packages' => [new PackageRequireOptions('bootstrap/dist/css/bootstrap.min.css')], 'expectedRequests' => [ @@ -539,6 +577,78 @@ ]); } + public function testDownloadCssFileAtPackageRootWithUrlReferences() + { + $expectedRequests = [ + [ + 'url' => '/npm/some-css-package@1.0.0', + 'body' => '@font-face{src:url("fonts/a.woff2") format("woff2"),url("../b.woff2") format("woff2")}', + ], + [ + 'url' => '/npm/some-css-package@1.0.0/fonts/a.woff2', + 'body' => 'a font contents', + ], + [ + 'url' => '/npm/some-css-package@1.0.0/b.woff2', + 'body' => 'b font contents', + ], + ]; + $responses = []; + foreach ($expectedRequests as $expectedRequest) { + $responses[] = function ($method, $url) use ($expectedRequest) { + $this->assertSame('GET', $method); + $this->assertStringEndsWith($expectedRequest['url'], $url); + + return new MockResponse($expectedRequest['body']); + }; + } + + $httpClient = new MockHttpClient($responses); + + $provider = new JsDelivrEsmResolver($httpClient); + $actualReturn = $provider->downloadPackages([ + 'some-css-package' => self::createRemoteEntry('some-css-package', version: '1.0.0', type: ImportMapType::CSS), + ]); + $this->assertSame(\count($responses), $httpClient->getRequestsCount()); + + $this->assertSame([ + '/fonts/a.woff2' => 'a font contents', + '/b.woff2' => 'b font contents', + ], $actualReturn['some-css-package']['extraFiles']); + } + + public function testResolvePackagesRejectsDotSegmentsInImports() + { + $httpClient = new MockHttpClient(static fn ($method, $url) => match (true) { + str_contains($url, '/resolved') => new MockResponse(json_encode(['version' => '1.0.0'])), + str_contains($url, '/entrypoints') => new MockResponse(json_encode(['entrypoints' => []])), + default => new MockResponse('import "/npm/foo@1.0.0/../../bar@1.0.0/bar.js/+esm";'), + }); + + $provider = new JsDelivrEsmResolver($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid import "foo/../../bar@1.0.0/bar.js": "." and ".." path segments are not allowed.'); + + $provider->resolvePackages([new PackageRequireOptions('foo')]); + } + + public function testDownloadPackagesRejectsDotSegmentsInImports() + { + $httpClient = new MockHttpClient([ + new MockResponse('import "/npm/foo@1.0.0/../../bar@1.0.0/bar.js/+esm";'), + ]); + + $provider = new JsDelivrEsmResolver($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid import "foo/../../bar@1.0.0/bar.js": "." and ".." path segments are not allowed.'); + + $provider->downloadPackages([ + 'foo' => self::createRemoteEntry('foo', version: '1.0.0'), + ]); + } + public function testDownloadCssRecursivelyDownloadsUrlCss() { $expectedRequests = [ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Cookie.php symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Cookie.php --- symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Cookie.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Cookie.php 2026-11-04 11:23:00.000000000 +0000 @@ -205,6 +205,8 @@ $now = time(); // 1 is the earliest expiration time, 0 means a session cookie $values['expires'] = 0 < $maxAge ? (\PHP_INT_MAX - $now > $maxAge ? $now + $maxAge : \PHP_INT_MAX) : 1; + } elseif ('0' === $values['expires']) { + $values['expires'] = 1; } return new static( diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/CookieJar.php symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/CookieJar.php --- symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/CookieJar.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/CookieJar.php 2026-11-04 11:23:00.000000000 +0000 @@ -27,7 +27,7 @@ */ public function set(Cookie $cookie) { - $this->cookieJar[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie; + $this->cookieJar[self::normalizeDomain($cookie->getDomain())][$cookie->getPath()][$cookie->getName()] = $cookie; } /** @@ -44,6 +44,10 @@ { $this->flushExpiredCookies(); + if (null !== $domain) { + $domain = self::normalizeDomain($domain); + } + $match = null; foreach ($this->cookieJar as $cookieDomain => $pathCookies) { @@ -85,7 +89,7 @@ // this should never happen but it allows for a better BC $domains = array_keys($this->cookieJar); } else { - $domains = [$domain]; + $domains = [self::normalizeDomain($domain)]; } foreach ($domains as $domain) { @@ -180,6 +184,9 @@ $this->flushExpiredCookies(); $parts = array_replace(['path' => '/'], parse_url($uri)); + if (isset($parts['host'])) { + $parts['host'] = self::normalizeDomain($parts['host']); + } $cookies = []; foreach ($this->cookieJar as $domain => $pathCookies) { if ($domain) { @@ -257,4 +264,22 @@ return str_starts_with($requestPath, $cookiePath) && (str_ends_with($cookiePath, '/') || '/' === $requestPath[\strlen($cookiePath)]); } + + private static function normalizeDomain(string $domain): string + { + if (!str_contains($domain, ':') || str_contains($domain, "\0")) { + return $domain; + } + + $ip = ltrim($domain, '.'); + $prefix = substr($domain, 0, \strlen($domain) - \strlen($ip)); + $bracketed = str_starts_with($ip, '[') && str_ends_with($ip, ']'); + if (false !== $address = @inet_pton($bracketed ? substr($ip, 1, -1) : $ip)) { + if (false !== $address = inet_ntop($address)) { + return $prefix.($bracketed ? '['.$address.']' : $address); + } + } + + return $domain; + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieJarTest.php symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieJarTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieJarTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieJarTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,6 +18,102 @@ class CookieJarTest extends TestCase { + public function testCookiesMatchEquivalentIpv6Hosts() + { + foreach ([ + ['[0:0:0:0:0:0:0:1]', '[::1]'], + ['[2001:0DB8:0:0:0:0:0:1]', '[2001:db8::1]'], + ['[0:0:0:0:0:ffff:c000:280]', '[::ffff:192.0.2.128]'], + ] as [$firstHost, $secondHost]) { + $cookieJar = new CookieJar(); + $cookieJar->updateFromSetCookie(['sid=old'], 'https://'.$firstHost.'/'); + + $this->assertSame(['sid' => 'old'], $cookieJar->allValues('https://'.$secondHost.'/')); + $this->assertSame('old', $cookieJar->get('sid', '/', $secondHost)->getValue()); + + $cookieJar->updateFromSetCookie(['sid=new'], 'https://'.$secondHost.'/'); + + $this->assertCount(1, $cookieJar->all()); + $this->assertSame(['sid' => 'new'], $cookieJar->allValues('https://'.$firstHost.'/')); + + $cookieJar->expire('sid', '/', $firstHost); + + $this->assertSame([], $cookieJar->all()); + } + } + + public function testCookiesWithLeadingDotIpv6Domains() + { + foreach (['.', '..'] as $prefix) { + $cookieJar = new CookieJar(); + $cookieJar->set($cookie = new Cookie('sid', 'old', null, '/', $prefix.'[0:0:0:0:0:0:0:1]')); + + $this->assertSame($cookie, $cookieJar->get('sid', '/', '[0:0:0:0:0:0:0:1]')); + $this->assertSame(['sid' => 'old'], $cookieJar->allValues('https://[0:0:0:0:0:0:0:1]/')); + $this->assertSame(['sid' => 'old'], $cookieJar->allValues('https://[::1]/')); + $this->assertSame($prefix.'[0:0:0:0:0:0:0:1]', $cookie->getDomain()); + + $cookieJar->set(new Cookie('sid', 'new', null, '/', $prefix.'[::1]')); + + $this->assertCount(1, $cookieJar->all()); + $this->assertSame('new', $cookieJar->get('sid', '/', '[::1]')->getValue()); + + $cookieJar->expire('sid', '/', $prefix.'[0:0:0:0:0:0:0:1]'); + + $this->assertSame([], $cookieJar->all()); + } + } + + public function testCookieIpv6DomainPrefixesStaySeparate() + { + $cookieJar = new CookieJar(); + foreach (['', '.', '..'] as $prefix) { + $cookieJar->set(new Cookie('sid', 'value', null, '/', $prefix.'[0:0:0:0:0:0:0:1]')); + } + + $this->assertCount(3, $cookieJar->all()); + + $cookieJar->expire('sid', '/', '[::1]'); + $this->assertCount(2, $cookieJar->all()); + + $cookieJar->expire('sid', '/', '.[::1]'); + $this->assertCount(1, $cookieJar->all()); + $this->assertSame('..[0:0:0:0:0:0:0:1]', $cookieJar->all()[0]->getDomain()); + } + + public function testManuallySetCookiesMatchEquivalentIpv6Domains() + { + $cookieJar = new CookieJar(); + $cookieJar->set($cookie = new Cookie('sid', 'old', null, '/', '0:0:0:0:0:0:0:1')); + + $this->assertSame($cookie, $cookieJar->get('sid', '/', '::1')); + $this->assertSame('0:0:0:0:0:0:0:1', $cookie->getDomain()); + + $cookieJar->set(new Cookie('sid', 'new', null, '/', '::1')); + + $this->assertCount(1, $cookieJar->all()); + $this->assertSame('new', $cookieJar->get('sid', '/', '0:0:0:0:0:0:0:1')->getValue()); + + $cookieJar->expire('sid', '/', '0:0:0:0:0:0:0:1'); + + $this->assertSame([], $cookieJar->all()); + } + + public function testCookieIpv6DomainsStaySeparate() + { + $cookieJar = new CookieJar(); + $cookieJar->set(new Cookie('sid', 'bare', null, '/', '::1')); + $cookieJar->set(new Cookie('sid', 'bracketed', null, '/', '[::1]')); + $cookieJar->set(new Cookie('sid', 'other', null, '/', '[::2]')); + $cookieJar->set(new Cookie('sid', 'invalid', null, '/', '[invalid:address]')); + + $this->assertCount(4, $cookieJar->all()); + $this->assertSame('bare', $cookieJar->get('sid', '/', '::1')->getValue()); + $this->assertSame(['sid' => 'bracketed'], $cookieJar->allValues('https://[::1]/')); + $this->assertSame(['sid' => 'other'], $cookieJar->allValues('https://[::2]/')); + $this->assertSame('invalid', $cookieJar->get('sid', '/', '[invalid:address]')->getValue()); + } + public function testSetGet() { $cookieJar = new CookieJar(); @@ -93,6 +189,16 @@ $this->assertNull($cookieJar->get('foo')); } + public function testUpdateFromSetCookieDeletesCookiesAtTheUnixEpoch() + { + $cookieJar = new CookieJar(); + $cookieJar->updateFromSetCookie(['foo=bar'], 'https://example.com/'); + $cookieJar->updateFromSetCookie(['foo=bar; Expires=Thu, 01 Jan 1970 00:00:00 GMT'], 'https://example.com/'); + + $this->assertNull($cookieJar->get('foo')); + $this->assertSame([], $cookieJar->allValues('https://example.com/')); + } + public function testUpdateFromSetCookieIgnoresAnEmptyDomainAttribute() { $cookieJar = new CookieJar(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieTest.php symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/CookieTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -158,6 +158,13 @@ $this->assertSame((string) \PHP_INT_MAX, Cookie::fromString('foo=bar; Max-Age=99999999999999999999')->getExpiresTime()); } + public function testFromStringExpiresCookiesAtTheUnixEpoch() + { + $this->assertTrue(Cookie::fromString('foo=bar; Expires=Thu, 01 Jan 1970 00:00:00 GMT')->isExpired()); + $this->assertFalse(Cookie::fromString('foo=bar; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=60')->isExpired()); + $this->assertFalse((new Cookie('foo', 'bar', 0))->isExpired()); + } + public function testFromStringExpiresCookiesWithNonPositiveMaxAge() { $this->assertTrue(Cookie::fromString('foo=bar; Max-Age=0')->isExpired()); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/BrowserKit/Tests/HttpBrowserTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -22,6 +22,56 @@ class HttpBrowserTest extends AbstractBrowserTest { + public function testDoesNotReplayCookiesExpiredAtTheUnixEpochAfterRedirect() + { + $client = new MockHttpClient([ + new MockResponse('', ['http_code' => 302, 'response_headers' => [ + 'Location: https://example.com/next', + 'Set-Cookie: sid=value; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/', + ]]), + function ($method, $url, $options) { + $this->assertArrayNotHasKey('cookie', $options['normalized_headers']); + + return new MockResponse(); + }, + ]); + $browser = new HttpBrowser($client); + $browser->getCookieJar()->updateFromSetCookie(['sid=old'], 'https://example.com/'); + $browser->request('GET', 'https://example.com/'); + } + + public function testCookieDomainsWithNullBytesDoNotThrow() + { + $client = new MockHttpClient([ + new MockResponse('', ['response_headers' => ["Set-Cookie: sid=value; Domain=[x:\0y]"]]), + function ($method, $url, $options) { + $this->assertArrayNotHasKey('cookie', $options['normalized_headers']); + + return new MockResponse(); + }, + ]); + $browser = new HttpBrowser($client); + $browser->request('GET', 'https://[::1]/'); + $browser->request('GET', 'https://[::1]/'); + } + + public function testReplaysCookiesAfterRedirectToEquivalentIpv6Host() + { + $client = new MockHttpClient([ + new MockResponse('', ['http_code' => 302, 'response_headers' => [ + 'Location: https://[::1]/next', + 'Set-Cookie: sid=value; Path=/', + ]]), + function ($method, $url, $options) { + $this->assertSame(['cookie: sid=value'], $options['normalized_headers']['cookie'] ?? []); + + return new MockResponse(); + }, + ]); + $browser = new HttpBrowser($client); + $browser->request('GET', 'https://[0:0:0:0:0:0:0:1]/'); + } + public function getBrowser(array $server = [], ?History $history = null, ?CookieJar $cookieJar = null) { return new TestHttpClient($server, $history, $cookieJar); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -109,7 +109,7 @@ return new ChainAdapter([$apcu, $opcache]); } - public static function createConnection(#[\SensitiveParameter] string $dsn, array $options = []): mixed + public static function createConnection(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options = []): mixed { if (str_starts_with($dsn, 'redis:') || str_starts_with($dsn, 'rediss:')) { return RedisAdapter::createConnection($dsn, $options); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -258,6 +258,8 @@ } catch (\Exception) { } + $ok = true; + // When bulk-delete failed, retry each item individually foreach ($ids as $key => $id) { try { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseBucketAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseBucketAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseBucketAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseBucketAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -54,7 +54,7 @@ $this->marshaller = $marshaller ?? new DefaultMarshaller(); } - public static function createConnection(#[\SensitiveParameter] array|string $servers, array $options = []): \CouchbaseBucket + public static function createConnection(#[\SensitiveParameter] array|string $servers, #[\SensitiveParameter] array $options = []): \CouchbaseBucket { if (\is_string($servers)) { $servers = [$servers]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseCollectionAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseCollectionAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseCollectionAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/CouchbaseCollectionAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -47,7 +47,7 @@ $this->marshaller = $marshaller ?? new DefaultMarshaller(); } - public static function createConnection(#[\SensitiveParameter] array|string $dsn, array $options = []): Bucket|Collection + public static function createConnection(#[\SensitiveParameter] array|string $dsn, #[\SensitiveParameter] array $options = []): Bucket|Collection { if (\is_string($dsn)) { $dsn = [$dsn]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -66,7 +66,7 @@ * * @throws InvalidArgumentException When namespace contains invalid characters */ - public function __construct(Connection|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) + public function __construct(#[\SensitiveParameter] Connection|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) { if (isset($namespace[0]) && preg_match('#[^-+.A-Za-z0-9]#', $namespace, $match)) { throw new InvalidArgumentException(\sprintf('Namespace contains "%s" but only characters in [-+.A-Za-z0-9] are allowed.', $match[0])); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Adapter; +use Symfony\Component\Cache\Exception\CacheException; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\Marshaller\TagAwareMarshaller; use Symfony\Component\Cache\PruneableInterface; @@ -170,14 +171,22 @@ protected function doDeleteYieldTags(array $ids): iterable { + $failed = false; + foreach ($ids as $id) { $file = $this->getFile($id); - if (!is_file($file) || !$h = @fopen($file, 'r')) { + if (!is_file($file)) { + continue; + } + + if (!$h = @fopen($file, 'r')) { + $failed = true; continue; } if (!@unlink($file)) { fclose($h); + $failed = true; continue; } @@ -202,6 +211,11 @@ fclose($h); } + + if ($failed) { + // Make deleteItems() retry deleting the items one by one + throw new CacheException('Failed to delete some keys.'); + } } protected function doDeleteTagRelations(array $tagData): bool diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/MemcachedAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/MemcachedAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/MemcachedAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/MemcachedAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -89,7 +89,7 @@ * * @throws \ErrorException When invalid options or servers are provided */ - public static function createConnection(#[\SensitiveParameter] array|string $servers, array $options = []): \Memcached + public static function createConnection(#[\SensitiveParameter] array|string $servers, #[\SensitiveParameter] array $options = []): \Memcached { if (\is_string($servers)) { $servers = [$servers]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/PdoAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/PdoAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/PdoAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/PdoAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -54,7 +54,7 @@ * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION * @throws InvalidArgumentException When namespace contains invalid characters */ - public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, array $options = [], ?MarshallerInterface $marshaller = null) + public function __construct(#[\SensitiveParameter] \PDO|string $connOrDsn, string $namespace = '', int $defaultLifetime = 0, #[\SensitiveParameter] array $options = [], ?MarshallerInterface $marshaller = null) { if (\is_string($connOrDsn) && str_contains($connOrDsn, '://')) { throw new InvalidArgumentException(\sprintf('Usage of Doctrine DBAL URL with "%s" is not supported. Use a PDO DSN or "%s" instead.', __CLASS__, DoctrineDbalAdapter::class)); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php 2026-11-04 11:23:00.000000000 +0000 @@ -19,6 +19,7 @@ use Predis\Response\Status; use Relay\Relay; use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\CacheException; use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\Exception\LogicException; use Symfony\Component\Cache\Marshaller\DeflateMarshaller; @@ -156,9 +157,10 @@ } }); + $failed = false; foreach ($results as $id => $result) { - if ($result instanceof \RedisException || $result instanceof \Relay\Exception || $result instanceof ErrorInterface) { - CacheItem::log($this->logger, 'Failed to delete key "{key}": '.$result->getMessage(), ['key' => substr($id, \strlen($this->namespace)), 'exception' => $result]); + if (false === $result || $result instanceof \RedisException || $result instanceof \Relay\Exception || $result instanceof ErrorInterface) { + $failed = true; continue; } @@ -169,6 +171,11 @@ yield $id => []; } } + + if ($failed) { + // Make deleteItems() retry deleting the items one by one + throw new CacheException('Failed to delete some keys.'); + } } protected function doDeleteTagRelations(array $tagData): bool diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationDispatcher.php 2026-11-04 11:23:00.000000000 +0000 @@ -35,7 +35,7 @@ } /** - * @return mixed + * @return mixed The computed value, or the cached value when recomputation is deferred or skipped */ public function __invoke(callable $callback, CacheItem $item, bool &$save, AdapterInterface $pool, \Closure $setMetadata, ?LoggerInterface $logger = null, ?float $beta = null) { @@ -59,6 +59,12 @@ // The item's value is not stale, no need to write it to the backend $save = false; + foreach ($envelope->all(HandledStamp::class) as $stamp) { + if (($result = $stamp->getResult()) instanceof EarlyExpirationResult && $message->getItem() === $result->item) { + return $message->getItem()->get(); + } + } + return $message->getItem()->get() ?? $item->get(); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -30,7 +30,7 @@ } /** - * @return void + * @return EarlyExpirationResult|null */ public function __invoke(EarlyExpirationMessage $message) { @@ -42,16 +42,16 @@ if ($expiry && $ctime) { // skip duplicate or expired messages - $processingNonce = [$expiry, $ctime]; + $processingNonce = (object) ['value' => [$expiry, $ctime]]; $pool = $message->getPool(); $key = $item->getKey(); - if (($this->processedNonces[$pool][$key] ?? null) === $processingNonce) { - return; + if (($this->processedNonces[$pool][$key]->value ?? null) === $processingNonce->value) { + return null; } if (microtime(true) >= $expiry) { - return; + return null; } $this->processedNonces[$pool] = [$key => $processingNonce] + ($this->processedNonces[$pool] ?? []); @@ -74,12 +74,28 @@ CacheItem::class ); - $startTime = microtime(true); - $pool = $message->findPool($this->reverseContainer); - $callback = $message->findCallback($this->reverseContainer); - $save = true; - $value = $callback($item, $save); - $setMetadata($item, $startTime); - $pool->save($item->set($value)); + try { + $startTime = microtime(true); + $pool = $message->findPool($this->reverseContainer); + $callback = $message->findCallback($this->reverseContainer); + $save = true; + $item->set($callback($item, $save)); + + if ($save) { + $setMetadata($item, $startTime); + $pool->save($item); + } + + return new EarlyExpirationResult($item); + } catch (\Throwable $e) { + $pool = $message->getPool(); + $key = $item->getKey(); + + if (isset($processingNonce) && ($this->processedNonces[$pool][$key] ?? null) === $processingNonce) { + unset($this->processedNonces[$pool][$key]); + } + + throw $e; + } } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationResult.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationResult.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationResult.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Messenger/EarlyExpirationResult.php 2026-11-04 11:23:00.000000000 +0000 @@ -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\Cache\Messenger; + +use Symfony\Component\Cache\CacheItem; + +/** + * @internal + */ +final class EarlyExpirationResult +{ + public function __construct(public readonly CacheItem $item) + { + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractAdapterCreateConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractAdapterCreateConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractAdapterCreateConnectionTest.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractAdapterCreateConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Adapter\CouchbaseBucketAdapter; +use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\Exception\InvalidArgumentException; + +class AbstractAdapterCreateConnectionTest extends TestCase +{ + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + foreach (['redis://localhost:999999', 'memcached://localhost:999999', 'couchbase://localhost:999999/cache'] as $dsn) { + try { + AbstractAdapter::createConnection($dsn, ['auth' => 's3cr3t', 'password' => 's3cr3t']); + $this->fail('An exception should have been thrown.'); + } catch (CacheException|InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testCouchbaseBucketOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + CouchbaseBucketAdapter::createConnection('couchbase://localhost:999999/cache', ['password' => 's3cr3t']); + $this->fail('An exception should have been thrown.'); + } catch (CacheException|InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTestCase.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTestCase.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTestCase.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTestCase.php 2026-11-04 11:23:00.000000000 +0000 @@ -14,6 +14,7 @@ use Psr\Cache\CacheItemPoolInterface; use Relay\Relay; use Symfony\Component\Cache\Adapter\RedisAdapter; +use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter; abstract class AbstractRedisAdapterTestCase extends AdapterTestCase { @@ -55,4 +56,21 @@ $cache->clear(); $this->assertFalse($cache->hasItem('foo')); } + + public function testDeleteItemsWhenKeyHoldsAnotherType() + { + $cache = $this->createCachePool(); + + if (!$cache instanceof RedisAdapter && !$cache instanceof RedisTagAwareAdapter) { + $this->markTestSkipped('The Redis key of the item is not known.'); + } + + $cache->save($cache->getItem('foo')->set('foo')); + self::$redis->sAdd((new \ReflectionMethod($cache, 'getId'))->invoke($cache, 'bar'), 'bar'); + + $cache->deleteItems(['foo', 'bar']); + + $this->assertFalse($cache->hasItem('foo')); + $this->assertFalse($cache->hasItem('bar')); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/DoctrineDbalAdapterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -22,6 +22,7 @@ use Doctrine\DBAL\Schema\Table; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\DoctrineDbalAdapter; +use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\Tests\Fixtures\DriverWrapper; /** @@ -215,6 +216,23 @@ $doSave->invoke($adapter, ['key' => 'value'], 0); } + public function testDsnIsNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + new DoctrineDbalAdapter('pgsql://user:s3cr3t@localhost/db', 'invalid:namespace'); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + protected function isPruned(DoctrineDbalAdapter $cache, string $name): bool { $o = new \ReflectionObject($cache); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -30,6 +30,44 @@ (new Filesystem())->remove(sys_get_temp_dir().'/symfony-cache'); } + public function testDeleteItemsWhenUnlinkFails() + { + $cache = $this->createCachePool(); + $cache->save($cache->getItem('foo')->set('foo')); + $cache->save($cache->getItem('bar')->set('bar')); + + $file = (new \ReflectionMethod($cache, 'getFile'))->invoke($cache, (new \ReflectionMethod($cache, 'getId'))->invoke($cache, 'foo')); + + // Windows doesn't delete read-only files, other systems don't delete files in read-only directories + chmod($file, 0o444); + chmod(\dirname($file), 0o555); + + try { + $this->assertFalse($cache->deleteItems(['foo', 'bar'])); + $this->assertTrue($cache->hasItem('foo')); + $this->assertFalse($cache->hasItem('bar')); + } finally { + chmod(\dirname($file), 0o777); + chmod($file, 0o666); + } + } + + public function testDeleteItemsWhenFileIsNotReadable() + { + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('Files cannot be made unreadable on Windows.'); + } + + $cache = $this->createCachePool(); + $cache->save($cache->getItem('foo')->set('foo')); + + $file = (new \ReflectionMethod($cache, 'getFile'))->invoke($cache, (new \ReflectionMethod($cache, 'getId'))->invoke($cache, 'foo')); + chmod($file, 0o000); + + $this->assertTrue($cache->deleteItems(['foo'])); + $this->assertFalse($cache->hasItem('foo')); + } + protected function isPruned(CacheItemPoolInterface $cache, string $name): bool { $getFileMethod = (new \ReflectionObject($cache))->getMethod('getFile'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -38,4 +38,18 @@ $this->assertFalse($adapter->commit(), 'Commit should return false when tag save fails'); } + + public function testDeleteItemsWhenFetchingTagsFails() + { + $pool = new class extends FilesystemTagAwareAdapter { + protected function doDeleteYieldTags(array $ids): iterable + { + throw new \RuntimeException('Fetching tags failed.'); + } + }; + $pool->save($pool->getItem('foo')->set('bar')); + + $this->assertTrue($pool->deleteItem('foo')); + $this->assertFalse($pool->hasItem('foo')); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PdoAdapterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\PdoAdapter; +use Symfony\Component\Cache\Exception\InvalidArgumentException; /** * @requires extension pdo_sqlite @@ -115,6 +116,23 @@ } } + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + new PdoAdapter('mysql://localhost/db', '', 0, ['db_password' => 's3cr3t']); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + protected function isPruned(PdoAdapter $cache, string $name): bool { $o = new \ReflectionObject($cache); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PredisReplicationAdapterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PredisReplicationAdapterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PredisReplicationAdapterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Adapter/PredisReplicationAdapterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -11,6 +11,9 @@ namespace Symfony\Component\Cache\Tests\Adapter; +use Symfony\Component\Cache\Adapter\RedisAdapter; +use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter; + /** * @group integration */ @@ -37,4 +40,12 @@ self::$redis = new \Predis\Client($hostTable, ['replication' => 'predis', 'prefix' => 'prefix_']); } + + public function testDeleteItemsOnReadOnlyReplica() + { + $replica = new \Predis\Client(array_combine(['host', 'port'], explode(':', explode(' ', getenv('REDIS_REPLICATION_HOSTS'))[0]))); + + $this->assertFalse((new RedisAdapter($replica))->deleteItems(['foo', 'bar'])); + $this->assertFalse((new RedisTagAwareAdapter($replica))->deleteItems(['foo', 'bar'])); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationDispatcherTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,20 +12,32 @@ namespace Symfony\Component\Cache\Tests\Messenger; use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\Messenger\EarlyExpirationDispatcher; +use Symfony\Component\Cache\Messenger\EarlyExpirationHandler; use Symfony\Component\Cache\Messenger\EarlyExpirationMessage; +use Symfony\Component\Cache\Messenger\EarlyExpirationResult; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ReverseContainer; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\Handler\HandlerDescriptor; +use Symfony\Component\Messenger\Handler\HandlersLocator; use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; +use Symfony\Component\Messenger\Middleware\SendMessageMiddleware; +use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; +use Symfony\Component\Messenger\Transport\InMemoryTransport as LegacyInMemoryTransport; +use Symfony\Component\Messenger\Transport\Sender\SendersLocator; +use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; class EarlyExpirationDispatcherTest extends TestCase { @@ -127,6 +139,100 @@ ]; $this->assertSame($expected, $logger->records); } + + /** + * @group time-sensitive + */ + public function testSynchronousEarlyExpirationReturnsNullAndSkipsDuplicates() + { + $pool = new class extends FilesystemAdapter { + public bool $failSave = false; + + public function save(CacheItemInterface $item): bool + { + return !$this->failSave && parent::save($item); + } + }; + $pool->get('null-sync', static function (CacheItem $item) { + $item->expiresAfter(3600); + usleep(1000); + + return 234; + }); + $pool->failSave = true; + + $callback = new class { + public int $calls = 0; + + public function __invoke() + { + ++$this->calls; + + return null; + } + }; + + $container = new Container(); + $container->set('callback', $callback); + $container->set('pool', $pool); + $reverseContainer = new ReverseContainer($container, new ServiceLocator([])); + $handler = new EarlyExpirationHandler($reverseContainer); + $observer = new HandlerDescriptor(static fn (EarlyExpirationMessage $message) => $message->getItem(), ['alias' => 'observer']); + $tupleObserver = new HandlerDescriptor(static fn (EarlyExpirationMessage $message) => [$message->getItem()], ['alias' => 'tuple']); + $otherItem = $pool->getItem('unrelated'); + $foreignObserver = new HandlerDescriptor(static fn () => new EarlyExpirationResult($otherItem), ['alias' => 'foreign']); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([EarlyExpirationMessage::class => [$handler, $observer, $tupleObserver, $foreignObserver]]))]); + $pool->setCallbackWrapper(new EarlyExpirationDispatcher($bus, $reverseContainer)); + + $this->assertNull($pool->get('null-sync', $callback, \INF)); + $this->assertSame(234, $pool->getItem('null-sync')->get()); + $this->assertSame(234, $pool->get('null-sync', $callback, \INF)); + $this->assertSame(1, $callback->calls); + } + + public function testAsynchronousEarlyExpirationPreservesCurrentValue() + { + $pool = new FilesystemAdapter(); + $pool->save($pool->getItem('null-async')->set(234)); + $callback = new class { + public int $calls = 0; + + public function __invoke() + { + ++$this->calls; + + return null; + } + }; + + $container = new Container(); + $container->set('callback', $callback); + $container->set('pool', $pool); + $reverseContainer = new ReverseContainer($container, new ServiceLocator([])); + $pool->setCallbackWrapper(new EarlyExpirationDispatcher(new MessageBus(), $reverseContainer)); + $this->assertSame(234, $pool->get('null-async', $callback, \INF)); + $this->assertSame(0, $callback->calls); + + $transportClass = class_exists(InMemoryTransport::class) ? InMemoryTransport::class : LegacyInMemoryTransport::class; + $transport = new $transportClass(new PhpSerializer()); + $container->set('async', $transport); + $handler = new EarlyExpirationHandler($reverseContainer); + $bus = new MessageBus([ + new SendMessageMiddleware(new SendersLocator([EarlyExpirationMessage::class => ['async']], $container)), + new HandleMessageMiddleware(new HandlersLocator([EarlyExpirationMessage::class => [$handler]])), + ]); + $pool->setCallbackWrapper(new EarlyExpirationDispatcher($bus, $reverseContainer)); + + $this->assertSame(234, $pool->get('null-async', $callback, \INF)); + $this->assertSame(0, $callback->calls); + $this->assertCount(1, $transport->getSent()); + + $bus->dispatch($transport->get()[0]->with(new ReceivedStamp('async'))); + + $this->assertSame(1, $callback->calls); + $this->assertTrue($pool->hasItem('null-async')); + $this->assertNull($pool->get('null-async', $this->fail(...), 0)); + } } final class TestLogger extends AbstractLogger diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Tests/Messenger/EarlyExpirationHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -14,14 +14,36 @@ use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Messenger\EarlyExpirationDispatcher; use Symfony\Component\Cache\Messenger\EarlyExpirationHandler; use Symfony\Component\Cache\Messenger\EarlyExpirationMessage; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ReverseContainer; use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent; +use Symfony\Component\Messenger\Event\WorkerRunningEvent; +use Symfony\Component\Messenger\EventListener\SendFailedMessageForRetryListener; +use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener; +use Symfony\Component\Messenger\EventListener\StopWorkerOnTimeLimitListener; +use Symfony\Component\Messenger\Handler\HandlersLocator; +use Symfony\Component\Messenger\MessageBus; +use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; +use Symfony\Component\Messenger\Middleware\SendMessageMiddleware; +use Symfony\Component\Messenger\Retry\MultiplierRetryStrategy; +use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Messenger\Stamp\RedeliveryStamp; +use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport; +use Symfony\Component\Messenger\Transport\InMemoryTransport as LegacyInMemoryTransport; +use Symfony\Component\Messenger\Transport\Sender\SendersLocator; +use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; +use Symfony\Component\Messenger\Worker; use Symfony\Contracts\Cache\CallbackInterface; +/** + * @group time-sensitive + */ class EarlyExpirationHandlerTest extends TestCase { public static function tearDownAfterClass(): void @@ -29,9 +51,6 @@ (new Filesystem())->remove(sys_get_temp_dir().'/symfony-cache'); } - /** - * @group time-sensitive - */ public function testHandle() { $pool = new FilesystemAdapter(); @@ -65,4 +84,253 @@ $this->assertGreaterThan(25, $metadata['ctime']); $this->assertGreaterThan(time(), $metadata['expiry']); } + + public function testHandleReturnsValueWithoutSavingWhenCallbackDisablesSaving() + { + $pool = new FilesystemAdapter(); + $item = $pool->getItem('bar'); + $pool->save($item->set(234)); + + $computationService = new class implements CallbackInterface { + public bool $called = false; + + public function __invoke(CacheItemInterface $item, bool &$save): mixed + { + $this->called = true; + $save = false; + + return 123; + } + }; + + $container = new Container(); + $container->set('computation_service', $computationService); + $container->set('cache_pool', $pool); + + $reverseContainer = new ReverseContainer($container, new ServiceLocator([])); + + $handler = new EarlyExpirationHandler($reverseContainer); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([EarlyExpirationMessage::class => [$handler]]))]); + $pool->setCallbackWrapper(new EarlyExpirationDispatcher($bus, $reverseContainer)); + + $value = $pool->get('bar', $computationService, \INF); + + $this->assertTrue($computationService->called); + $this->assertSame(234, $pool->getItem('bar')->get()); + $this->assertSame(123, $value); + } + + public function testWorkerRetriesFailedCallbacks() + { + foreach ([new \RuntimeException('Failed computation.'), new \Error('Failed computation.')] as $failure) { + $calls = 0; + $currentRetry = 0; + $callbackRetries = []; + $whileComputing = null; + $callback = static function (CacheItemInterface $item) use (&$calls, &$currentRetry, &$callbackRetries, &$whileComputing, $failure) { + if (3 < ++$calls) { + throw new \LogicException('Too many callback calls.'); + } + $callbackRetries[] = $currentRetry; + if (1 === $calls) { + $whileComputing(); + + throw $failure; + } + + $item->expiresAfter(3600); + + return 'recovered'; + }; + [$pool, $container, $reverseContainer, $handler] = $this->createHandler($callback); + $transportClass = class_exists(InMemoryTransport::class) ? InMemoryTransport::class : LegacyInMemoryTransport::class; + $transport = new $transportClass(new PhpSerializer()); + $container->set('async', $transport); + $bus = new MessageBus([ + new SendMessageMiddleware(new SendersLocator([EarlyExpirationMessage::class => ['async']], $container)), + new HandleMessageMiddleware(new HandlersLocator([EarlyExpirationMessage::class => [$handler]])), + ]); + $pool->setCallbackWrapper(new EarlyExpirationDispatcher($bus, $reverseContainer)); + + $this->assertSame(234, $pool->get('foo', $callback, \INF)); + $originalEnvelope = $transport->getSent()[0]; + $whileComputing = static fn () => $bus->dispatch($originalEnvelope->with(new ReceivedStamp('async'))); + + $dispatcher = new EventDispatcher(); + $senders = new ServiceLocator(['async' => static fn () => $transport]); + $strategies = new ServiceLocator(['async' => static fn () => new MultiplierRetryStrategy(1, 0)]); + $dispatcher->addSubscriber(new SendFailedMessageForRetryListener($senders, $strategies)); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + $dispatcher->addSubscriber(new StopWorkerOnTimeLimitListener(1)); + $dispatcher->addListener(WorkerMessageReceivedEvent::class, static function (WorkerMessageReceivedEvent $event) use (&$currentRetry) { + $currentRetry = RedeliveryStamp::getRetryCountFromEnvelope($event->getEnvelope()); + }); + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) use ($pool) { + if (!$event->isWorkerIdle()) { + $pool->reset(); + } + }); + + $worker = new Worker(['async' => $transport], $bus, $dispatcher); + $worker->run(['sleep' => 0]); + + $this->assertSame([0, 1], $callbackRetries); + $this->assertSame(2, $calls); + $this->assertSame('recovered', $pool->getItem('foo')->get()); + $bus->dispatch($originalEnvelope->with(new ReceivedStamp('async'))); + $this->assertSame(2, $calls); + } + } + + public function testFailureDoesNotClearReplacementComputation() + { + foreach ([false, true] as $evictOriginal) { + $calls = 0; + $handler = $replacement = null; + $otherMessages = []; + $failure = new \RuntimeException('Failed computation.'); + $callback = static function () use (&$calls, &$handler, &$replacement, &$otherMessages, $failure) { + if (3 < ++$calls) { + throw new \LogicException('Too many callback calls.'); + } + if (1 === $calls) { + foreach ($otherMessages as $message) { + $handler($message); + } + $handler($replacement); + + throw $failure; + } + + return 'recovered'; + }; + [$pool, $container, $reverseContainer, $handler, $message] = $this->createHandler($callback); + + if ($evictOriginal) { + $otherCalls = 0; + $otherCallback = static function () use (&$otherCalls) { + if (102 < ++$otherCalls) { + throw new \LogicException('Too many callback calls.'); + } + + return 0; + }; + $container->set('other_callback', $otherCallback); + + for ($i = 0; $i < 100; ++$i) { + $pool->get('other-'.$i, static function (CacheItemInterface $item) { + $item->expiresAfter(3600); + usleep(1000); + + return 0; + }); + $otherMessages[] = EarlyExpirationMessage::create($reverseContainer, $otherCallback, $pool->getItem('other-'.$i), $pool); + } + + $replacement = EarlyExpirationMessage::create($reverseContainer, $callback, $message->getItem(), $pool); + } else { + $pool->delete('foo'); + $pool->get('foo', static function (CacheItemInterface $item) { + $item->expiresAfter(7200); + usleep(2000); + + return 345; + }); + $replacement = EarlyExpirationMessage::create($reverseContainer, $callback, $pool->getItem('foo'), $pool); + } + + try { + $handler($message); + $this->fail('An exception should have been thrown.'); + } catch (\RuntimeException $e) { + $this->assertSame($failure, $e); + } + + $this->assertSame('recovered', $pool->getItem('foo')->get()); + $handler($replacement); + $this->assertSame(2, $calls); + + if ($evictOriginal) { + $this->assertSame(100, $otherCalls); + $handler($otherMessages[99]); + $this->assertSame(100, $otherCalls); + $handler($otherMessages[0]); + $this->assertSame(101, $otherCalls); + } + } + } + + public function testExpiredMessageIsSkippedAfterFailure() + { + $calls = 0; + $callback = static function () use (&$calls) { + if (1 === ++$calls) { + throw new \RuntimeException('Failed computation.'); + } + + return 'recovered'; + }; + [$pool, , , $handler, $message] = $this->createHandler($callback, 1); + + try { + $handler($message); + $this->fail('An exception should have been thrown.'); + } catch (\RuntimeException) { + } + + sleep(2); + $handler($message); + + $this->assertSame(1, $calls); + $this->assertFalse($pool->hasItem('foo')); + } + + public function testFailureWithoutMetadataCanBeRetried() + { + $calls = 0; + $callback = static function () use (&$calls) { + if (1 === ++$calls) { + throw new \RuntimeException('Failed computation.'); + } + + return 'recovered'; + }; + [$pool, , $reverseContainer, $handler] = $this->createHandler($callback); + $message = EarlyExpirationMessage::create($reverseContainer, $callback, $pool->getItem('uncached'), $pool); + + try { + $handler($message); + $this->fail('An exception should have been thrown.'); + } catch (\RuntimeException) { + } + + $handler($message); + + $this->assertSame(2, $calls); + $this->assertSame('recovered', $pool->getItem('uncached')->get()); + } + + private function createHandler(\Closure $callback, int $lifetime = 3600): array + { + $pool = new FilesystemAdapter(); + $pool->clear(); + $pool->get('foo', static function (CacheItemInterface $item) use ($lifetime) { + $item->expiresAfter($lifetime); + usleep(1000); + + return 234; + }); + $container = new Container(); + $container->set('callback', $callback); + $container->set('cache_pool', $pool); + $reverseContainer = new ReverseContainer($container, new ServiceLocator([])); + + return [ + $pool, + $container, + $reverseContainer, + new EarlyExpirationHandler($reverseContainer), + EarlyExpirationMessage::create($reverseContainer, $callback, $pool->getItem('foo'), $pool), + ]; + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/RedisTrait.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/RedisTrait.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/RedisTrait.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/RedisTrait.php 2026-11-04 11:23:00.000000000 +0000 @@ -86,7 +86,7 @@ * * @throws InvalidArgumentException when the DSN is invalid */ - public static function createConnection(#[\SensitiveParameter] string $dsn, array $options = []): \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|Relay + public static function createConnection(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options = []): \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|Relay { if (str_starts_with($dsn, 'redis:')) { $scheme = 'redis'; @@ -575,25 +575,34 @@ static $del; $del ??= (class_exists(UNLINK::class) ? 'unlink' : 'del'); - $this->pipeline(static function () use ($ids, $del) { + $results = $this->pipeline(static function () use ($ids, $del) { foreach ($ids as $id) { yield $del => [$id]; } - })->rewind(); + }); + + foreach ($results as $result) { + if ($result instanceof ErrorInterface) { + return false; + } + } } else { static $unlink = true; if ($unlink) { try { - $unlink = false !== $this->redis->unlink($ids); + $result = $this->redis->unlink($ids); + $unlink = false !== $result; } catch (\Throwable) { $unlink = false; } } if (!$unlink) { - $this->redis->del($ids); + $result = $this->redis->del($ids); } + + return false !== $result && !$result instanceof ErrorInterface; } return true; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/Relay/Relay61Trait.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/Relay/Relay61Trait.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/Relay/Relay61Trait.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/Relay/Relay61Trait.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Traits\Relay; + +if (version_compare(phpversion('relay'), '0.61.0', '>=')) { + /** + * @internal + */ + trait Relay61Trait + { + public function reconnect(): bool + { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->reconnect(...\func_get_args()); + } + } +} else { + /** + * @internal + */ + trait Relay61Trait + { + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/RelayProxy.php symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/RelayProxy.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Cache/Traits/RelayProxy.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Cache/Traits/RelayProxy.php 2026-11-04 11:23:00.000000000 +0000 @@ -32,6 +32,7 @@ use Symfony\Component\Cache\Traits\Relay\Relay40Trait; use Symfony\Component\Cache\Traits\Relay\Relay50Trait; use Symfony\Component\Cache\Traits\Relay\Relay502Trait; +use Symfony\Component\Cache\Traits\Relay\Relay61Trait; use Symfony\Component\Cache\Traits\Relay\SwapdbTrait; use Symfony\Component\VarExporter\LazyObjectInterface; use Symfony\Component\VarExporter\LazyProxyTrait; @@ -72,6 +73,7 @@ use Relay40Trait; use Relay50Trait; use Relay502Trait; + use Relay61Trait; use SwapdbTrait; private const LAZY_OBJECT_PROPERTY_SCOPES = []; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Config/Tests/Util/XmlUtilsTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Config/Tests/Util/XmlUtilsTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Config/Tests/Util/XmlUtilsTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Config/Tests/Util/XmlUtilsTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Config\Util\Exception\InvalidXmlException; +use Symfony\Component\Config\Util\Exception\XmlParsingException; use Symfony\Component\Config\Util\XmlUtils; class XmlUtilsTest extends TestCase @@ -226,6 +227,31 @@ // should not throw an exception XmlUtils::loadFile(__DIR__.'/../Fixtures/Util/valid.xml', __DIR__.'/../Fixtures/Util/schema.xsd'); } + + public function testParseDoesNotResolveExternalEntities() + { + // the target is not well-formed on its own, so expanding it would abort the parsing + $target = __DIR__.'/../Fixtures/Util/invalid.xml'; + $parts = explode('/', ltrim(str_replace('\\', '/', $target), '/')); + $drive = '\\' === \DIRECTORY_SEPARATOR ? array_shift($parts).'/' : ''; + $uri = 'file:///'.$drive.implode('/', array_map('rawurlencode', $parts)); + $content = ']>&xxe;'; + + $internalErrors = libxml_use_internal_errors(true); + + try { + XmlUtils::parse($content); + $this->fail('An exception should have been raised.'); + } catch (XmlParsingException $e) { + // the document is rejected for carrying a doctype, not for failing DTD validation, + // which means the doctype was never processed and the entity was never resolved + $this->assertSame('Document types are not allowed.', $e->getMessage()); + $this->assertTrue(!libxml_get_errors(), 'The external entity should not have been resolved.'); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($internalErrors); + } + } } interface Validator diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Config/Util/XmlUtils.php symfony-6.4.50+dfsg/src/Symfony/Component/Config/Util/XmlUtils.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Config/Util/XmlUtils.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Config/Util/XmlUtils.php 2026-11-04 11:23:00.000000000 +0000 @@ -52,7 +52,6 @@ libxml_clear_errors(); $dom = new \DOMDocument(); - $dom->validateOnParse = true; if (!$dom->loadXML($content, \LIBXML_NONET | \LIBXML_COMPACT)) { throw new XmlParsingException(implode("\n", static::getXmlErrors($internalErrors))); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/OutputWrapper.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/OutputWrapper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/OutputWrapper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/OutputWrapper.php 2026-11-04 11:23:00.000000000 +0000 @@ -71,7 +71,9 @@ // the u modifier counts characters instead of bytes, but preg_replace() returns null // on malformed UTF-8, which would empty the text $modifiers = preg_match('//u', $text) ? 'imux' : 'imx'; - $pattern = \sprintf('#(?:((?>(%1$s)((?<=[^\S\r\n])[^\S\r\n]?|(?=\r?\n)|$|[^\S\r\n]))|(%1$s))(?:\r?\n)?|(?:\r?\n|$))#%2$s', $rowPattern, $modifiers); + // a line cut inside a word never ends between a backslash and the "<" or ">" it escapes, + // or the escaped character would start the next line as a tag delimiter + $pattern = \sprintf('#(?:((?>(%1$s)((?<=[^\S\r\n])[^\S\r\n]?|(?=\r?\n)|$|[^\S\r\n]))|(%1$s)(?!(?<=\\\\)[<>]))(?:\r?\n)?|(?:\r?\n|$))#%2$s', $rowPattern, $modifiers); $output = rtrim(preg_replace($pattern, '\\1'.$break, $text), $break); return str_replace(' '.$break, $break, $output); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/ProcessHelper.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/ProcessHelper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/ProcessHelper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/ProcessHelper.php 2026-11-04 11:23:00.000000000 +0000 @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Helper; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -127,7 +128,7 @@ private function escapeString(string $str): string { - return str_replace('<', '\\<', $str); + return OutputFormatter::escapeTrailingBackslash(str_replace('<', '\\<', $str)); } public function getName(): string diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/ProgressBar.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/ProgressBar.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/ProgressBar.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/ProgressBar.php 2026-11-04 11:23:00.000000000 +0000 @@ -58,6 +58,9 @@ private bool $overwrite = true; private Terminal $terminal; private ?string $previousMessage = null; + private int $lastDrawnStep = 0; + private int $lastDrawnMax = 0; + private bool $finishedWithoutMax = false; private Cursor $cursor; private array $placeholders = []; @@ -334,16 +337,24 @@ */ public function start(?int $max = null, int $startAt = 0): void { + $startAt = max(0, $startAt); $this->startTime = time(); $this->step = $startAt; $this->startingStep = $startAt; - $startAt > 0 ? $this->setProgress($startAt) : $this->percent = 0.0; - if (null !== $max) { $this->setMaxSteps($max); + } elseif ($this->finishedWithoutMax) { + // drop the max that finish() set to draw a full bar + $this->setMaxSteps(0); + } + + if ($this->max && $startAt > $this->max) { + $this->max = $startAt; } + $this->percent = $this->max ? (float) $startAt / $this->max : 0.0; + $this->display(); } @@ -401,8 +412,10 @@ public function setMaxSteps(int $max): void { $this->format = null; + $this->finishedWithoutMax = false; $this->max = max(0, $max); $this->stepWidth = $this->max ? Helper::width((string) $this->max) : 4; + $this->percent = $this->max ? min(1, (float) $this->step / $this->max) : 0; } /** @@ -410,12 +423,24 @@ */ public function finish(): void { + if ($this->max && $this->step > $this->max) { + // the max was lowered below the step with setMaxSteps(), raise it like setProgress() does + $this->max = $this->step; + } + + $max = $this->max; + if (!$this->max) { $this->max = $this->step; + $this->percent = $this->max ? 1.0 : 0.0; + $this->finishedWithoutMax = true; } - if ($this->step === $this->max && !$this->overwrite) { - // prevent double 100% output + if ($this->step === $this->max && !$this->overwrite && $this->lastDrawnStep === $this->step && \in_array($this->lastDrawnMax, [$max, $this->max], true)) { + // prevent double output when the last frame already shows this step, against the max it had before finishing or this one + // remember this max as drawn, so that calling finish() again does not draw the frame either + $this->lastDrawnMax = $this->max; + return; } @@ -436,6 +461,8 @@ } $this->overwrite($this->buildLine()); + $this->lastDrawnStep = $this->step; + $this->lastDrawnMax = $this->max; } /** @@ -507,7 +534,7 @@ $this->cursor->clearLine(); } } - } elseif ($this->step > 0) { + } elseif (null !== $this->previousMessage) { $message = \PHP_EOL.$message; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/QuestionHelper.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/QuestionHelper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/QuestionHelper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/QuestionHelper.php 2026-11-04 11:23:00.000000000 +0000 @@ -220,7 +220,7 @@ foreach ($choices as $key => $value) { $padding = str_repeat(' ', $maxWidth - self::width($key)); - $messages[] = \sprintf(" [<$tag>%s$padding] %s", $key, $value); + $messages[] = \sprintf(" [<$tag>%s$padding] %s", OutputFormatter::escapeTrailingBackslash($key), $value); } return $messages; @@ -236,7 +236,7 @@ if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) { $message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'); } else { - $message = ''.$error->getMessage().''; + $message = ''.OutputFormatter::escapeTrailingBackslash($error->getMessage()).''; } $output->writeln($message); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/SymfonyQuestionHelper.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/SymfonyQuestionHelper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/SymfonyQuestionHelper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/SymfonyQuestionHelper.php 2026-11-04 11:23:00.000000000 +0000 @@ -56,13 +56,13 @@ $default[$key] = $choices[trim($value)]; } - $text = \sprintf(' %s [%s]:', $text, implode(', ', $default)); + $text = \sprintf(' %s [%s]:', $text, OutputFormatter::escapeTrailingBackslash(implode(', ', $default))); break; case $question instanceof ChoiceQuestion: $choices = $question->getChoices(); - $text = \sprintf(' %s [%s]:', $text, $choices[$default] ?? $default); + $text = \sprintf(' %s [%s]:', $text, OutputFormatter::escapeTrailingBackslash($choices[$default] ?? $default)); break; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/Table.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/Table.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Helper/Table.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Helper/Table.php 2026-11-04 11:23:00.000000000 +0000 @@ -285,7 +285,7 @@ */ public function setRow(int|string $column, array $row): static { - $this->rows[$column] = $row; + $this->rows[$column] = array_values($row); return $this; } @@ -412,7 +412,7 @@ $rows[] = [\sprintf( '%s%s: %s', str_repeat(' ', $maxHeaderLength - Helper::width(Helper::removeDecoration($formatter, $headers[$i] ?? ''))), - $headers[$i] ?? '', + OutputFormatter::escapeTrailingBackslash($headers[$i] ?? ''), $part )]; } else { @@ -668,7 +668,7 @@ $padType = $cell->getStyle()->getPadByAlign(); } - return \sprintf($cellFormat, str_pad($content, $width, $style->getPaddingChar(), $padType)); + return \sprintf($cellFormat, OutputFormatter::escapeTrailingBackslash(str_pad($content, $width, $style->getPaddingChar(), $padType))); } /** @@ -697,13 +697,15 @@ $rows = $this->fillNextRows($rows, $rowKey); // Remove any new line breaks and replace it with a new line + $firstColumn = $colspan = 0; foreach ($rows[$rowKey] as $column => $cell) { + $firstColumn += $colspan; $colspan = $cell instanceof TableCell ? $cell->getColspan() : 1; $minWrappedWidth = 0; $widthApplied = []; $lengthColumnBorder = $this->getColumnSeparatorWidth() + Helper::width($this->style->getCellRowContentFormat()) - 2; - for ($i = $column; $i < ($column + $colspan); ++$i) { + for ($i = $firstColumn; $i < ($firstColumn + $colspan); ++$i) { if (isset($this->columnMaxWidths[$i])) { $minWrappedWidth += $this->columnMaxWidths[$i]; $widthApplied[] = ['type' => 'max', 'column' => $i]; @@ -741,7 +743,7 @@ $cellWidth -= $this->configuredColumnWidths[$item['column']] + $lengthColumnBorder; } } - for ($i = $column; $i < ($column + $colspan); ++$i) { + for ($i = $firstColumn; $i < ($firstColumn + $colspan); ++$i) { if (isset($columnsMinWidthProcessed[$i])) { continue; } @@ -794,49 +796,44 @@ { $this->rowspanLines ??= new \WeakMap(); $unmergedRows = []; - foreach ($rows[$line] as $column => $cell) { + $column = 0; + foreach ($rows[$line] as $cellKey => $cell) { if (null !== $cell && !$cell instanceof TableCell && !\is_scalar($cell) && !$cell instanceof \Stringable) { throw new InvalidArgumentException(\sprintf('A cell must be a TableCell, a scalar or an object implementing "__toString()", "%s" given.', get_debug_type($cell))); } if ($cell instanceof TableCell && $cell->getRowspan() > 1) { $nbLines = $cell->getRowspan() - 1; - $lines = [$cell]; + $value = ''; if (str_contains($cell, "\n")) { $eol = str_contains($cell, "\r\n") ? "\r\n" : "\n"; $lines = explode($eol, $this->closeStyles($cell, $eol)); $nbLines = \count($lines) > $nbLines ? substr_count($cell, $eol) : $nbLines; - $rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); - $this->rowspanLines[$rows[$line][$column]] = true; + $rows[$line][$cellKey] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); + $this->rowspanLines[$rows[$line][$cellKey]] = true; unset($lines[0]); + $value = implode($eol, $lines); } - // create a two dimensional array (rowspan x colspan) - $unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, []), $unmergedRows); - foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) { - $value = $lines[$unmergedRowKey - $line] ?? ''; - $unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); - $this->rowspanLines[$unmergedRows[$unmergedRowKey][$column]] = true; - if ($nbLines === $unmergedRowKey - $line) { - break; - } - } + // only fill the next row, the rows below are filled from that row after its own cells that span rows + $unmergedRows[$line + 1][$column] = new TableCell($value, ['rowspan' => $nbLines, 'colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); + $this->rowspanLines[$unmergedRows[$line + 1][$column]] = true; } + + $column += $cell instanceof TableCell ? $cell->getColspan() : 1; } foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) { // we need to know if $unmergedRow will be merged or inserted into $rows if (isset($rows[$unmergedRowKey]) && \is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRows[$unmergedRowKey]) <= $this->numberOfColumns)) { - foreach ($unmergedRow as $cellKey => $cell) { - // insert cell into row at cellKey position - array_splice($rows[$unmergedRowKey], $cellKey, 0, [$cell]); + foreach ($unmergedRow as $column => $cell) { + // insert cell into row at column position + array_splice($rows[$unmergedRowKey], $this->getCellPosition($rows[$unmergedRowKey], $column), 0, [$cell]); } } else { $row = $this->copyRow($rows, $unmergedRowKey - 1); foreach ($unmergedRow as $column => $cell) { - if (!empty($cell)) { - $row[$column] = $unmergedRow[$column]; - } + array_splice($row, $this->getCellPosition($row, $column), 1, [$cell]); } array_splice($rows, $unmergedRowKey, 0, [$row]); } @@ -963,6 +960,23 @@ } /** + * Gets the position in the given row of the cell that starts at the given column. + */ + private function getCellPosition(array $row, int $column): int + { + $position = 0; + foreach ($row as $cell) { + if ($column <= 0) { + break; + } + $column -= $cell instanceof TableCell ? $cell->getColspan() : 1; + ++$position; + } + + return $position; + } + + /** * Gets list of columns for the given row. */ private function getRowColumns(array $row): array diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Logger/ConsoleLogger.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Logger/ConsoleLogger.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Logger/ConsoleLogger.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Logger/ConsoleLogger.php 2026-11-04 11:23:00.000000000 +0000 @@ -14,6 +14,7 @@ use Psr\Log\AbstractLogger; use Psr\Log\InvalidArgumentException; use Psr\Log\LogLevel; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -78,7 +79,7 @@ // the if condition check isn't necessary -- it's the same one that $output will do internally anyway. // We only do it for efficiency here as the message formatting is relatively expensive. if ($output->getVerbosity() >= $this->verbosityLevelMap[$level]) { - $output->writeln(\sprintf('<%1$s>[%2$s] %3$s', $this->formatLevelMap[$level], $level, $this->interpolate($message, $context)), $this->verbosityLevelMap[$level]); + $output->writeln(\sprintf('<%1$s>[%2$s] %3$s', $this->formatLevelMap[$level], $level, OutputFormatter::escapeTrailingBackslash($this->interpolate($message, $context))), $this->verbosityLevelMap[$level]); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Style/SymfonyStyle.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Style/SymfonyStyle.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Style/SymfonyStyle.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Style/SymfonyStyle.php 2026-11-04 11:23:00.000000000 +0000 @@ -512,7 +512,7 @@ $line .= str_repeat(' ', max($this->lineLength - Helper::width(Helper::removeDecoration($this->getFormatter(), $line)), 0)); if ($style) { - $line = \sprintf('<%s>%s', $style, $line); + $line = \sprintf('<%s>%s', $style, OutputFormatter::escapeTrailingBackslash($line)); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/OutputWrapperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/OutputWrapperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/OutputWrapperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/OutputWrapperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -42,6 +42,16 @@ $this->assertSame($wrapper->wrap('Lorem X ipsum', 5), str_replace("\xB3", 'X', $wrapper->wrap("Lorem \xB3 ipsum", 5))); } + public function testWrapKeepsEscapedTagDelimitersWithTheirBackslash() + { + $wrapper = new OutputWrapper(); + + $this->assertSame("aaaa\n\\wrap('aaaa\assertSame("aaaa\n\\>b", $wrapper->wrap('aaaa\>b', 5)); + $this->assertSame("aaa\\<\nb", $wrapper->wrap('aaa\assertSame("aaaa\n\\c", $wrapper->wrap('aaaa\c', 5)); + } + public function testWrapDoesNotDropBytesOfMalformedUtf8() { $text = "Cannot read the log file: \xB3 check the permissions"; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/ProcessHelperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -116,6 +116,8 @@ [[' OUT 42'], true, Process::fromShellCommandline('php -r "echo 42;"'), StreamOutput::VERBOSITY_DEBUG, null], [[' OUT 42'], true, [new Process(['php', '-r', 'echo 42;'])], StreamOutput::VERBOSITY_DEBUG, null], [[' OUT 42'], true, [Process::fromShellCommandline('php -r '.$PHP), 'PHP' => 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], + [[], true, 'php -r "echo 42;" C:\\\\', StreamOutput::VERBOSITY_VERY_VERBOSE, null], + [[], false, 'php -r "exit(252);"', StreamOutput::VERBOSITY_VERBOSE, 'Cannot write to C:\\'], ]; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -94,6 +94,141 @@ ); } + public function testResumeWithNewMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 0, 0); + $bar->start(200, 50); + + rewind($output->getStream()); + + $this->assertEquals( + ' 50/200 [=======>--------------------] 25%', + stream_get_contents($output->getStream()) + ); + } + + public function testResumeWithNewMaxAfterSmallerMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 10, 0); + $bar->start(100, 50); + + rewind($output->getStream()); + + $this->assertEquals( + ' 50/100 [==============>-------------] 50%', + stream_get_contents($output->getStream()) + ); + } + + public function testResumeBeyondNewMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 0, 0); + $bar->start(10, 50); + + rewind($output->getStream()); + + $this->assertSame(50, $bar->getMaxSteps()); + $this->assertEquals( + ' 50/50 [============================] 100%', + stream_get_contents($output->getStream()) + ); + } + + public function testResumeWithNonDecoratedOutput() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 200, 0); + $bar->start(null, 50); + $bar->advance(20); + + rewind($output->getStream()); + + $this->assertEquals( + ' 50/200 [=======>--------------------] 25%'.\PHP_EOL. + ' 70/200 [=========>------------------] 35%', + stream_get_contents($output->getStream()) + ); + } + + public function testResumeNoMaxWithNonDecoratedOutput() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(null, 15); + + rewind($output->getStream()); + + $this->assertEquals( + ' 15 [>---------------------------]', + stream_get_contents($output->getStream()) + ); + } + + public function testRestartWithNonDecoratedOutput() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 2, 0); + $bar->start(); + $bar->finish(); + $bar->start(); + + rewind($output->getStream()); + + $this->assertEquals( + ' 0/2 [>---------------------------] 0%'.\PHP_EOL. + ' 2/2 [============================] 100%'.\PHP_EOL. + ' 0/2 [>---------------------------] 0%', + stream_get_contents($output->getStream()) + ); + } + + public function testRestartNoMaxAfterFinish() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 0, 0); + $bar->start(); + $bar->advance(3); + $bar->finish(); + $bar->start(); + $bar->advance(5); + + rewind($output->getStream()); + + $this->assertSame(0, $bar->getMaxSteps()); + $this->assertEquals( + ' 0 [>---------------------------]'. + $this->generateOutput(' 3 [--->------------------------]'). + $this->generateOutput(' 3 [============================]'). + $this->generateOutput(' 0 [>---------------------------]'). + $this->generateOutput(' 5 [----->----------------------]'), + stream_get_contents($output->getStream()) + ); + } + + public function testRestartWithMaxSetAfterFinishWithoutMax() + { + $bar = new ProgressBar($this->getOutputStream(), 0, 0); + $bar->start(); + $bar->advance(3); + $bar->finish(); + $bar->setMaxSteps(10); + $bar->start(); + + $this->assertSame(10, $bar->getMaxSteps()); + } + + public function testResumeAtNegativeStep() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 0, 0); + $bar->start(null, -5); + $bar->start(100, -5); + + rewind($output->getStream()); + + $this->assertSame(0, $bar->getProgress()); + $this->assertEquals( + ' 0 [>---------------------------]'. + $this->generateOutput(' 0/100 [>---------------------------] 0%'), + stream_get_contents($output->getStream()) + ); + } + public function testRegularTimeEstimation() { $bar = new ProgressBar($output = $this->getOutputStream(), 1_200, 0); @@ -843,6 +978,146 @@ ); } + public function testFinishNonDecoratedOutputWithoutMaxWhenLastStepIsNotDrawnYet() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 10); + $bar->start(); + $bar->advance(15); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals(' 0 [>---------------------------]'.\PHP_EOL.' 15 [============================]', stream_get_contents($output->getStream())); + } + + public function testFinishNonDecoratedOutputWithoutMaxWhenLastStepIsAlreadyDrawn() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(); + $bar->advance(15); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals(' 0 [>---------------------------]'.\PHP_EOL.' 15 [->--------------------------]', stream_get_contents($output->getStream())); + } + + public function testFinishTwiceNonDecoratedOutputWithoutMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(); + $bar->advance(15); + $bar->finish(); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals(' 0 [>---------------------------]'.\PHP_EOL.' 15 [->--------------------------]', stream_get_contents($output->getStream())); + } + + public function testDisplayAfterFinishNonDecoratedOutputWithoutMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(); + $bar->advance(15); + $bar->finish(); + + $this->assertSame(1.0, $bar->getProgressPercent()); + + $bar->display(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0 [>---------------------------]'.\PHP_EOL. + ' 15 [->--------------------------]'.\PHP_EOL. + ' 15 [============================]', + stream_get_contents($output->getStream()) + ); + } + + public function testFinishEmptyNonDecoratedOutputWithoutMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals(' 0 [>---------------------------]', stream_get_contents($output->getStream())); + } + + public function testFinishUnstartedNonDecoratedOutputWithoutMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->finish(); + + rewind($output->getStream()); + $this->assertSame('', stream_get_contents($output->getStream())); + } + + public function testFinishNonDecoratedOutputAfterSettingMaxStepsToCurrentStep() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 100, 0); + $bar->start(); + $bar->setProgress(50); + $bar->setMaxSteps(50); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0/100 [>---------------------------] 0%'.\PHP_EOL. + ' 50/100 [==============>-------------] 50%'.\PHP_EOL. + ' 50/50 [============================] 100%', + stream_get_contents($output->getStream()) + ); + } + + public function testFinishNonDecoratedOutputWithoutMaxAfterSettingMaxStepsToCurrentStep() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 0, 0); + $bar->start(); + $bar->setProgress(15); + $bar->setMaxSteps(15); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0 [>---------------------------]'.\PHP_EOL. + ' 15 [->--------------------------]'.\PHP_EOL. + ' 15/15 [============================] 100%', + stream_get_contents($output->getStream()) + ); + } + + public function testFinishNonDecoratedOutputAfterReachingMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 2, 0); + $bar->setFormat('verbose'); + $bar->start(); + $bar->advance(2); + sleep(1); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0/2 [>---------------------------] 0% < 1 sec'.\PHP_EOL. + ' 2/2 [============================] 100% < 1 sec', + stream_get_contents($output->getStream()) + ); + } + + public function testFinishNonDecoratedOutputAgainAfterRemovingMax() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 10, 0); + $bar->start(); + $bar->finish(); + $bar->setMaxSteps(0); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0/10 [>---------------------------] 0%'.\PHP_EOL. + ' 10/10 [============================] 100%', + stream_get_contents($output->getStream()) + ); + } + public function testParallelBars() { $output = $this->getOutputStream(); @@ -946,6 +1221,66 @@ stream_get_contents($output->getStream()) ); } + + public function testSettingMaxStepsUpdatesPercent() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 100, 0); + $bar->start(); + $bar->setProgress(50); + $bar->setMaxSteps(200); + $bar->display(); + $bar->setMaxSteps(10); + $bar->display(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0/100 [>---------------------------] 0%'. + $this->generateOutput(' 50/100 [==============>-------------] 50%'). + $this->generateOutput(' 50/200 [=======>--------------------] 25%'). + $this->generateOutput(' 50/10 [============================] 100%'), + stream_get_contents($output->getStream()) + ); + + $bar->setMaxSteps(0); + $this->assertSame(0.0, $bar->getProgressPercent()); + } + + public function testFinishAfterSettingMaxStepsBelowProgress() + { + $bar = new ProgressBar($output = $this->getOutputStream(), 100, 0); + $bar->start(); + $bar->setProgress(80); + $bar->setMaxSteps(50); + $bar->finish(); + + rewind($output->getStream()); + $this->assertSame(80, $bar->getProgress()); + $this->assertEquals( + ' 0/100 [>---------------------------] 0%'. + $this->generateOutput(' 80/100 [======================>-----] 80%'). + $this->generateOutput(' 80/80 [============================] 100%'), + stream_get_contents($output->getStream()) + ); + } + + public function testFinishNonDecoratedOutputAfterDrawingMaxStepsBelowProgress() + { + $bar = new ProgressBar($output = $this->getOutputStream(false), 100, 0); + $bar->start(); + $bar->setProgress(80); + $bar->setMaxSteps(50); + $bar->display(); + $bar->finish(); + + rewind($output->getStream()); + $this->assertEquals( + ' 0/100 [>---------------------------] 0%'.\PHP_EOL. + ' 80/100 [======================>-----] 80%'.\PHP_EOL. + ' 80/50 [============================] 100%'.\PHP_EOL. + ' 80/80 [============================] 100%', + stream_get_contents($output->getStream()) + ); + } public function testWithSmallScreen() { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -782,6 +782,31 @@ $dialog->ask($this->createStreamableInputInterfaceMock($this->getInputStream("\n")), $output, $question); } + public function testChoiceKeysWithATrailingBackslash() + { + $question = new ChoiceQuestion('Which drive?', ['C:\\' => 'first', 'D:\\' => 'second']); + (new QuestionHelper())->ask($this->createStreamableInputInterfaceMock($this->getInputStream("C:\\\n")), $output = $this->createOutputInterface(), $question); + + rewind($output->getStream()); + $this->assertStringStartsWith("Which drive?\n [C:\\] first\n [D:\\] second\n > ", str_replace(\PHP_EOL, "\n", stream_get_contents($output->getStream()))); + } + + public function testErrorWithATrailingBackslash() + { + $question = new Question('Which path?'); + $question->setValidator(static function ($value) { + if ('/' !== $value) { + throw new \InvalidArgumentException('The path must not end with \\'); + } + + return $value; + }); + (new QuestionHelper())->ask($this->createStreamableInputInterfaceMock($this->getInputStream("a\\\n/\n")), $output = $this->createOutputInterface(), $question); + + rewind($output->getStream()); + $this->assertSame("Which path?The path must not end with \\\nWhich path?", str_replace(\PHP_EOL, "\n", stream_get_contents($output->getStream()))); + } + public function testAskThrowsExceptionOnMissingInput() { $this->expectException(MissingInputException::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/SymfonyQuestionHelperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/SymfonyQuestionHelperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/SymfonyQuestionHelperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/SymfonyQuestionHelperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -138,6 +138,23 @@ $this->assertOutputNotContains('[Add new tag, hello]', $output); } + public function testAskChoiceDefaultWithATrailingBackslash() + { + $question = new ChoiceQuestion('Which drive?', ['C:\\', 'D:\\'], 0); + (new SymfonyQuestionHelper())->ask($this->createStreamableInputInterfaceMock($this->getInputStream("\n")), $output = $this->createOutputInterface(), $question); + + $this->assertOutputContains('Which drive? [C:\]:', $output); + } + + public function testAskMultiselectChoiceDefaultWithATrailingBackslash() + { + $question = new ChoiceQuestion('Which drives?', ['C:\\', 'D:\\'], '0,1'); + $question->setMultiselect(true); + (new SymfonyQuestionHelper())->ask($this->createStreamableInputInterfaceMock($this->getInputStream("\n")), $output = $this->createOutputInterface(), $question); + + $this->assertOutputContains('Which drives? [C:\, D:\]:', $output); + } + public function testAskReturnsNullIfValidatorAllowsIt() { $questionHelper = new SymfonyQuestionHelper(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/TableTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/TableTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Helper/TableTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Helper/TableTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -581,6 +581,104 @@ TABLE, ], + 'Cell with rowspan after a cell with colspan' => [ + ['ISBN', 'Title', 'Author'], + [ + [ + new TableCell('Divine Comedy', ['colspan' => 2]), + new TableCell('Dante Alighieri', ['rowspan' => 2]), + ], + ['9971-5-0210-0', 'Inferno'], + ], + 'default', + <<<'TABLE' + +---------------+---------+-----------------+ + | ISBN | Title | Author | + +---------------+---------+-----------------+ + | Divine Comedy | Dante Alighieri | + | 9971-5-0210-0 | Inferno | | + +---------------+---------+-----------------+ + + TABLE, + ], + 'Cell with rowspan and line breaks after a cell with colspan' => [ + ['ISBN', 'Title', 'Author'], + [ + [ + new TableCell('Divine Comedy', ['colspan' => 2]), + new TableCell("Dante\nAlighieri\n1265-1321", ['rowspan' => 2]), + ], + ['9971-5-0210-0', 'Inferno'], + ], + 'default', + <<<'TABLE' + +---------------+---------+-----------+ + | ISBN | Title | Author | + +---------------+---------+-----------+ + | Divine Comedy | Dante | + | 9971-5-0210-0 | Inferno | Alighieri | + | | | 1265-1321 | + +---------------+---------+-----------+ + + TABLE, + ], + 'Cells with rowspan around a cell with colspan' => [ + ['ISBN', 'Title', 'Part', 'Author'], + [ + [ + new TableCell('9971-5-0210-0', ['rowspan' => 2]), + new TableCell('Divine Comedy', ['colspan' => 2]), + new TableCell('Dante Alighieri', ['rowspan' => 2]), + ], + ['Inferno', 'Canto I'], + ], + 'default', + <<<'TABLE' + +---------------+---------+---------+-----------------+ + | ISBN | Title | Part | Author | + +---------------+---------+---------+-----------------+ + | 9971-5-0210-0 | Divine Comedy | Dante Alighieri | + | | Inferno | Canto I | | + +---------------+---------+---------+-----------------+ + + TABLE, + ], + 'Cell with rowspan above a cell with colspan' => [ + ['ISBN', 'Title', 'Author', 'Year'], + [ + ['9971-5-0210-0', 'Divine Comedy', new TableCell('Dante Alighieri', ['rowspan' => 2]), '1320'], + [new TableCell('La Vita Nuova', ['colspan' => 2]), '1294'], + ], + 'default', + <<<'TABLE' + +---------------+---------------+-----------------+------+ + | ISBN | Title | Author | Year | + +---------------+---------------+-----------------+------+ + | 9971-5-0210-0 | Divine Comedy | Dante Alighieri | 1320 | + | La Vita Nuova | | 1294 | + +-------------------------------+-----------------+------+ + + TABLE, + ], + 'Cells with rowspan that start in consecutive rows' => [ + ['ISBN', 'Title', 'Author'], + [ + ['99921-58-10-7', new TableCell('Divine Comedy', ['rowspan' => 3]), 'Dante Alighieri'], + [new TableCell('9971-5-0210-0', ['rowspan' => 2]), 'Charles Dickens'], + ['J. R. R. Tolkien'], + ], + 'default', + <<<'TABLE' + +---------------+---------------+------------------+ + | ISBN | Title | Author | + +---------------+---------------+------------------+ + | 99921-58-10-7 | Divine Comedy | Dante Alighieri | + | 9971-5-0210-0 | | Charles Dickens | + | | | J. R. R. Tolkien | + +---------------+---------------+------------------+ + + TABLE, + ], 'Cell with rowspan and colspan with separator inside a rowspan' => [ ['ISBN', 'Author'], [ @@ -988,6 +1086,27 @@ $this->assertEquals($expected, $this->getOutputContent($output)); } + public function testStyleWithoutPaddingAfterACellEndingWithABackslash() + { + $style = new TableStyle(); + $style + ->setHorizontalBorderChars('') + ->setVerticalBorderChars('') + ->setDefaultCrossingChar('') + ->setCellRowContentFormat('%s') + ->setPadType(\STR_PAD_LEFT) + ; + + $table = new Table($output = $this->getOutputStream()); + $table + ->setHeaders(['C:\\', 'D:\\']) + ->setRows([['abcd', 'ef']]) + ->setStyle($style); + $table->render(); + + $this->assertSame(" C:\\D:\\\nabcd ef\n", $this->getOutputContent($output)); + } + public function testRowSeparator() { $table = new Table($output = $this->getOutputStream()); @@ -1048,6 +1167,30 @@ $this->assertEquals($expected, $this->getOutputContent($output)); } + public function testRenderHeaderCellWithRowspanWithNonListKeys() + { + $table = new Table($output = $this->getOutputStream()); + $table + ->setHeaders([[1 => 'ISBN', 2 => new TableCell("Title\n(original)", ['rowspan' => 2]), 3 => 'Author']]) + ->setRows([['9971-5-0210-0', 'Divine Comedy', 'Dante Alighieri']]) + ; + $table->render(); + + $expected = + <<<'TABLE' + +---------------+---------------+-----------------+ + | ISBN | Title | Author | + +---------------+---------------+-----------------+ + | | (original) | | + +---------------+---------------+-----------------+ + | 9971-5-0210-0 | Divine Comedy | Dante Alighieri | + +---------------+---------------+-----------------+ + + TABLE; + + $this->assertEquals($expected, $this->getOutputContent($output)); + } + public function testColumnStyle() { $table = new Table($output = $this->getOutputStream()); @@ -1092,6 +1235,28 @@ $table->render(); } + public function testSetRowWithStringKeys() + { + $table = new Table($output = $this->getOutputStream()); + $table + ->setHeaders(['ISBN', 'Title']) + ->setRow(0, ['isbn' => '9971-5-0210-0', 'title' => 'Divine Comedy']) + ; + $table->render(); + + $expected = + <<<'TABLE' + +---------------+---------------+ + | ISBN | Title | + +---------------+---------------+ + | 9971-5-0210-0 | Divine Comedy | + +---------------+---------------+ + + TABLE; + + $this->assertEquals($expected, $this->getOutputContent($output)); + } + public function testColumnWidth() { $table = new Table($output = $this->getOutputStream()); @@ -2179,6 +2344,54 @@ $this->assertSame($expected, $this->getOutputContent($output)); } + public function testColumnMaxWidthOfCellAfterColspan() + { + $table = new Table($output = $this->getOutputStream()); + $table + ->setHeaders(['ISBN', 'Title', 'Author']) + ->setRows([[new TableCell('Divine Comedy', ['colspan' => 2]), 'Dante Alighieri']]) + ->setColumnMaxWidth(2, 9) + ; + $table->render(); + + $expected = + <<<'TABLE' + +---------+--------+-----------+ + | ISBN | Title | Author | + +---------+--------+-----------+ + | Divine Comedy | Dante | + | | Alighieri | + +------------------+-----------+ + + TABLE; + + $this->assertSame($expected, $this->getOutputContent($output)); + } + + public function testColumnMaxWidthOfCellWithColspanAfterColspan() + { + $table = new Table($output = $this->getOutputStream()); + $table + ->setHeaders(['ISBN', 'Title', 'Author', 'Year']) + ->setRows([[new TableCell('Divine Comedy', ['colspan' => 2]), new TableCell('Dante Alighieri, 1265-1321', ['colspan' => 2])]]) + ->setColumnMaxWidth(2, 8) + ; + $table->render(); + + $expected = + <<<'TABLE' + +---------+--------+----------+----------+ + | ISBN | Title | Author | Year | + +---------+--------+----------+----------+ + | Divine Comedy | Dante Alighieri, | + | | 1265-1321 | + +------------------+---------------------+ + + TABLE; + + $this->assertSame($expected, $this->getOutputContent($output)); + } + public static function provideRenderVerticalTests(): \Traversable { $books = [ @@ -2529,6 +2742,18 @@ 'Books', 'Page 1/2', ]; + + yield 'With a trailing backslash in a header' => [ + <<assertEquals($expected, $this->getLogs()); } + public function testMessageWithATrailingBackslash() + { + $this->getLogger()->error('Cannot write to {dir}', ['dir' => 'C:\\']); + + $this->assertSame(['error Cannot write to C:\\'], $this->getLogs()); + } + public function testObjectCastToString() { $this->getLogger()->warning(new DummyTest()); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Style/SymfonyStyleTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Style/SymfonyStyleTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Console/Tests/Style/SymfonyStyleTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Console/Tests/Style/SymfonyStyleTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -130,6 +130,23 @@ $this->assertCount(1, array_unique(array_map('strlen', $lines)), 'Every line of the block is padded to the same width.'); } + public function testErrorKeepsAnEscapedTagThatIsWrappedAsText() + { + // the escaped "<" falls right where the block wraps the message + $message = str_repeat('a', 110).'y'; + $this->command->setCode(static function (InputInterface $input, OutputInterface $output) use ($message) { + (new SymfonyStyle($input, $output))->error($message); + + return Command::SUCCESS; + }); + + $this->tester->execute([], ['interactive' => false, 'decorated' => false]); + + $lines = array_filter(explode("\n", $this->tester->getDisplay(true)), static fn (string $line): bool => '' !== trim($line)); + + $this->assertSame($message, implode('', array_map(static fn (string $line): string => trim(substr($line, 9)), $lines))); + } + public function testGetErrorStyle() { $input = $this->createStub(InputInterface::class); @@ -215,6 +232,24 @@ $this->assertSame(0, memory_get_usage() - $start); } + public function testBlockKeepsTheBackslashThatEndsAFullLine() + { + $this->command->setCode(static function (InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + $io->error(str_repeat('a', 110).'\b'); + $io->writeln('after'); + + return Command::SUCCESS; + }); + + $this->tester->execute([], ['interactive' => false, 'decorated' => true]); + + $lines = explode("\n", $this->tester->getDisplay(true)); + + $this->assertSame("\e[37;41m [ERROR] ".str_repeat('a', 110)."\\\e[39;49m", $lines[2]); + $this->assertSame('after', $lines[6]); + } + public function testAskAndClearExpectFullSectionCleared() { $answer = 'Answer'; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Filesystem.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Filesystem.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Filesystem.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Filesystem.php 2026-11-04 11:23:00.000000000 +0000 @@ -171,35 +171,55 @@ throw new IOException(\sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); } } elseif (is_dir($file)) { - if (!$isRecursive) { - $tmpName = \dirname(realpath($file)).'/.!'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-!')); + $tmpDir = null; + $origFile = null; - if (file_exists($tmpName)) { - try { - self::doRemove([$tmpName], true); - } catch (IOException) { + try { + if (!$isRecursive && false !== $realPath = realpath($file)) { + for ($i = 0; $i < 10; ++$i) { + // Keep the temporary suffix within six characters to preserve room for descendant paths. + $tmpName = \dirname($realPath).'/.'.strtr(base64_encode(random_bytes(2)), ['/' => '-', '+' => '_', '=' => '']); + + if (!self::box('mkdir', $tmpName, 0o700)) { + continue; + } + + $tmpDir = $tmpName; + + if (self::box('rename', $file, $tmpDir.'/d')) { + $origFile = $file; + $file = $tmpDir.'/d'; + } + + break; } } - if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { - $origFile = $file; - $file = $tmpName; - } else { - $origFile = null; - } - } + try { + $filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); + } catch (\UnexpectedValueException $e) { + if (!file_exists($file)) { + continue; + } - $filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); - self::doRemove(iterator_to_array($filesystemIterator, true), true); + throw new IOException(\sprintf('Failed to remove directory "%s": ', $file).$e->getMessage(), 0, $e); + } - if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { - $lastError = self::$lastError; + self::doRemove(iterator_to_array($filesystemIterator, true), true); - if (null !== $origFile && self::box('rename', $file, $origFile)) { - $file = $origFile; + if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { + throw new IOException(\sprintf('Failed to remove directory "%s": ', $file).self::$lastError); + } + } catch (IOException $e) { + if (null === $origFile || !self::box('rename', $file, $origFile)) { + throw $e; } - throw new IOException(\sprintf('Failed to remove directory "%s": ', $file).$lastError); + throw new IOException(str_replace($file, $origFile, $e->getMessage()), 0, $e); + } finally { + if (null !== $tmpDir) { + self::box('rmdir', $tmpDir); + } } } elseif (!self::box('unlink', $file) && ((self::$lastError && str_contains(self::$lastError, 'Permission denied')) || file_exists($file))) { throw new IOException(\sprintf('Failed to remove file "%s": ', $file).self::$lastError); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Path.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Path.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Path.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Path.php 2026-11-04 11:23:00.000000000 +0000 @@ -213,9 +213,9 @@ } // Maintain scheme - if (false !== $schemeSeparatorPosition = strpos($path, '://')) { - $scheme = substr($path, 0, $schemeSeparatorPosition + 3); - $path = substr($path, $schemeSeparatorPosition + 3); + if (preg_match('{^[a-zA-Z0-9+.-]{2,}://}', $path, $m)) { + $scheme = $m[0]; + $path = substr($path, \strlen($scheme)); } else { $scheme = ''; } @@ -372,7 +372,7 @@ } // URLs and stream wrappers are considered absolute - if (str_contains($path, '://') && null !== parse_url($path, \PHP_URL_SCHEME)) { + if (preg_match('{^[a-zA-Z0-9+.-]{2,}://}', $path)) { return true; } @@ -460,9 +460,9 @@ return self::canonicalize($path); } - if (false !== $schemeSeparatorPosition = strpos($basePath, '://')) { - $scheme = substr($basePath, 0, $schemeSeparatorPosition + 3); - $basePath = substr($basePath, $schemeSeparatorPosition + 3); + if (preg_match('{^[a-zA-Z0-9+.-]{2,}://}', $basePath, $m)) { + $scheme = $m[0]; + $basePath = substr($basePath, \strlen($scheme)); } else { $scheme = ''; } @@ -593,7 +593,7 @@ */ public static function isLocal(string $path): bool { - return '' !== $path && !str_contains($path, '://'); + return '' !== $path && !preg_match('{^[a-zA-Z0-9+.-]{2,}://}', $path); } /** @@ -795,9 +795,9 @@ } // Remember scheme as part of the root, if any - if (false !== $schemeSeparatorPosition = strpos($path, '://')) { - $root = substr($path, 0, $schemeSeparatorPosition + 3); - $path = substr($path, $schemeSeparatorPosition + 3); + if (preg_match('{^[a-zA-Z0-9+.-]{2,}://}', $path, $m)) { + $root = $m[0]; + $path = substr($path, \strlen($root)); } else { $root = ''; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/FilesystemTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -358,6 +358,270 @@ $this->assertFileDoesNotExist($basePath.'dir'); } + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testRemoveDoesNotDeleteExistingTemporaryDirectories() + { + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; + mkdir($directory); + file_put_contents($directory.'/cache', 'cache'); + $temporaryDirectories = [$this->workspace.'/.AAA']; + + foreach ([2, 8] as $length) { + $temporaryDirectories[] = $this->workspace.'/.!'.strrev(strtr(base64_encode(str_repeat("\0", $length)), '/=', '-!')); + } + + foreach ($temporaryDirectories as $temporaryDirectory) { + mkdir($temporaryDirectory); + file_put_contents($temporaryDirectory.'/foreign', 'foreign'); + } + + $attempts = 0; + $GLOBALS['filesystemRemoveCollision'] = static function (int $length) use (&$attempts): string { + ++$attempts; + + return str_repeat("\0", $length); + }; + + try { + $this->filesystem->remove($directory); + } finally { + unset($GLOBALS['filesystemRemoveCollision']); + } + + $this->assertDirectoryDoesNotExist($directory); + $this->assertSame(10, $attempts); + + foreach ($temporaryDirectories as $temporaryDirectory) { + $this->assertStringEqualsFile($temporaryDirectory.'/foreign', 'foreign'); + } + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testRemoveRetriesTemporaryDirectoryCollisions() + { + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; + $temporaryDirectory = $this->workspace.'/.AAA'; + mkdir($directory); + mkdir($temporaryDirectory); + file_put_contents($directory.'/cache', 'cache'); + file_put_contents($temporaryDirectory.'/foreign', 'foreign'); + $attempts = 0; + $removedDirectory = null; + $GLOBALS['filesystemRemoveCollision'] = static function (int $length) use (&$attempts): string { + return str_repeat(\chr($attempts++), $length); + }; + $GLOBALS['filesystemRemoveIterator'] = static function (\FilesystemIterator $iterator, bool $preserveKeys) use (&$removedDirectory): array { + $removedDirectory ??= $iterator->getPath(); + + return iterator_to_array($iterator, $preserveKeys); + }; + + try { + $this->filesystem->remove($directory); + } finally { + unset($GLOBALS['filesystemRemoveCollision'], $GLOBALS['filesystemRemoveIterator']); + } + + $this->assertSame(2, $attempts); + $this->assertSame($this->workspace.'/.AQE/d', $removedDirectory); + $this->assertDirectoryDoesNotExist($directory); + $this->assertDirectoryDoesNotExist($this->workspace.'/.AQE'); + $this->assertStringEqualsFile($temporaryDirectory.'/foreign', 'foreign'); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testRemoveDoesNotInterfereWithConcurrentRemoval() + { + require __DIR__.'/Fixtures/remove_collision.php'; + + $first = $this->workspace.'/first'; + $second = $this->workspace.'/second'; + mkdir($first); + mkdir($second); + file_put_contents($first.'/first-cache', 'first'); + file_put_contents($second.'/second-cache', 'second'); + $phase = 0; + $activeDirectory = null; + $filesystem = $this->filesystem; + $secondRemoval = new \Fiber(static fn () => $filesystem->remove($second)); + $GLOBALS['filesystemRemoveCollision'] = static fn (int $length): string => str_repeat("\0", $length); + $GLOBALS['filesystemRemoveIterator'] = static function (\FilesystemIterator $iterator, bool $preserveKeys) use (&$phase, &$activeDirectory, $secondRemoval): array { + if (0 === $phase) { + $activeDirectory = $iterator->getPath(); + $snapshot = iterator_to_array($iterator, $preserveKeys); + $phase = 1; + $secondRemoval->start(); + + return $snapshot; + } + + if (1 === $phase && $iterator->getPath() === $activeDirectory && is_file($activeDirectory.'/second-cache')) { + $phase = 2; + \Fiber::suspend(); + } + + return iterator_to_array($iterator, $preserveKeys); + }; + + try { + $filesystem->remove($first); + } finally { + unset($GLOBALS['filesystemRemoveCollision'], $GLOBALS['filesystemRemoveIterator']); + + if ($secondRemoval->isSuspended()) { + $secondRemoval->resume(); + } + } + + $this->assertTrue($secondRemoval->isTerminated()); + $this->assertDirectoryDoesNotExist($first); + $this->assertDirectoryDoesNotExist($second); + $this->assertCount(0, new \FilesystemIterator($this->workspace)); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testRemoveRestoresDirectoryWhenConcurrentWritePreventsRemoval() + { + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; + mkdir($directory); + file_put_contents($directory.'/cache', 'cache'); + $written = false; + $GLOBALS['filesystemRemoveIterator'] = static function (\FilesystemIterator $iterator, bool $preserveKeys) use (&$written): array { + $snapshot = iterator_to_array($iterator, $preserveKeys); + + if (!$written) { + $written = true; + file_put_contents($iterator->getPath().'/concurrent', 'concurrent'); + } + + return $snapshot; + }; + + try { + $this->filesystem->remove($directory); + $this->fail('Expected removal to fail.'); + } catch (IOException $e) { + $this->assertStringContainsString('Failed to remove directory "'.$directory.'": ', $e->getMessage()); + $this->assertStringEqualsFile($directory.'/concurrent', 'concurrent'); + $this->assertCount(1, new \FilesystemIterator($this->workspace)); + } finally { + unset($GLOBALS['filesystemRemoveIterator']); + } + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testRemoveIgnoresDirectoryRemovedConcurrently() + { + require __DIR__.'/Fixtures/remove_collision.php'; + + $directory = $this->workspace.'/directory'; + mkdir($directory); + $attempts = 0; + $GLOBALS['filesystemRemoveRealpath'] = static function (string $path): string|false { + if (is_dir($path)) { + rmdir($path); + } + + return realpath($path); + }; + $GLOBALS['filesystemRemoveCollision'] = static function (int $length) use (&$attempts): string { + ++$attempts; + + return random_bytes($length); + }; + + try { + $this->filesystem->remove($directory); + } finally { + unset($GLOBALS['filesystemRemoveRealpath'], $GLOBALS['filesystemRemoveCollision']); + } + + $this->assertSame(0, $attempts); + $this->assertCount(0, new \FilesystemIterator($this->workspace)); + } + + public function testRemoveWithRestrictiveUmask() + { + $directory = $this->workspace.'/directory'; + mkdir($directory); + file_put_contents($directory.'/cache', 'cache'); + $umask = umask(0o777); + + try { + $this->filesystem->remove($directory); + } finally { + umask($umask); + } + + $this->assertCount(0, new \FilesystemIterator($this->workspace)); + } + + public function testRemoveTreeWithPathsNearLengthLimit() + { + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('Windows path limits depend on long-path support.'); + } + + $directory = $this->workspace.'/d'; + mkdir($directory); + $path = $directory; + $maxLength = \PHP_MAXPATHLEN - 8; + + while (\strlen($path) < $maxLength - 1) { + $path .= '/'.str_repeat('x', min(200, $maxLength - \strlen($path) - 1)); + mkdir($path); + } + + file_put_contents($path.'/f', 'contents'); + $this->assertGreaterThanOrEqual(\PHP_MAXPATHLEN - 7, \strlen($path.'/f')); + + $this->filesystem->remove($directory); + + $this->assertDirectoryDoesNotExist($directory); + $this->assertCount(0, new \FilesystemIterator($this->workspace)); + } + + public function testRemoveCleansReservationWhenRenameIsDisabled() + { + $directory = $this->workspace.'/directory'; + mkdir($directory); + file_put_contents($directory.'/cache', 'cache'); + $finder = new PhpExecutableFinder(); + $process = new Process(array_merge([$finder->find(false)], $finder->findArguments(), ['-ddisable_functions=rename', __DIR__.'/Fixtures/remove_without_rename.php', $directory])); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertSame('Unable to perform filesystem operation because the "rename()" function has been disabled.'.\PHP_EOL, $process->getOutput()); + $this->assertStringEqualsFile($directory.'/cache', 'cache'); + $this->assertCount(1, new \FilesystemIterator($this->workspace)); + } + public function testRemoveThrowsExceptionOnPermissionDenied() { $this->markAsSkippedIfChmodIsMissing(); @@ -380,6 +644,51 @@ } } + public function testRemoveRestoresDirectoryWhenNestedRemovalFails() + { + $this->markAsSkippedIfChmodIsMissing(); + + $directory = $this->workspace.'/directory'; + $file = $directory.'/sub/file'; + mkdir($directory.'/sub', 0o777, true); + touch($file); + chmod($directory.'/sub', 0o500); + + try { + $this->filesystem->remove($directory); + $this->fail('Filesystem::remove() should throw an exception'); + } catch (IOException $e) { + $this->assertStringStartsWith('Failed to remove file "'.$file.'": unlink(', $e->getMessage()); + $this->assertStringEndsWith('): Permission denied', $e->getMessage()); + $this->assertFileExists($file); + $this->assertCount(1, new \FilesystemIterator($this->workspace)); + } finally { + chmod($directory.'/sub', 0o777); + } + } + + public function testRemoveThrowsExceptionOnUnreadableDirectory() + { + $this->markAsSkippedIfChmodIsMissing(); + + $directory = $this->workspace.'/directory'; + mkdir($directory); + touch($directory.'/file'); + chmod($directory, 0o300); + + try { + $this->filesystem->remove($directory); + $this->fail('Filesystem::remove() should throw an exception'); + } catch (IOException $e) { + $this->assertStringStartsWith('Failed to remove directory "'.$directory.'": FilesystemIterator::__construct(', $e->getMessage()); + $this->assertStringEndsWith('): Failed to open directory: Permission denied', $e->getMessage()); + $this->assertFileExists($directory.'/file'); + $this->assertCount(1, new \FilesystemIterator($this->workspace)); + } finally { + chmod($directory, 0o777); + } + } + public function testRemoveCleansInvalidLinks() { $this->markAsSkippedIfSymlinkIsMissing(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_collision.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_collision.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_collision.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_collision.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Filesystem; + +function random_bytes(int $length): string +{ + return isset($GLOBALS['filesystemRemoveCollision']) ? ($GLOBALS['filesystemRemoveCollision'])($length) : \random_bytes($length); +} + +/** + * @param \Traversable $iterator + * + * @return array + */ +function iterator_to_array(\Traversable $iterator, bool $preserveKeys = true): array +{ + return isset($GLOBALS['filesystemRemoveIterator']) ? ($GLOBALS['filesystemRemoveIterator'])($iterator, $preserveKeys) : \iterator_to_array($iterator, $preserveKeys); +} + +function realpath(string $path): string|false +{ + return isset($GLOBALS['filesystemRemoveRealpath']) ? ($GLOBALS['filesystemRemoveRealpath'])($path) : \realpath($path); +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_without_rename.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_without_rename.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_without_rename.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/Fixtures/remove_without_rename.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; + +require __DIR__.'/../../Exception/ExceptionInterface.php'; +require __DIR__.'/../../Exception/IOExceptionInterface.php'; +require __DIR__.'/../../Exception/IOException.php'; +require __DIR__.'/../../Filesystem.php'; + +if (\function_exists('rename')) { + throw new \LogicException('The rename function must be disabled.'); +} + +try { + (new Filesystem())->remove($argv[1]); + throw new \LogicException('Removal must fail when rename() is disabled.'); +} catch (IOException $e) { + echo $e->getMessage().\PHP_EOL; +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/PathTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/PathTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Filesystem/Tests/PathTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Filesystem/Tests/PathTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -1125,6 +1125,69 @@ $this->assertSame('/path/to/test/subdir', Path::join('/path', 'to', '/test', 'subdir/')); } + public function testInvalidSchemeIsNotARoot() + { + $this->assertFalse(Path::isBasePath('/base', '/base/../../etc/x://../passwd')); + $this->assertFalse(Path::isBasePath('/base', '/base/x://../../etc/passwd')); + $this->assertSame('/etc/passwd', Path::canonicalize('/base/../../etc/x://../passwd')); + $this->assertSame('/etc/passwd', Path::join('/base', '../../etc/x://../passwd')); + $this->assertSame('/etc/passwd', Path::makeAbsolute('../../etc/x://../passwd', '/base')); + $this->assertSame('../etc/passwd', Path::makeRelative('/base/../../etc/x://../passwd', '/base')); + + $this->assertSame('b:/c', Path::canonicalize('a/../b://c')); + $this->assertSame('x', Path::canonicalize('.://../x')); + $this->assertSame('x', Path::canonicalize('a_b://../x')); + $this->assertSame('x', Path::canonicalize('://../x')); + $this->assertSame('a/c', Path::canonicalize('a/b://../c')); + $this->assertSame('../b', Path::canonicalize('../a://../b')); + } + + public function testIsAbsoluteAndGetRootIgnoreInvalidScheme() + { + $this->assertTrue(Path::isAbsolute('/etc/x://y')); + $this->assertFalse(Path::isAbsolute('a/b:///c')); + $this->assertFalse(Path::isAbsolute('a_b:///c')); + $this->assertFalse(Path::isAbsolute(':///c')); + + $this->assertSame('/', Path::getRoot('/etc/x://y')); + $this->assertSame('', Path::getRoot('a/b:///c')); + + $this->assertSame('/a/b:/x/c', Path::makeAbsolute('c', '/a/b://x')); + } + + public function testIsLocalIgnoresInvalidScheme() + { + $this->assertTrue(Path::isLocal('/etc/x://y')); + $this->assertTrue(Path::isLocal('a/b://c')); + } + + public function testSingleLetterBeforeDoubleSlashIsNotAScheme() + { + $this->assertSame(Path::canonicalize('x:/../y'), Path::canonicalize('x://../y')); + $this->assertSame(Path::getRoot('x:/y'), Path::getRoot('x://y')); + $this->assertTrue(Path::isLocal('x://y')); + } + + public function testValidSchemeIsARoot() + { + $this->assertSame('phar:///style.css', Path::canonicalize('phar:///../style.css')); + $this->assertSame('PHAR:///style.css', Path::canonicalize('PHAR:///../style.css')); + $this->assertSame('file:///etc/passwd', Path::canonicalize('file:///../etc/passwd')); + $this->assertSame('vfs://root/file', Path::canonicalize('vfs://root/dir/../file')); + $this->assertSame('s3://file', Path::canonicalize('s3://../file')); + $this->assertSame('compress.zlib:///file.gz', Path::canonicalize('compress.zlib:///../file.gz')); + $this->assertSame('1a://x', Path::canonicalize('1a://../x')); + $this->assertSame('+a://x', Path::canonicalize('+a://../x')); + + $this->assertTrue(Path::isAbsolute('PHAR:///c')); + $this->assertTrue(Path::isAbsolute('compress.zlib:///c')); + $this->assertTrue(Path::isAbsolute('1a:///c')); + $this->assertSame('compress.zlib:///', Path::getRoot('compress.zlib:///c')); + $this->assertFalse(Path::isLocal('compress.zlib:///c')); + $this->assertFalse(Path::isLocal('s3://bucket/c')); + $this->assertFalse(Path::isLocal('1a://x')); + } + public function testGetHomeDirectoryFailsIfNotSupportedOperatingSystem() { $this->expectException(\RuntimeException::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Finder/Glob.php symfony-6.4.50+dfsg/src/Symfony/Component/Finder/Glob.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Finder/Glob.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Finder/Glob.php 2026-11-04 11:23:00.000000000 +0000 @@ -77,8 +77,8 @@ if ($delimiter === $car || '.' === $car || '(' === $car || ')' === $car || '|' === $car || '+' === $car || '^' === $car || '$' === $car) { $regex .= "\\$car"; - } elseif ('[' === $car && !$escaping && null !== $class = self::bracketExpressionToRegex($glob, $i, $delimiter, $bracketScanEnd)) { - $regex .= $class; + } elseif ('[' === $car && !$escaping) { + $regex .= self::bracketExpressionToRegex($glob, $i, $delimiter, $bracketScanEnd); } elseif ('*' === $car) { if ($escaping || $strictWildcardSlash || !str_ends_with($regex, '.*')) { // ".*.*" matches the same as ".*" but backtracks quadratically @@ -135,16 +135,16 @@ } /** - * Returns null when a "/" comes before the closing "]", and "\[" when no "]" closes the "[" at position $i. + * Returns "\[" when no "]" closes the "[" at position $i, or when a "/" comes before the closing "]". * * $scanEnd is where the last scan stopped without finding a "]": every "[" before that position gets the same result. */ - private static function bracketExpressionToRegex(string $glob, int &$i, string $delimiter, int &$scanEnd): ?string + private static function bracketExpressionToRegex(string $glob, int &$i, string $delimiter, int &$scanEnd): string { $sizeGlob = \strlen($glob); if ($i < $scanEnd) { - return $scanEnd < $sizeGlob ? null : '\\['; + return '\\['; } $class = '['; @@ -159,9 +159,7 @@ } if ('/' === $car || ('\\' === $car && '/' === ($glob[$j + 1] ?? ''))) { - $scanEnd = $j; - - return null; + break; } if ('[' === $car && preg_match('/\[:[a-z]++:\]/A', $glob, $m, 0, $j)) { @@ -178,7 +176,7 @@ $class .= $car; } } - $scanEnd = $sizeGlob; + $scanEnd = $j; return '\\['; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Finder/Tests/GlobTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Finder/Tests/GlobTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Finder/Tests/GlobTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Finder/Tests/GlobTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -174,6 +174,23 @@ $this->assertSame('#^(?=[^\.])[a[]$#', Glob::toRegex('[a[]')); } + public function testGlobToRegexBracketWithSlash() + { + $this->assertSame(1, preg_match(Glob::toRegex('a[/]b'), 'a[/]b')); + $this->assertSame(0, preg_match(Glob::toRegex('a[/]b'), 'a/b')); + $this->assertSame(1, preg_match(Glob::toRegex('[/]'), '[/]')); + $this->assertSame(1, preg_match(Glob::toRegex('[a/b]'), '[a/b]')); + $this->assertSame(1, preg_match(Glob::toRegex('x[y/z]w'), 'x[y/z]w')); + $this->assertSame(0, preg_match(Glob::toRegex('x[y/z]w'), 'xzw')); + $this->assertSame(1, preg_match(Glob::toRegex('[a[/]'), '[a[/]')); + + $this->assertSame(1, preg_match(Glob::toRegex('[a/b][c]'), '[a/b]c')); + $this->assertSame(0, preg_match(Glob::toRegex('[a/b][c]'), '[a/b]d')); + + $this->assertSame(1, preg_match(Glob::toRegex('[*/]x'), '[foo/]x')); + $this->assertSame(0, preg_match(Glob::toRegex('[*/]x'), '[foo/bar/]x')); + } + public function testGlobToRegexBracketsAfterAnUnclosedOne() { $this->assertSame('#^(?=[^\.])\[\[:alpha:]$#', Glob::toRegex('[[:alpha:]')); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php 2026-11-04 11:23:00.000000000 +0000 @@ -101,7 +101,7 @@ */ public function reverseTransform(mixed $value): ?\DateTime { - if (empty($value)) { + if (null === $value || '' === $value) { return null; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\FormConfigBuilder; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; @@ -101,10 +102,10 @@ $data = []; } - // Remove all empty rows + // Remove all rows missing from the submitted data if ($this->allowDelete) { foreach ($form as $name => $child) { - if (!isset($data[$name])) { + if (!\array_key_exists($name, $data)) { $form->remove($name); } } @@ -113,7 +114,7 @@ // Add all additional rows if ($this->allowAdd) { foreach ($data as $name => $value) { - if (!$form->has($name)) { + if (!$form->has($name) && '' !== $name && FormConfigBuilder::isValidName((string) $name)) { $form->add($name, $this->type, array_replace([ 'property_path' => '['.$name.']', ], $this->prototypeOptions)); @@ -142,7 +143,7 @@ $initialKeys = $this->initialKeys[$form] ?? []; /** @var FormInterface $child */ foreach ($form as $name => $child) { - if (!$child->isValid() || !$child->isSynchronized()) { + if (!$child->isSubmitted() || !$child->isValid() || !$child->isSynchronized()) { continue; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -97,7 +97,7 @@ if (\is_array($params) && \is_array($files)) { $data = FormUtil::mergeParamsAndFiles($params, $files); } else { - $data = $params ?: $files; + $data = null === $params || '' === $params || [] === $params ? $files : $params; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationMapper.php 2026-11-04 11:23:00.000000000 +0000 @@ -56,12 +56,13 @@ $violationPath = null; $relativePath = null; + $originForms = []; $match = false; // Don't create a ViolationPath instance for empty property paths if ('' !== $violation->getPropertyPath()) { $violationPath = new ViolationPath($violation->getPropertyPath()); - $relativePath = $this->reconstructPath($violationPath, $form); + $relativePath = $this->reconstructPath($violationPath, $form, $originForms); } // This case happens if the violation path is empty and thus @@ -90,7 +91,7 @@ $scope = $relativePath->getRoot(); $it = new PropertyPathIterator($relativePath); - while ($this->acceptsErrors($scope) && null !== ($child = $this->matchChild($scope, $it))) { + while ($this->acceptsErrors($scope) && null !== ($child = $this->matchChild($scope, $it, $originForms))) { $scope = $child; $it->next(); $match = true; @@ -231,10 +232,12 @@ * * If a matching child is found, it is returned. Otherwise * null is returned. + * + * @param array $originForms The originating forms indexed by their offset in the reconstructed property path */ - private function matchChild(FormInterface $form, PropertyPathIteratorInterface $it): ?FormInterface + private function matchChild(FormInterface $form, PropertyPathIteratorInterface $it, array $originForms): ?FormInterface { - $target = null; + $candidates = []; $chunk = ''; $foundAtIndex = null; @@ -251,6 +254,7 @@ $children = iterator_to_array(new \RecursiveIteratorIterator(new InheritDataAwareIterator($form)), false); $allChildren = $children; $startIndex = $it->valid() ? $it->key() : null; + $origin = null !== $startIndex ? ($originForms[$startIndex] ?? null) : null; while ($it->valid()) { if ($it->isIndex()) { @@ -278,8 +282,11 @@ foreach ($children as $i => $child) { $childPath = (string) $child->getPropertyPath(); if ($childPath === $chunk) { - $target = $child; - $foundAtIndex = $it->key(); + if ($foundAtIndex !== $index = $it->key()) { + $candidates = []; + $foundAtIndex = $index; + } + $candidates[] = $child; } elseif (str_starts_with($childPath, $chunk)) { continue; } @@ -291,15 +298,15 @@ } // No child matched the violation path verbatim: retry with camelized paths. - if (null === $target && null !== $startIndex) { - return $this->matchCamelizedChild($allChildren, $it, $startIndex); + if (null === $foundAtIndex) { + return null !== $startIndex ? $this->matchCamelizedChild($allChildren, $it, $startIndex, $originForms) : null; } - if (null !== $foundAtIndex) { - $it->seek($foundAtIndex); + if (\in_array($origin, $candidates, true)) { + $candidates = [$origin]; } - return $target; + return $this->selectCandidate($candidates, $it, $foundAtIndex, $originForms); } /** @@ -310,11 +317,12 @@ * setters, which makes a child named "discount_price" read and write the * "discountPrice" property the violation points at. * - * @param list $children + * @param list $children + * @param array $originForms */ - private function matchCamelizedChild(array $children, PropertyPathIteratorInterface $it, int $startIndex): ?FormInterface + private function matchCamelizedChild(array $children, PropertyPathIteratorInterface $it, int $startIndex, array $originForms): ?FormInterface { - $target = null; + $candidates = []; $chunk = ''; $foundAtIndex = null; $childPaths = []; @@ -332,8 +340,11 @@ foreach ($childPaths as $i => $childPath) { if ($childPath === $chunk) { - $target = $children[$i]; - $foundAtIndex = $it->key(); + if ($foundAtIndex !== $index = $it->key()) { + $candidates = []; + $foundAtIndex = $index; + } + $candidates[] = $children[$i]; } elseif (str_starts_with($childPath, $chunk)) { continue; } @@ -342,17 +353,59 @@ } } - if (null !== $foundAtIndex) { - $it->seek($foundAtIndex); + return null !== $foundAtIndex ? $this->selectCandidate($candidates, $it, $foundAtIndex, $originForms) : null; + } + + /** + * Selects the candidate through which the longest part of the property path can be matched, the last one on a tie. + * + * @param non-empty-list $candidates The children whose property path matches the path up to $foundAtIndex + * @param array $originForms + */ + private function selectCandidate(array $candidates, PropertyPathIteratorInterface $it, int $foundAtIndex, array $originForms): FormInterface + { + $target = end($candidates); + + if (1 < \count($candidates)) { + $deepestIndex = -1; + + foreach ($candidates as $candidate) { + $it->seek($foundAtIndex); + + if ($deepestIndex <= $index = $this->matchDescendants($candidate, $it, $originForms)) { + $target = $candidate; + $deepestIndex = $index; + } + } } + $it->seek($foundAtIndex); + return $target; } /** + * Returns the index of the last element of the property path that the mapping can reach through the descendants of the form. + * + * @param array $originForms + */ + private function matchDescendants(FormInterface $form, PropertyPathIteratorInterface $it, array $originForms): int + { + $index = $it->key(); + + for ($it->next(); $this->acceptsErrors($form) && $it->valid() && null !== $form = $this->matchChild($form, $it, $originForms); $it->next()) { + $index = $it->key(); + } + + return $index; + } + + /** * Reconstructs a property path from a violation path and a form tree. + * + * @param array $originForms The originating forms indexed by their offset in the reconstructed property path */ - private function reconstructPath(ViolationPath $violationPath, FormInterface $origin): ?RelativePath + private function reconstructPath(ViolationPath $violationPath, FormInterface $origin, array &$originForms): ?RelativePath { $propertyPathBuilder = new PropertyPathBuilder($violationPath); $it = $violationPath->getIterator(); @@ -386,6 +439,7 @@ break; } + $originForms[$i] = $scope; $propertyPathBuilder->replace($i, 1, $propertyPath); $i += $propertyPath->getLength(); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/NativeRequestHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/NativeRequestHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/NativeRequestHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/NativeRequestHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -108,7 +108,7 @@ if (\is_array($params) && \is_array($files)) { $data = FormUtil::mergeParamsAndFiles($params, $files); } else { - $data = $params ?: $files; + $data = null === $params || '' === $params || [] === $params ? $files : $params; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.da.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.da.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.da.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.da.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Værdien er ikke en gyldig fødselsdato. + Værdien er ikke en gyldig fødselsdato. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Værdien er ikke en gyldig farve. + Værdien er ikke en gyldig farve. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Værdien er ikke et gyldigt datointerval. + Værdien er ikke et gyldigt datointerval. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Værdien er ikke en gyldig fil. + Værdien er ikke en gyldig fil. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Værdien er ikke et gyldigt heltal. + Værdien er ikke et gyldigt heltal. This value is not a valid language. @@ -72,15 +72,15 @@ This value is not a valid locale. - Værdien er ikke en gyldig sprogkode. + Værdien er ikke en gyldig sprogkode. This value is not a valid money amount. - Værdien er ikke et gyldigt beløb. + Værdien er ikke et gyldigt beløb. This value is not a valid number. - Værdien er ikke et gyldigt tal. + Værdien er ikke et gyldigt tal. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Værdien er ikke en gyldig procentværdi. + Værdien er ikke en gyldig procentværdi. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Værdien er ikke et gyldigt søgeord. + Værdien er ikke et gyldigt søgeord. This value is not a valid phone number. - Værdien er ikke et gyldigt telefonnummer. + Værdien er ikke et gyldigt telefonnummer. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Værdien er ikke en gyldig mulighed. + Værdien er ikke en gyldig mulighed. This value is not a valid range. - Værdien er ikke et gyldigt interval. + Værdien er ikke et gyldigt interval. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Den nedre grænse må ikke være større end den øvre grænse. + Den nedre grænse må ikke være større end den øvre grænse. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.de.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.de.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.de.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.de.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Dieser Wert ist kein gültiges Geburtsdatum. + Dieser Wert ist kein gültiges Geburtsdatum. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Dieser Wert ist keine gültige Farbe. + Dieser Wert ist keine gültige Farbe. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Dieser Wert ist kein gültiges Datumsintervall. + Dieser Wert ist kein gültiges Datumsintervall. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Dieser Wert ist keine gültige Datei. + Dieser Wert ist keine gültige Datei. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Dieser Wert ist keine gültige Ganzzahl. + Dieser Wert ist keine gültige Ganzzahl. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Dieser Wert ist kein gültiger Geldbetrag. + Dieser Wert ist kein gültiger Geldbetrag. This value is not a valid number. - Dieser Wert ist keine gültige Zahl. + Dieser Wert ist keine gültige Zahl. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Dieser Wert ist kein gültiger Prozentwert. + Dieser Wert ist kein gültiger Prozentwert. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Dieser Wert ist kein gültiger Suchbegriff. + Dieser Wert ist kein gültiger Suchbegriff. This value is not a valid phone number. - Dieser Wert ist keine gültige Telefonnummer. + Dieser Wert ist keine gültige Telefonnummer. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Dieser Wert ist keine gültige Option. + Dieser Wert ist keine gültige Option. This value is not a valid range. - Dieser Wert ist kein gültiger Bereich. + Dieser Wert ist kein gültiger Bereich. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Der untere Grenzwert darf nicht größer als der obere Grenzwert sein. + Der untere Grenzwert darf nicht größer als der obere Grenzwert sein. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.el.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.el.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.el.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.el.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -16,23 +16,23 @@ This value is not a valid HTML5 color. - Αυτή η τιμή δέν έναι έγκυρο χρώμα HTML5. + Αυτή η τιμή δεν είναι έγκυρο χρώμα HTML5. This value is not a valid birthdate. - Αυτή η τιμή δεν είναι έγκυρη ημερομηνία γέννησης. + Αυτή η τιμή δεν είναι έγκυρη ημερομηνία γέννησης. The selected choice is invalid. - Η επιλεγμένη επιλογή δέν είναι έγκυρη. + Η επιλεγμένη επιλογή δεν είναι έγκυρη. The collection is invalid. - Η συλλογή δέν είναι έγκυρη. + Η συλλογή δεν είναι έγκυρη. This value is not a valid color. - Αυτή η τιμή δεν είναι έγκυρο χρώμα. + Αυτή η τιμή δεν είναι έγκυρο χρώμα. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Αυτή η τιμή δεν είναι έγκυρο διάστημα ημερομηνίας. + Αυτή η τιμή δεν είναι έγκυρο χρονικό διάστημα. This value is not a valid date and time. @@ -52,19 +52,19 @@ This value is not a valid date. - Αυτή η τιμή δεν είναι έγκυρη ημερομηνία. + Αυτή η τιμή δεν αντιστοιχεί σε έγκυρη ημερομηνία. This value is not a valid file. - Αυτή η τιμή δεν είναι έγκυρο αρχείο. + Αυτή η τιμή δεν είναι έγκυρο αρχείο. The hidden field is invalid. - Το κρυφό πεδίο δέν είναι έγκυρο. + Το κρυφό πεδίο δεν είναι έγκυρο. This value is not a valid integer. - Αυτή η τιμή δεν είναι έγκυρος ακέραιος αριθμός. + Αυτή η τιμή δεν είναι έγκυρος ακέραιος αριθμός. This value is not a valid language. @@ -72,27 +72,27 @@ This value is not a valid locale. - Αυτή η τιμή δεν είναι έγκυρη τοπικοποίηση. + Αυτή η τιμή δεν αντιστοιχεί σε μια έγκυρη τοπική ρύθμιση. This value is not a valid money amount. - Αυτή η τιμή δεν είναι έγκυρο χρηματικό ποσό. + Αυτή η τιμή δεν είναι έγκυρο χρηματικό ποσό. This value is not a valid number. - Αυτή η τιμή δεν είναι έγκυρος αριθμός. + Αυτή η τιμή δεν είναι έγκυρος αριθμός. The password is invalid. - Ο κωδικός δέν είναι έγκυρος. + Ο κωδικός δεν είναι έγκυρος. This value is not a valid percentage. - Αυτή η τιμή δεν είναι έγκυρη ποσοστιαία τιμή. + Αυτή η τιμή δεν είναι έγκυρο ποσοστό. The values do not match. - Οι τιμές δέν ταιριάζουν. + Οι τιμές δεν ταιριάζουν. This value is not a valid time. @@ -108,11 +108,11 @@ This value is not a valid search term. - Αυτή η τιμή δεν είναι έγκυρος όρος αναζήτησης. + Αυτή η τιμή δεν είναι έγκυρος όρος αναζήτησης. This value is not a valid phone number. - Αυτή η τιμή δεν είναι έγκυρος αριθμός τηλεφώνου. + Αυτή η τιμή δεν είναι έγκυρος αριθμός τηλεφώνου. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Αυτή η τιμή δεν είναι έγκυρη επιλογή. + Αυτή η τιμή δεν είναι έγκυρη επιλογή. This value is not a valid range. - Αυτή η τιμή δεν είναι έγκυρο εύρος. + Αυτή η τιμή δεν είναι έγκυρο εύρος. This value is not a valid week. @@ -136,7 +136,7 @@ This value is not a valid UUID. - Αυτή η τιμή δεν είναι έγκυρο UUID. + Αυτή η τιμή δεν είναι έγκυρο UUID. The lower bound must not be greater than the upper bound. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.et.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.et.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.et.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.et.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - See väärtus ei ole korrektne sünnikuupäev. + See väärtus ei ole korrektne sünnikuupäev. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - See väärtus ei ole korrektne värv. + See väärtus ei ole korrektne värv. This value is not a valid country. @@ -44,11 +44,11 @@ This value is not a valid date interval. - See väärtus ei ole korrektne kuupäevade vahemik. + See väärtus ei ole korrektne kuupäevade vahemik. This value is not a valid date and time. - See väärtus ei ole korrektne kuupäev ja kellaaeg. + See väärtus ei ole korrektne kuupäev ja kellaaeg. This value is not a valid date. @@ -56,7 +56,7 @@ This value is not a valid file. - See väärtus ei ole korrektne fail. + See väärtus ei ole korrektne fail. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - See väärtus ei ole korrektne täisarv. + See väärtus ei ole korrektne täisarv. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - See väärtus ei ole korrektne rahasumma. + See väärtus ei ole korrektne rahasumma. This value is not a valid number. - See väärtus ei ole korrektne number. + See väärtus ei ole korrektne number. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - See väärtus ei ole korrektne protsendiline väärtus. + See väärtus ei ole korrektne protsendiline väärtus. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - See väärtus ei ole korrektne otsingutermin. + See väärtus ei ole korrektne otsingutermin. This value is not a valid phone number. - See väärtus ei ole korrektne telefoninumber. + See väärtus ei ole korrektne telefoninumber. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - See väärtus ei ole korrektne valik. + See väärtus ei ole korrektne valik. This value is not a valid range. - See väärtus ei ole korrektne vahemik. + See väärtus ei ole korrektne vahemik. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Alumine piir ei tohi olla suurem kui ülemine piir. + Alumine piir ei tohi olla suurem kui ülemine piir. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.hr.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.hr.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.hr.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.hr.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Ova vrijednost nije valjan datum rođenja. + Ova vrijednost nije valjani datum rođenja. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Ova vrijednost nije valjana boja. + Ova vrijednost nije valjana boja. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Ova vrijednost nije valjan vremenski interval. + Ova vrijednost nije valjani vremenski interval. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Ova vrijednost nije valjana datoteka. + Ova vrijednost nije valjana datoteka. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Ova vrijednost nije valjan cijeli broj. + Ova vrijednost nije valjani cijeli broj. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Ova vrijednost nije valjan novčani iznos. + Ova vrijednost nije valjani novčani iznos. This value is not a valid number. - Ova vrijednost nije valjan broj. + Ova vrijednost nije valjani broj. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Ova vrijednost nije valjana postotna vrijednost. + Ova vrijednost nije valjani postotak. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Ova vrijednost nije valjan pojam za pretraživanje. + Ova vrijednost nije valjani pojam za pretraživanje. This value is not a valid phone number. - Ova vrijednost nije valjan telefonski broj. + Ova vrijednost nije valjani telefonski broj. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Ova vrijednost nije valjana opcija. + Ova vrijednost nije valjana opcija. This value is not a valid range. - Ova vrijednost nije valjan raspon. + Ova vrijednost nije valjani raspon. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.it.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.it.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.it.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.it.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Questo valore non è una data di nascita valida. + Questo valore non è una data di nascita valida. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Questo valore non è un colore valido. + Questo valore non è un colore valido. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Questo valore non è un intervallo di date valido. + Questo valore non è un intervallo di date valido. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Questo valore non è un file valido. + Questo valore non è un file valido. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Questo valore non è un numero intero valido. + Questo valore non è un numero intero valido. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Questo valore non è un importo valido. + Questo valore non è un importo di denaro valido. This value is not a valid number. - Questo valore non è un numero valido. + Questo valore non è un numero valido. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Questo valore non è un valore percentuale valido. + Questo valore non è una percentuale valida. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Questo valore non è un termine di ricerca valido. + Questo valore non è un termine di ricerca valido. This value is not a valid phone number. - Questo valore non è un numero di telefono valido. + Questo valore non è un numero di telefono valido. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Questo valore non è un'opzione valida. + Questo valore non è un'opzione valida. This value is not a valid range. - Questo valore non è un intervallo valido. + Questo valore non è un intervallo valido. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.ja.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.ja.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.ja.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.ja.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - 有効な生年月日ではありません。 + 有効な生年月日ではありません。 The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - 有効な色ではありません。 + 有効な色ではありません。 This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - 有効な日付間隔ではありません。 + 有効な期間ではありません。 This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - 有効なファイルではありません。 + 有効なファイルではありません。 The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - 有効な整数ではありません。 + 有効な整数ではありません。 This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - 有効な金額ではありません。 + 有効な金額ではありません。 This value is not a valid number. - 有効な数値ではありません。 + 有効な数値ではありません。 The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - 有効なパーセント値ではありません。 + 有効なパーセント値ではありません。 The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - 有効な検索語ではありません。 + 有効な検索語ではありません。 This value is not a valid phone number. - 有効な電話番号ではありません。 + 有効な電話番号ではありません。 The checkbox has an invalid value. @@ -128,7 +128,7 @@ This value is not a valid range. - 有効な範囲ではありません。 + 有効な範囲ではありません。 This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.lb.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.lb.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.lb.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.lb.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Dëse Wäert ass kee gëltege Gebuertsdatum. + Dëse Wäert ass kee gëltege Gebuertsdatum. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Dëse Wäert ass keng gëlteg Faarf. + Dëse Wäert ass keng gëlteg Faarf. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Dëse Wäert ass kee gëltegen Datumsinterval. + Dëse Wäert ass kee gëltegen Datumsintervall. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Dëse Wäert ass kee gëltege Fichier. + Dëse Wäert ass kee gëltege Fichier. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Dëse Wäert ass keng ganz Zuel. + Dëse Wäert ass keng ganz Zuel. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Dëse Wäert ass keng gëlteg Geldzomm. + Dëse Wäert ass keng gëlteg Geldzomm. This value is not a valid number. - Dëse Wäert ass keng Zuel. + Dëse Wäert ass keng gëlteg Zuel. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Dëse Wäert ass kee gëltege Prozentwäert. + Dëse Wäert ass kee gëltege Prozentwäert. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Dëse Wäert ass kee gëltege Sichbegrëff. + Dëse Wäert ass kee gëltege Sichbegrëff. This value is not a valid phone number. - Dëse Wäert ass keng gëlteg Telefonsnummer. + Dëse Wäert ass keng gëlteg Telefonsnummer. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Dëse Wäert ass keng gëlteg Optioun. + Dëse Wäert ass keng gëlteg Optioun. This value is not a valid range. - Dëse Wäert ass keng gëlteg Spannbreet. + Dëse Wäert ass kee gëltege Beräich. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Déi ënnescht Grenz däerf net méi grouss si wéi déi iewescht Grenz. + Déi ënnescht Grenz däerf net méi grouss si wéi déi iewescht Grenz. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.nl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.nl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.nl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.nl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Dit is geen geldige geboortedatum. + Deze waarde is geen geldige geboortedatum. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Dit is geen geldige kleur. + Deze waarde is geen geldige kleur. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Dit is geen geldig tijdsinterval. + Deze waarde is geen geldig tijdsinterval. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Dit is geen geldig bestand. + Deze waarde is geen geldig bestand. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Dit is geen geldig geheel getal. + Deze waarde is geen geldig geheel getal. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Dit is geen geldig bedrag. + Deze waarde is geen geldig bedrag. This value is not a valid number. - Dit is geen geldig getal. + Deze waarde is geen geldig getal. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Dit is geen geldig percentage. + Deze waarde is geen geldig percentage. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Dit is geen geldige zoekterm. + Deze waarde is geen geldige zoekterm. This value is not a valid phone number. - Dit is geen geldig telefoonnummer. + Deze waarde is geen geldig telefoonnummer. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Dit is geen geldige optie. + Deze waarde is geen geldige optie. This value is not a valid range. - Dit is geen geldig bereik. + Deze waarde is geen geldig bereik. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.pl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.pl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.pl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.pl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Ta wartość nie jest prawidłową datą urodzenia. + Ta wartość nie jest prawidłową datą urodzenia. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Ta wartość nie jest prawidłowym kolorem. + Ta wartość nie jest prawidłowym kolorem. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Ta wartość nie jest prawidłowym przedziałem czasowym. + Ta wartość nie jest prawidłowym przedziałem czasowym. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Ta wartość nie jest prawidłowym plikiem. + Ta wartość nie jest prawidłowym plikiem. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Ta wartość nie jest prawidłową liczbą całkowitą. + Ta wartość nie jest prawidłową liczbą całkowitą. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Ta wartość nie jest prawidłową kwotą pieniężną. + Ta wartość nie jest prawidłową kwotą pieniężną. This value is not a valid number. - Ta wartość nie jest prawidłową liczbą. + Ta wartość nie jest prawidłową liczbą. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Ta wartość nie jest prawidłową wartością procentową. + Ta wartość nie jest prawidłową wartością procentową. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Ta wartość nie jest prawidłowym terminem wyszukiwania. + Ta wartość nie jest prawidłowym terminem wyszukiwania. This value is not a valid phone number. - Ta wartość nie jest prawidłowym numerem telefonu. + Ta wartość nie jest prawidłowym numerem telefonu. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Ta wartość nie jest prawidłową opcją. + Ta wartość nie jest prawidłową opcją. This value is not a valid range. - Ta wartość nie jest prawidłowym zakresem. + Ta wartość nie jest prawidłowym zakresem. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sk.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sk.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sk.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sk.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Táto hodnota nie je platný dátum narodenia. + Táto hodnota nie je platný dátum narodenia. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Táto hodnota nie je platná farba. + Táto hodnota nie je platná farba. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Táto hodnota nie je platný rozsah dát. + Táto hodnota nie je platný časový interval. This value is not a valid date and time. @@ -52,11 +52,11 @@ This value is not a valid date. - Táto hodnota nie je platný dátum. + Táto hodnota nie je platný dátum. This value is not a valid file. - Táto hodnota nie je platný súbor. + Táto hodnota nie je platný súbor. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Táto hodnota nie je platné celé číslo. + Táto hodnota nie je platné celé číslo. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Táto hodnota nie je platná čiastka. + Táto hodnota nie je platná suma. This value is not a valid number. - Táto hodnota nie je platné číslo. + Táto hodnota nie je platné číslo. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Táto hodnota nie je platná percentuálna hodnota. + Táto hodnota nie je platná percentuálna hodnota. The values do not match. @@ -96,7 +96,7 @@ This value is not a valid time. - Táto hodnota nie je platný čas. + Táto hodnota nie je platný čas. This value is not a valid timezone. @@ -108,11 +108,11 @@ This value is not a valid search term. - Táto hodnota nie je platný vyhľadávací výraz. + Táto hodnota nie je platný vyhľadávací výraz. This value is not a valid phone number. - Táto hodnota nie je platné telefónne číslo. + Táto hodnota nie je platné telefónne číslo. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Táto hodnota nie je platná možnosť. + Táto hodnota nie je platný výber. This value is not a valid range. - Táto hodnota nie je platný rozsah. + Táto hodnota nie je platný rozsah. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Dolná hranica nesmie byť väčšia ako horná hranica. + Dolná hranica nesmie byť väčšia ako horná hranica. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Ta vrednost ni veljaven rojstni datum. + Ta vrednost ni veljaven rojstni datum. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Ta vrednost ni veljavna barva. + Ta vrednost ni veljavna barva. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Ta vrednost ni veljaven datumski interval. + Ta vrednost ni veljaven datumski interval. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Ta vrednost ni veljavna datoteka. + Ta vrednost ni veljavna datoteka. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Ta vrednost ni veljavno celo število. + Ta vrednost ni veljavno celo število. This value is not a valid language. @@ -72,15 +72,15 @@ This value is not a valid locale. - Ta vrednost ni veljavna področna nastavitev. + Ta vrednost ni veljavna področna nastavitev. This value is not a valid money amount. - Ta vrednost ni veljaven denarni znesek. + Ta vrednost ni veljaven denarni znesek. This value is not a valid number. - Ta vrednost ni veljavna številka. + Ta vrednost ni veljavna številka. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Ta vrednost ni veljavna odstotna vrednost. + Ta vrednost ni veljaven odstotek. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Ta vrednost ni veljaven iskalni izraz. + Ta vrednost ni veljaven iskalni izraz. This value is not a valid phone number. - Ta vrednost ni veljavna telefonska številka. + Ta vrednost ni veljavna telefonska številka. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Ta vrednost ni veljavna možnost. + Ta vrednost ni veljavna možnost. This value is not a valid range. - Ta vrednost ni veljaven obseg. + Ta vrednost ni veljaven razpon. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Spodnja meja ne sme biti večja od zgornje meje. + Spodnja meja ne sme biti večja od zgornje meje. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Cyrl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Cyrl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Cyrl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Cyrl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Ова вредност није исправан датум рођења. + Ова вредност није исправан датум рођења. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Ова вредност није исправна боја. + Ова вредност није исправна боја. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Ова вредност није исправан датумски интервал. + Ова вредност није исправан датумски интервал. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Ова вредност није исправна датотека. + Ова вредност није исправна датотека. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Ова вредност није исправан цео број. + Ова вредност није исправан цео број. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Ова вредност није исправна количина новца. + Ова вредност није исправна количина новца. This value is not a valid number. - Ова вредност није исправан број. + Ова вредност није исправан број. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Ова вредност није исправан проценат. + Ова вредност није исправан проценат. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Ова вредност није исправан термин за претрагу. + Ова вредност није исправан термин за претрагу. This value is not a valid phone number. - Ова вредност није исправан број телефона. + Ова вредност није исправан број телефона. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Ова вредност није исправна опција. + Ова вредност није исправна опција. This value is not a valid range. - Ова вредност није исправан опсег. + Ова вредност није исправан опсег. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Latn.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Latn.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Latn.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.sr_Latn.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Ova vrednost nije ispravan datum rođenja. + Ova vrednost nije ispravan datum rođenja. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Ova vrednost nije ispravna boja. + Ova vrednost nije ispravna boja. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Ova vrednost nije ispravan datumski interval. + Ova vrednost nije ispravan datumski interval. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Ova vrednost nije ispravna datoteka. + Ova vrednost nije ispravna datoteka. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Ova vrednost nije ispravan ceo broj. + Ova vrednost nije ispravan ceo broj. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Ova vrednost nije ispravna količina novca. + Ova vrednost nije ispravna količina novca. This value is not a valid number. - Ova vrednost nije ispravan broj. + Ova vrednost nije ispravan broj. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Ova vrednost nije ispravan procenat. + Ova vrednost nije ispravan procenat. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Ova vrednost nije ispravan termin za pretragu. + Ova vrednost nije ispravan termin za pretragu. This value is not a valid phone number. - Ova vrednost nije ispravan broj telefona. + Ova vrednost nije ispravan broj telefona. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Ova vrednost nije ispravna opcija. + Ova vrednost nije ispravna opcija. This value is not a valid range. - Ova vrednost nije ispravan opseg. + Ova vrednost nije ispravan opseg. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.tr.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.tr.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.tr.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.tr.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Bu değer, geçerli bir doğum tarihi değil. + Bu değer geçerli bir doğum tarihi değildir. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Bu değer, geçerli bir renk değil. + Bu değer geçerli bir renk değildir. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Bu değer, geçerli bir tarih aralığı değil. + Bu değer geçerli bir tarih aralığı değildir. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Bu değer, geçerli bir dosya değil. + Bu değer geçerli bir dosya değildir. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Bu değer, geçerli bir tam sayı değil. + Bu değer geçerli bir tam sayı değildir. This value is not a valid language. @@ -72,15 +72,15 @@ This value is not a valid locale. - Bu değer, geçerli bir yerel ayar değil. + Bu değer geçerli bir yerel ayar değildir. This value is not a valid money amount. - Bu değer, geçerli bir para tutarı değil. + Bu değer geçerli bir para tutarı değildir. This value is not a valid number. - Bu değer, geçerli bir sayı değil. + Bu değer geçerli bir sayı değildir. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Bu değer, geçerli bir yüzde değeri değil. + Bu değer geçerli bir yüzde değildir. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Bu değer, geçerli bir arama terimi değil. + Bu değer geçerli bir arama terimi değildir. This value is not a valid phone number. - Bu değer, geçerli bir telefon numarası değil. + Bu değer geçerli bir telefon numarası değildir. The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - Bu değer, geçerli bir seçenek değil. + Bu değer geçerli bir seçenek değildir. This value is not a valid range. - Bu değer, geçerli bir aralık değil. + Bu değer geçerli bir aralık değildir. This value is not a valid week. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.uk.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.uk.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.uk.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.uk.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Це значення не є допустимою датою народження. + Це значення не є допустимою датою народження. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Це значення не є допустимим кольором. + Це значення не є допустимим кольором. This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - Це значення не є допустимим інтервалом дати. + Це значення не є допустимим інтервалом дат. This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - Це значення не є допустимим файлом. + Це значення не є допустимим файлом. The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - Це значення не є допустимим цілим числом. + Це значення не є допустимим цілим числом. This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - Це значення не є допустимою сумою грошей. + Це значення не є допустимою сумою грошей. This value is not a valid number. - Це значення не є допустимим числом. + Це значення не є допустимим числом. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Це значення не є допустимим процентним значенням. + Це значення не є допустимим процентним значенням. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Це значення не є допустимим пошуковим терміном. + Це значення не є допустимим пошуковим терміном. This value is not a valid phone number. - Це значення не є допустимим номером телефону. + Це значення не є допустимим номером телефону. The checkbox has an invalid value. @@ -120,15 +120,15 @@ This value is not a valid email address. - Це значення не є допустимою адресою електронної пошти. + Це значення не є допустимою адресою електронної пошти. This value is not a valid option. - Це значення не є допустимим варіантом. + Це значення не є допустимим варіантом. This value is not a valid range. - Це значення не є допустимим діапазоном. + Це значення не є допустимим діапазоном. This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - Нижня межа не повинна бути більшою за верхню межу. + Нижня межа не повинна бути більшою за верхню межу. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.vi.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.vi.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.vi.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.vi.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -4,7 +4,7 @@ This form should not contain extra fields. - Mẫu này không nên chứa trường mở rộng. + Biểu mẫu này không được chứa trường thừa. The uploaded file was too large. Please try to upload a smaller file. @@ -20,7 +20,7 @@ This value is not a valid birthdate. - Giá trị này không phải là ngày sinh hợp lệ. + Giá trị này không phải là ngày sinh hợp lệ. The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - Giá trị này không phải là màu hợp lệ. + Giá trị này không phải là màu hợp lệ. This value is not a valid country. @@ -44,11 +44,11 @@ This value is not a valid date interval. - Giá trị này không phải là khoảng thời gian hợp lệ. + Giá trị này không phải là khoảng thời gian hợp lệ. This value is not a valid date and time. - Giá trị này không phải là ngày và thời gian hợp lệ. + Giá trị này không phải là ngày giờ hợp lệ. This value is not a valid date. @@ -56,15 +56,15 @@ This value is not a valid file. - Giá trị này không phải là tệp hợp lệ. + Giá trị này không phải là tệp hợp lệ. The hidden field is invalid. - Phạm vi ẩn không hợp lệ. + Trường ẩn không hợp lệ. This value is not a valid integer. - Giá trị này không phải là số nguyên hợp lệ. + Giá trị này không phải là số nguyên hợp lệ. This value is not a valid language. @@ -72,15 +72,15 @@ This value is not a valid locale. - Giá trị này không phải là miền hợp lệ. + Giá trị này không phải là mã ngôn ngữ và khu vực hợp lệ. This value is not a valid money amount. - Giá trị này không phải là khoản tiền hợp lệ. + Giá trị này không phải là số tiền hợp lệ. This value is not a valid number. - Giá trị này không phải là số hợp lệ. + Giá trị này không phải là số hợp lệ. The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - Giá trị này không phải là phần trăm hợp lệ. + Giá trị này không phải là tỷ lệ phần trăm hợp lệ. The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - Giá trị này không phải là chuỗi tìm kiếm hợp lệ. + Giá trị này không phải là từ khóa tìm kiếm hợp lệ. This value is not a valid phone number. - Giá trị này không phải là số điện thoại hợp lệ. + Giá trị này không phải là số điện thoại hợp lệ. The checkbox has an invalid value. @@ -120,27 +120,27 @@ This value is not a valid email address. - Giá trị này không phải là địa chỉ email hợp lệ. + Giá trị này không phải là địa chỉ email hợp lệ. This value is not a valid option. - Giá trị này không phải là phương án hợp lệ. + Giá trị này không phải là lựa chọn hợp lệ. This value is not a valid range. - Giá trị này không phải là phạm vi hợp lệ. + Giá trị này không phải là khoảng giá trị hợp lệ. This value is not a valid week. - Giá trị này không phải là tuần hợp lệ. + Giá trị này không phải là tuần hợp lệ. This value is not a valid UUID. - Giá trị này không phải là UUID hợp lệ. + Giá trị này không phải là UUID hợp lệ. The lower bound must not be greater than the upper bound. - Giới hạn dưới không được lớn hơn giới hạn trên. + Giới hạn dưới không được lớn hơn giới hạn trên. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_CN.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_CN.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_CN.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_CN.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -4,23 +4,23 @@ This form should not contain extra fields. - 该表单中不可有额外字段. + 该表单中不应包含额外字段。 The uploaded file was too large. Please try to upload a smaller file. - 上传文件太大, 请重新尝试上传一个较小的文件. + 上传文件太大,请尝试上传较小的文件。 The CSRF token is invalid. Please try to resubmit the form. - CSRF 验证符无效, 请重新提交. + CSRF 验证无效,请重新提交。 This value is not a valid HTML5 color. - 该数值不是个有效的 HTML5 颜色。 + 该值不是一个有效的 HTML5 颜色。 This value is not a valid birthdate. - 该数值不是个有效的生日日期。 + 该值不是一个有效的出生日期。 The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - 该数值不是个有效的颜色。 + 该值不是一个有效的颜色值。 This value is not a valid country. @@ -44,11 +44,11 @@ This value is not a valid date interval. - 该数值不是个有效的日期间隔。 + 该值不是一个有效的日期间隔。 This value is not a valid date and time. - 该值不是一个有效的日期时间(datetime)。 + 该值不是一个有效的日期和时间(datetime)。 This value is not a valid date. @@ -56,7 +56,7 @@ This value is not a valid file. - 该数值不是个有效的文件。 + 该值不是一个有效的文件。 The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - 该数值不是个有效的整数。 + 该值不是一个有效的整数。 This value is not a valid language. @@ -76,11 +76,11 @@ This value is not a valid money amount. - 该数值不是个有效的金额。 + 该值不是一个有效的金额。 This value is not a valid number. - 该数值不是个有效的数字。 + 该值不是一个有效的数字。 The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - 该数值不是个有效的百分比。 + 该值不是一个有效的百分比。 The values do not match. @@ -104,19 +104,19 @@ This value is not a valid URL. - 该值不是一个有效的 URL 。 + 该值不是一个有效的 URL。 This value is not a valid search term. - 该数值不是个有效的搜索词。 + 该值不是一个有效的搜索词。 This value is not a valid phone number. - 该数值不是个有效的手机号码。 + 该值不是一个有效的手机号码。 The checkbox has an invalid value. - 无效的选框值。 + 复选框的值无效。 This value is not a valid email address. @@ -124,11 +124,11 @@ This value is not a valid option. - 该数值不是个有效的选项。 + 该值不是一个有效的选项。 This value is not a valid range. - 该数值不是个有效的范围。 + 该值不是一个有效的范围。 This value is not a valid week. @@ -136,11 +136,11 @@ This value is not a valid UUID. - 该值不是有效的 UUID 。 + 该值不是有效的 UUID。 The lower bound must not be greater than the upper bound. - 下限不得大于上限。 + 下限不得大于上限。 diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_TW.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_TW.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_TW.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Resources/translations/validators.zh_TW.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -20,7 +20,7 @@ This value is not a valid birthdate. - 這個值不是有效的出生日期。 + 這個值不是有效的出生日期。 The selected choice is invalid. @@ -32,7 +32,7 @@ This value is not a valid color. - 這個值不是有效的顏色。 + 這個值不是有效的顏色。 This value is not a valid country. @@ -44,7 +44,7 @@ This value is not a valid date interval. - 這個值不是有效的日期區間。 + 這個值不是有效的日期區間。 This value is not a valid date and time. @@ -56,7 +56,7 @@ This value is not a valid file. - 這個值不是有效的檔案。 + 這個值不是有效的檔案。 The hidden field is invalid. @@ -64,7 +64,7 @@ This value is not a valid integer. - 這個值不是有效的整數。 + 這個值不是有效的整數。 This value is not a valid language. @@ -72,15 +72,15 @@ This value is not a valid locale. - 這個值不是有效的語系。 + 這個值不是有效的語系。 This value is not a valid money amount. - 這個值不是有效的金額。 + 這個值不是有效的金額。 This value is not a valid number. - 這個值不是有效的數字。 + 這個值不是有效的數字。 The password is invalid. @@ -88,7 +88,7 @@ This value is not a valid percentage. - 這個值不是有效的百分比。 + 這個值不是有效的百分比。 The values do not match. @@ -108,11 +108,11 @@ This value is not a valid search term. - 這個值不是有效的搜尋關鍵字。 + 這個值不是有效的搜尋關鍵字。 This value is not a valid phone number. - 這個值不是有效的電話號碼。 + 這個值不是有效的電話號碼。 The checkbox has an invalid value. @@ -124,11 +124,11 @@ This value is not a valid option. - 這個值不是有效的選項。 + 這個值不是有效的選項。 This value is not a valid range. - 這個值不是有效的範圍。 + 這個值不是有效的範圍。 This value is not a valid week. @@ -140,7 +140,7 @@ The lower bound must not be greater than the upper bound. - 下限不得大於上限。 + 下限不得大於上限。 diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/AbstractRequestHandlerTestCase.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/AbstractRequestHandlerTestCase.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/AbstractRequestHandlerTestCase.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/AbstractRequestHandlerTestCase.php 2026-11-04 11:23:00.000000000 +0000 @@ -304,6 +304,23 @@ $this->assertSame('DATA', $form->getData()); } + /** + * @dataProvider methodExceptGetProvider + */ + public function testSubmitZeroStringToSimpleForm($method) + { + $form = $this->createForm('param1', $method); + + $this->setRequestData($method, [ + 'param1' => '0', + ]); + + $this->requestHandler->handleRequest($form, $this->request); + + $this->assertTrue($form->isSubmitted()); + $this->assertSame('0', $form->getData()); + } + public function testMergeZeroIndexedCollection() { $form = $this->createForm('root', 'POST', true); @@ -499,6 +516,7 @@ [null, '1K', false], [1024, '', false], [1024, '0', false], + [1024, '-1', false], ]; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToStringTransformerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToStringTransformerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToStringTransformerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/DataTransformer/DateTimeToStringTransformerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -59,6 +59,7 @@ // seconds since Unix ['U', '1265213106', '2010-02-03 16:05:06 UTC'], + ['U', '0', '1970-01-01 00:00:00 UTC'], ['Y-z', '2010-33', '2010-02-03 00:00:00 UTC'], ]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/Type/CollectionTypeTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/Type/CollectionTypeTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/Type/CollectionTypeTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Core/Type/CollectionTypeTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -99,6 +99,48 @@ $this->assertEquals(['foo@foo.com'], $form->getData()); } + public function testNotResizedIfSubmittedWithNullDataAndAllowDelete() + { + $form = $this->factory->create(static::TESTED_TYPE, ['foo@foo.com', 'bar@bar.com'], [ + 'entry_type' => TextTypeTest::TESTED_TYPE, + 'allow_delete' => true, + ]); + $form->submit(['foo@bar.com', null]); + + $this->assertTrue($form->has('1')); + $this->assertSame(['foo@bar.com', null], $form->getData()); + $this->assertSame([], $form->getExtraData()); + } + + public function testResizedDownIfSubmittedWithNullDataAndDeleteEmpty() + { + $form = $this->factory->create(static::TESTED_TYPE, ['foo@foo.com', 'bar@bar.com'], [ + 'entry_type' => TextTypeTest::TESTED_TYPE, + 'allow_delete' => true, + 'delete_empty' => true, + ]); + $form->submit(['foo@foo.com', null]); + + $this->assertFalse($form->has('1')); + $this->assertSame(['foo@foo.com'], $form->getData()); + $this->assertSame([], $form->getExtraData()); + } + + public function testDeleteEmptyPreservesUnsubmittedEntries() + { + $form = $this->factory->create(static::TESTED_TYPE, ['kept', '', 'original'], [ + 'delete_empty' => true, + ]); + + $form->submit([2 => 'updated'], false); + + $this->assertSame(['kept', '', 'updated'], $form->getData()); + $this->assertFalse($form->get('0')->isSubmitted()); + $this->assertFalse($form->get('1')->isSubmitted()); + $this->assertTrue($form->get('2')->isSubmitted()); + $this->assertTrue($form->isValid()); + } + public function testResizedDownIfSubmittedWithEmptyDataAndDeleteEmpty() { $form = $this->factory->create(static::TESTED_TYPE, null, [ @@ -319,6 +361,33 @@ $this->assertEquals(['foo@bar.com', 'bar@bar.com'], $form->getData()); } + public function testIgnoresSubmittedEntriesWithInvalidNameWhenAllowAdd() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'entry_type' => TextTypeTest::TESTED_TYPE, + 'allow_add' => true, + ]); + $form->submit(['a.b' => 'x', 0 => 'y']); + + $this->assertFalse($form->has('a.b')); + $this->assertTrue($form->has('0')); + $this->assertSame('y', $form[0]->getData()); + $this->assertSame(['a.b' => 'x'], $form->getExtraData()); + } + + public function testIgnoresSubmittedEntriesWithEmptyNameWhenAllowAdd() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'entry_type' => TextTypeTest::TESTED_TYPE, + 'allow_add' => true, + ]); + $form->submit(['' => 'x', 0 => 'y']); + + $this->assertFalse($form->has('')); + $this->assertSame([0 => 'y'], $form->getData()); + $this->assertSame(['' => 'x'], $form->getExtraData()); + } + public function testAllowAddButNoPrototype() { $form = $this->factory->create(static::TESTED_TYPE, null, [ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/HttpFoundation/HttpFoundationRequestHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/HttpFoundation/HttpFoundationRequestHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/HttpFoundation/HttpFoundationRequestHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/HttpFoundation/HttpFoundationRequestHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -14,6 +14,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Exception\UnexpectedTypeException; use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler; use Symfony\Component\Form\Extension\HttpFoundation\Type\FormTypeHttpFoundationExtension; use Symfony\Component\Form\FormBuilder; @@ -39,6 +40,33 @@ $this->requestHandler->handleRequest($this->createForm('name', 'GET'), new \stdClass()); } + public function testPatchSkipsDeleteEmptyForUnsubmittedCollectionEntries() + { + $checked = []; + $form = Forms::createFormFactoryBuilder() + ->addTypeExtension(new FormTypeHttpFoundationExtension($this->requestHandler)) + ->getFormFactory() + ->createNamed('collection', CollectionType::class, ['kept', '', 'original'], [ + 'method' => 'PATCH', + 'allow_add' => true, + 'delete_empty' => static function ($value) use (&$checked) { + $checked[] = $value; + + return null === $value; + }, + ]); + + $form->handleRequest(Request::create('/', 'PATCH', ['collection' => [2 => 'updated', 3 => '']])); + + $this->assertSame(['kept', '', 'updated'], $form->getData()); + $this->assertSame(['updated', null], $checked); + $this->assertFalse($form->get('0')->isSubmitted()); + $this->assertFalse($form->get('1')->isSubmitted()); + $this->assertTrue($form->get('2')->isSubmitted()); + $this->assertFalse($form->has('3')); + $this->assertTrue($form->isValid()); + } + protected function setRequestData($method, $data, $files = []) { $this->request = Request::create('http://localhost', $method, $data, [], $files); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorFunctionalTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorFunctionalTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorFunctionalTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Constraints/FormValidatorFunctionalTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -291,6 +291,65 @@ $this->assertSame('children[field2].data', $violations[1]->getPropertyPath()); } + public function testFieldConstraintsStayInTheirCollectionWithSharedPropertyPaths() + { + $this->assertFieldConstraintStaysInItsCollection(false, false); + $this->assertFieldConstraintStaysInItsCollection(true, false); + } + + public function testFieldConstraintsStayInTheirCollectionWithSharedPropertyPathsAndErrorBubbling() + { + $this->assertFieldConstraintStaysInItsCollection(false, true); + $this->assertFieldConstraintStaysInItsCollection(true, true); + } + + public function testTransformationErrorsStayOnTheirOriginatingFieldWithSharedPropertyPaths() + { + $form = $this->formFactory->create(FormType::class) + ->add('first', IntegerType::class, ['property_path' => '[value]']) + ->add('second', IntegerType::class, ['property_path' => '[value]']); + + $form->submit(['first' => 'invalid', 'second' => '175']); + + $this->assertFalse($form->isValid()); + $this->assertFalse($form->get('first')->isSynchronized()); + $this->assertTrue($form->get('second')->isSynchronized()); + $this->assertTrue($form->get('second')->isValid()); + $this->assertCount(1, $form->get('first')->getErrors()); + $this->assertCount(0, $form->get('second')->getErrors()); + $this->assertCount(1, $form->getErrors(true)); + $this->assertSame($form->get('first'), $form->get('first')->getErrors()->current()->getOrigin()); + } + + public function testDataConstraintsReachTheSubFormHavingTheFieldWithSharedPropertyPaths() + { + $this->assertDataConstraintReachesTheSubFormHavingTheField(false); + $this->assertDataConstraintReachesTheSubFormHavingTheField(true); + } + + public function testEntityConstraintsReachTheSubFormHavingTheFieldWithSharedPropertyPaths() + { + $profile = new Profile(); + $profile->person = new Person(); + $form = $this->formFactory->create(FormType::class, $profile, ['data_class' => Profile::class]); + + foreach (['pub' => 'name', 'home' => 'homeAddress', 'office' => 'officeAddress'] as $name => $field) { + $form->add($name, FormType::class, ['property_path' => 'person', 'data_class' => Person::class]); + $form->get($name)->add($field, TextType::class); + } + + $form->submit(['pub' => ['name' => 'Jane'], 'home' => ['homeAddress' => ''], 'office' => ['officeAddress' => '']]); + + $this->assertFalse($form->isValid()); + $this->assertCount(2, $form->getErrors(true)); + $this->assertCount(0, $form->getErrors()); + $this->assertCount(0, $form->get('pub')->getErrors(true)); + $this->assertCount(0, $form->get('home')->getErrors()); + $this->assertCount(0, $form->get('office')->getErrors()); + $this->assertSame('data.person.homeAddress', $form->get('home')->get('homeAddress')->getErrors()->current()->getCause()->getPropertyPath()); + $this->assertSame('data.person.officeAddress', $form->get('office')->get('officeAddress')->getErrors()->current()->getCause()->getPropertyPath()); + } + public function testCascadeValidationToChildFormsWithTwoValidConstraints() { $form = $this->formFactory->create(ReviewType::class); @@ -481,6 +540,60 @@ $this->assertFalse($form->get('field2')->isValid()); $this->assertCount(1, $form->get('field2')->getErrors()); } + + private function assertFieldConstraintStaysInItsCollection(bool $reverseOrder, bool $errorBubbling): void + { + $builder = $this->formFactory->createBuilder(FormType::class, ['employees' => ['']]); + $names = $reverseOrder ? ['private', 'public'] : ['public', 'private']; + + foreach ($names as $name) { + $builder->add($name, CollectionType::class, [ + 'property_path' => '[employees]', + 'entry_type' => TextType::class, + 'entry_options' => [ + 'constraints' => 'public' === $name ? [new NotBlank()] : [], + 'error_bubbling' => $errorBubbling, + ], + 'error_bubbling' => false, + ]); + } + + $form = $builder->getForm(); + $form->submit(['public' => [''], 'private' => ['175']]); + + $this->assertFalse($form->isValid()); + $this->assertFalse($form->get('public')->isValid()); + $this->assertTrue($form->get('private')->isValid()); + $this->assertCount(1, $form->get('public')->getErrors(true)); + $this->assertCount(0, $form->get('private')->getErrors(true)); + $error = $form->get('public')->getErrors(true)->current(); + $this->assertSame($form->get('public')->get('0'), $error->getOrigin()); + $this->assertSame('children[public].children[0].data', $error->getCause()->getPropertyPath()); + $view = $form->createView(); + $this->assertSame($error, ($errorBubbling ? $view['public'] : $view['public']['0'])->vars['errors']->current()); + } + + private function assertDataConstraintReachesTheSubFormHavingTheField(bool $reverseOrder): void + { + $builder = $this->formFactory->createBuilder(FormType::class, ['address' => ['street' => '', 'zip' => '1']], [ + 'constraints' => [new Collection(['address' => new Collection(['street' => new NotBlank()], allowExtraFields: true)])], + ]); + $fields = ['withStreet' => 'street', 'withZip' => 'zip']; + + foreach ($reverseOrder ? array_reverse($fields) : $fields as $name => $field) { + $builder->add($builder->create($name, FormType::class, ['property_path' => '[address]', 'error_bubbling' => false])->add($field, TextType::class)); + } + + $form = $builder->getForm(); + $form->submit(['withStreet' => ['street' => ''], 'withZip' => ['zip' => '1']]); + + $this->assertFalse($form->isValid()); + $this->assertCount(1, $form->getErrors(true)); + $this->assertCount(0, $form->getErrors()); + $this->assertCount(0, $form->get('withStreet')->getErrors()); + $this->assertCount(0, $form->get('withZip')->getErrors(true)); + $this->assertSame('data[address][street]', $form->get('withStreet')->get('street')->getErrors()->current()->getCause()->getPropertyPath()); + } } class Foo @@ -570,3 +683,26 @@ $resolver->setDefault('data_class', Customer::class); } } + +class Profile +{ + public $person; + + public static function loadValidatorMetadata(ClassMetadata $metadata): void + { + $metadata->addPropertyConstraint('person', new Valid()); + } +} + +class Person +{ + public $name; + public $homeAddress; + public $officeAddress; + + public static function loadValidatorMetadata(ClassMetadata $metadata): void + { + $metadata->addPropertyConstraint('homeAddress', new NotBlank()); + $metadata->addPropertyConstraint('officeAddress', new NotBlank()); + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Type/CollectionTypeValidatorExtensionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Type/CollectionTypeValidatorExtensionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Type/CollectionTypeValidatorExtensionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/Type/CollectionTypeValidatorExtensionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,10 @@ namespace Symfony\Component\Form\Tests\Extension\Validator\Type; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler; +use Symfony\Component\Form\Extension\Validator\Constraints\Form; +use Symfony\Component\HttpFoundation\Request; class CollectionTypeValidatorExtensionTest extends BaseValidatorExtensionTestCase { @@ -20,6 +24,30 @@ return $this->factory->create(CollectionType::class, null, $options); } + public function testInvalidSubmittedEntryNamesProduceExtraFieldsError() + { + $form = $this->factory->createNamedBuilder('tags', CollectionType::class, ['existing' => 'old'], [ + 'entry_type' => TextType::class, + 'allow_add' => true, + ]) + ->setRequestHandler(new HttpFoundationRequestHandler()) + ->getForm(); + parse_str('tags[existing]=updated&tags[a.b]=invalid&tags[01]=added', $data); + + $form->handleRequest(Request::create('/', 'POST', $data)); + + $this->assertFalse($form->isValid()); + $this->assertSame(['existing' => 'updated', '01' => 'added'], $form->getData()); + $this->assertSame(['a.b' => 'invalid'], $form->getExtraData()); + $this->assertCount(1, $form->getErrors()); + $error = $form->getErrors()->current(); + $this->assertSame(Form::NO_SUCH_FIELD_ERROR, $error->getCause()->getCode()); + $this->assertSame($form, $error->getOrigin()); + $view = $form->createView(); + $this->assertSame($error, $view->vars['errors']->current()); + $this->assertSame('tags[01]', $view['01']->vars['full_name']); + } + public function testInvalidMessage() { $form = $this->createForm(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Tests/Extension/Validator/ViolationMapper/ViolationMapperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -1581,6 +1581,229 @@ $this->assertEquals([$this->getFormError($violation3, $grandChild3)], iterator_to_array($grandChild3->getErrors()), $grandChild3->getName().' should have an error, but has none'); } + public function testBacktrackIfSeveralSubFormsWithSamePropertyPathAndTheFirstOneHasTheChild() + { + $parent = $this->getForm('parent'); + $child1 = $this->getForm('subform1', 'address'); + $child2 = $this->getForm('subform2', 'address'); + $grandChild = $this->getForm('street'); + + $parent->add($child2); + $parent->add($child1); + $child2->add($grandChild); + + $parent->submit([]); + + $violation = $this->getConstraintViolation('data.address[street]'); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors(), $parent->getName().' should not have an error, but has one'); + $this->assertCount(0, $child1->getErrors(), $child1->getName().' should not have an error, but has one'); + $this->assertCount(0, $child2->getErrors(), $child2->getName().' should not have an error, but has one'); + $this->assertEquals([$this->getFormError($violation, $grandChild)], iterator_to_array($grandChild->getErrors()), $grandChild->getName().' should have an error, but has none'); + } + + public function testBacktrackToTheSubFormHavingTheFieldWithSharedPropertyPath() + { + $parent = $this->getForm('parent'); + $children = []; + + foreach (['home' => 'homeAddress', 'office' => 'officeAddress', 'name' => 'name'] as $name => $field) { + $children[$name] = $this->getForm($name, 'person'); + $children[$name.'.'.$field] = $this->getForm($field, $field); + $children[$name]->add($children[$name.'.'.$field]); + $parent->add($children[$name]); + } + + $parent->submit([]); + + foreach (['home.homeAddress', 'office.officeAddress', 'name.name'] as $expectedTarget) { + $violation = $this->getConstraintViolation('data.person.'.explode('.', $expectedTarget)[1]); + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + + foreach ($children as $name => $child) { + $this->assertCount($name === $expectedTarget ? 1 : 0, $child->getErrors(), $name); + } + + $this->assertEquals([$this->getFormError($violation, $children[$expectedTarget])], iterator_to_array($children[$expectedTarget]->getErrors())); + $children[$expectedTarget]->clearErrors(); + } + } + + public function testBacktrackThroughNestedSubFormsWithSamePropertyPath() + { + $parent = $this->getForm('parent'); + $first = $this->getForm('first', 'person'); + $firstAddress = $this->getForm('address', 'address'); + $street = $this->getForm('street', '[street]'); + $second = $this->getForm('second', 'person'); + $secondAddress = $this->getForm('address', 'address'); + $firstAddress->add($street); + $first->add($firstAddress); + $second->add($secondAddress); + $parent->add($first); + $parent->add($second); + $parent->submit([]); + $violation = $this->getConstraintViolation('data.person.address[street]'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $first->getErrors()); + $this->assertCount(0, $firstAddress->getErrors()); + $this->assertCount(0, $second->getErrors()); + $this->assertCount(0, $secondAddress->getErrors()); + $this->assertEquals([$this->getFormError($violation, $street)], iterator_to_array($street->getErrors())); + } + + public function testBacktrackToTheSubFormHavingTheCamelizedField() + { + $parent = $this->getForm('parent'); + $first = $this->getForm('first', 'person_info'); + $homeAddress = $this->getForm('home_address', 'home_address'); + $second = $this->getForm('second', 'person_info'); + $first->add($homeAddress); + $parent->add($first); + $parent->add($second); + $parent->submit([]); + $violation = $this->getConstraintViolation('data.personInfo.homeAddress'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $first->getErrors()); + $this->assertCount(0, $second->getErrors()); + $this->assertEquals([$this->getFormError($violation, $homeAddress)], iterator_to_array($homeAddress->getErrors())); + } + + public function testBacktrackToTheSynchronizedSubFormWithSharedPropertyPath() + { + $parent = $this->getForm('parent'); + $first = $this->getForm('first', 'address'); + $firstStreet = $this->getForm('street', '[street]'); + $second = $this->getForm('second', 'address', null, [], false, false); + $secondStreet = $this->getForm('street', '[street]'); + $first->add($firstStreet); + $second->add($secondStreet); + $parent->add($first); + $parent->add($second); + $parent->submit(['first' => [], 'second' => 'invalid']); + $violation = $this->getConstraintViolation('data.address[street]'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $first->getErrors()); + $this->assertCount(0, $second->getErrors()); + $this->assertCount(0, $secondStreet->getErrors()); + $this->assertEquals([$this->getFormError($violation, $firstStreet)], iterator_to_array($firstStreet->getErrors())); + } + + public function testOriginatingChildTakesPrecedenceOverTheSubFormHavingTheField() + { + $parent = $this->getForm('parent'); + $first = $this->getForm('first', 'address'); + $second = $this->getForm('second', 'address'); + $city = $this->getForm('city', '[city]'); + $second->add($city); + $parent->add($first); + $parent->add($second); + $parent->submit([]); + $violation = $this->getConstraintViolation('children[first].data[city]'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $second->getErrors()); + $this->assertCount(0, $city->getErrors()); + $this->assertEquals([$this->getFormError($violation, $first)], iterator_to_array($first->getErrors())); + } + + public function testMapErrorToOriginatingChildWithSharedPropertyPath() + { + $this->assertErrorMappedWithSharedPropertyPath('children[first].data', 'address', 'first'); + $this->assertErrorMappedWithSharedPropertyPath('children[second].data', 'address', 'second'); + $this->assertErrorMappedWithSharedPropertyPath('children[first].children[street].data', 'address', 'first.street'); + $this->assertErrorMappedWithSharedPropertyPath('children[second].children[street].data', 'address', 'second.street'); + $this->assertErrorMappedWithSharedPropertyPath('children[first].data[street]', 'address', 'first.street'); + $this->assertErrorMappedWithSharedPropertyPath('children[first].children[street].data', 'person.address', 'first.street'); + $this->assertErrorMappedWithSharedPropertyPath('children[first].children[street].data', '[address]', 'first.street'); + $this->assertErrorMappedWithSharedPropertyPath('children[group].children[first].children[street].data', 'address', 'first.street', true); + } + + public function testMapDataErrorToLastChildWithSharedPropertyPath() + { + $this->assertErrorMappedWithSharedPropertyPath('data.address[street]', 'address', 'second.street'); + $this->assertErrorMappedWithSharedPropertyPath('data.address[city]', 'address', 'second'); + } + + public function testErrorMappingTakesPrecedenceOverOriginatingChild() + { + $this->assertErrorMappedWithSharedPropertyPath('children[first].children[street].data', 'address', 'second.street', false, ['address' => 'second']); + $this->assertErrorMappedWithSharedPropertyPath('children[first].children[street].data', 'address', 'second.street', false, ['address[street]' => 'second.street']); + } + + public function testMapErrorToNestedOriginatingChildWithSharedPropertyPath() + { + $parent = $this->getForm('parent'); + $address = $this->getForm('address', 'person.address'); + $first = $this->getForm('first', '[street]'); + $second = $this->getForm('second', '[street]'); + $parent->add($address); + $address->add($first); + $address->add($second); + $parent->submit([]); + $violation = $this->getConstraintViolation('children[address].children[first].data'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $address->getErrors()); + $this->assertCount(0, $second->getErrors()); + $this->assertEquals([$this->getFormError($violation, $first)], iterator_to_array($first->getErrors())); + } + + public function testMapErrorToOriginatingSubformWithDisjointChildren() + { + $parent = $this->getForm('parent'); + $home = $this->getForm('home', 'person'); + $office = $this->getForm('office', 'person'); + $homeAddress = $this->getForm('homeAddress', '[homeAddress]', null, [], false, true, ['error_bubbling' => true]); + $officeAddress = $this->getForm('officeAddress', '[officeAddress]'); + $home->add($homeAddress); + $office->add($officeAddress); + $parent->add($home); + $parent->add($office); + $parent->submit([]); + $violation = $this->getConstraintViolation('children[home].children[homeAddress].data'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $office->getErrors(true)); + $this->assertCount(0, $homeAddress->getErrors()); + $this->assertEquals([$this->getFormError($violation, $homeAddress)], iterator_to_array($home->getErrors())); + } + + public function testPreferLongerPropertyPathOverOriginatingChild() + { + $parent = $this->getForm('parent'); + $child = $this->getForm('address', 'address'); + $street = $this->getForm('street', 'address[street]'); + $parent->add($child); + $parent->add($street); + $parent->submit([]); + $violation = $this->getConstraintViolation('children[address].data[street]'); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + $this->assertCount(0, $child->getErrors()); + $this->assertEquals([$this->getFormError($violation, $street)], iterator_to_array($street->getErrors())); + } + public function testMapErrorToChildWhoseNameIsTheSnakeCasedProperty() { $violation = $this->getConstraintViolation('data.discountPrice'); @@ -1984,4 +2207,37 @@ $this->assertCount(3, $parent->getErrors()); $this->assertCount(0, $child->getErrors()); } + + private function assertErrorMappedWithSharedPropertyPath(string $violationPath, string $propertyPath, string $expectedTarget, bool $inheritData = false, array $errorMapping = []): void + { + $parent = $this->getForm('parent', null, null, $errorMapping); + $scope = $parent; + + if ($inheritData) { + $scope = $this->getForm('group', null, null, [], true); + $parent->add($scope); + } + + $children = []; + + foreach (['first', 'second'] as $name) { + $children[$name] = $this->getForm($name, $propertyPath); + $children[$name.'.street'] = $this->getForm('street', '[street]'); + $children[$name]->add($children[$name.'.street']); + $scope->add($children[$name]); + } + + $parent->submit([]); + $violation = $this->getConstraintViolation($violationPath); + + $this->mapper->mapViolation($violation, $parent); + + $this->assertCount(0, $parent->getErrors()); + + foreach ($children as $name => $child) { + $this->assertCount($name === $expectedTarget ? 1 : 0, $child->getErrors(), $name); + } + + $this->assertEquals([$this->getFormError($violation, $children[$expectedTarget])], iterator_to_array($children[$expectedTarget]->getErrors())); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Form/Util/ServerParams.php symfony-6.4.50+dfsg/src/Symfony/Component/Form/Util/ServerParams.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Form/Util/ServerParams.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Form/Util/ServerParams.php 2026-11-04 11:23:00.000000000 +0000 @@ -33,7 +33,7 @@ $contentLength = $this->getContentLength(); $maxContentLength = $this->getPostMaxSize(); - return $maxContentLength && $contentLength > $maxContentLength; + return $maxContentLength > 0 && $contentLength > $maxContentLength; } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -359,6 +359,32 @@ ); } + public function testAllowLinksRelativeDoesNotAllowSchemeRelativeHosts() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('a', ['href']) + ->allowLinkHosts(['trusted.com']) + ->allowRelativeLinks() + ; + + foreach (['https:untrusted.com', 'https:untrusted.com/login', 'http:untrusted.com', 'https:/untrusted.com', 'https:///untrusted.com', '///untrusted.com', '//untrusted.com', 'https://://untrusted.com/', 'https://:/untrusted.com/', '//:/untrusted.com/'] as $href) { + $this->assertSame( + 'Hello world', + $this->sanitize($config, \sprintf('Hello world', $href)) + ); + } + + $this->assertSame( + 'Hello world', + $this->sanitize($config, 'Hello world') + ); + + $this->assertSame( + 'Hello world', + $this->sanitize($config, 'Hello world') + ); + } + public function testAllowMediaSchemes() { $config = (new HtmlSanitizerConfig()) @@ -423,6 +449,243 @@ ); } + public function testAllowMediasRelativeDoesNotAllowSchemeRelativeHosts() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['src']) + ->allowMediaHosts(['trusted.com']) + ->allowRelativeMedias() + ; + + foreach (['https:untrusted.com/image.png', 'https:/untrusted.com/image.png', 'https:///untrusted.com/image.png', '///untrusted.com/image.png', '//untrusted.com/image.png', 'https://://untrusted.com/image.png', '//:/untrusted.com/image.png'] as $src) { + $this->assertSame( + '', + $this->sanitize($config, \sprintf('', $src)) + ); + } + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testAllowedHostsApplyToHostZero() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('a', ['href']) + ->allowElement('img', ['src']) + ->allowLinkHosts(['trusted.com']) + ->allowRelativeLinks() + ->allowMediaHosts(['trusted.com']) + ->allowRelativeMedias() + ; + + $this->assertSame('Hello world', $this->sanitize($config, 'Hello world')); + $this->assertSame('Hello world', $this->sanitize($config, 'Hello world')); + $this->assertSame('', $this->sanitize($config, '')); + } + + public function testAllowMediasHostsInSrcset() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['src', 'srcset']) + ->allowMediaHosts(['trusted.com']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testAllowMediasSchemesInSrcset() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('picture') + ->allowElement('source', ['srcset']) + ->allowElement('img', ['src']) + ->allowMediaSchemes(['https']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testAllowMediasHostsInImageSrcset() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('link', ['rel', 'as', 'href', 'imagesrcset']) + ->allowMediaHosts(['trusted.com']) + ->allowLinkHosts(['trusted.com']) + ; + + $this->assertSame( + '', + (new HtmlSanitizer($config))->sanitizeFor('head', '') + ); + + $this->assertSame( + '', + (new HtmlSanitizer($config))->sanitizeFor('head', '') + ); + } + + public function testAllowedSrcsetIsPreservedWithItsDescriptors() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['src', 'srcset']) + ->allowMediaHosts(['trusted.com']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testSrcsetCommasInsideUrlsAreNotCandidateSeparators() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ->allowMediaHosts(['trusted.com']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testSrcsetCommasInsideDescriptorParenthesesAreNotCandidateSeparators() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ->allowMediaHosts(['trusted.com']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + // An unterminated parenthesis swallows the rest of the value, as the specification requires + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + // Parentheses do not nest, so the first one closes the run and the next comma separates + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testSrcsetDescriptorWhitespaceIsNormalized() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ->allowMediaHosts(['trusted.com']) + ; + + $this->assertSame( + '', + $this->sanitize($config, "") + ); + } + + public function testForceHttpsUrlsInSrcset() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ->forceHttpsUrls() + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + + public function testAllowRelativeMediasInSrcset() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config->allowRelativeMedias(), '') + ); + } + + public function testAllowRelativeMediasInSrcsetDoesNotAllowSchemeRelativeHosts() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('img', ['srcset']) + ->allowMediaHosts(['trusted.com']) + ->allowRelativeMedias() + ; + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + + $this->assertSame( + '', + $this->sanitize($config, '') + ); + } + public function testActionAttributeIsSanitized() { $config = (new HtmlSanitizerConfig()) @@ -576,6 +839,82 @@ ); } + /** + * @dataProvider provideMetaRefreshContent + */ + public function testMetaRefreshContentGrammar(string $content, string $expected) + { + $config = (new HtmlSanitizerConfig()) + ->allowStaticElements() + ->allowLinkHosts(['trusted.example']) + ; + + $this->assertSame( + $expected, + (new HtmlSanitizer($config))->sanitizeFor('head', '') + ); + } + + public static function provideMetaRefreshContent(): iterable + { + yield 'canonical' => ['0;url=https://evil.example/', '']; + yield 'whitespace separator' => ['0 url=https://evil.example/', '']; + yield 'no url keyword' => ['0;https://evil.example/', '']; + yield 'whitespace around comma' => ['9 , https://evil.example/', '']; + yield 'comma separator' => ['0,https://evil.example/', '']; + yield 'fractional time' => ['.5;url=https://evil.example/', '']; + yield 'incomplete url keyword' => ['0;uhttps://evil.example/', '']; + yield 'quoted url' => ['0;url='https://evil.example/'', '']; + yield 'unparsable directive' => ['0:https://evil.example/', '']; + yield 'vertical tab in the url keyword' => ["0;url\x0B=https://trusted.example/x", '']; + yield 'vertical tab as separator' => ["0\x0Bhttps://trusted.example/x", '']; + + yield 'allowed host' => [ + '0;url=https://trusted.example/x', + '', + ]; + yield 'allowed host without url keyword' => [ + '9 , https://trusted.example/x', + '', + ]; + yield 'long trailing whitespace' => [ + '0;url=https://trusted.example/x'.str_repeat(' ', 2000).'y', + '', + ]; + yield 'reload' => ['5', '']; + yield 'reload with an empty url' => ['0;url=', '']; + yield 'reload with an empty quoted url' => [ + '0;url=''', + '', + ]; + yield 'not a refresh directive' => ['some text', '']; + } + + /** + * An incomplete "url=" keyword is part of the URL rather than a keyword, so the + * browser navigates to a relative URL that allowRelativeLinks() opts into. + */ + public function testMetaRefreshIncompleteUrlKeyword() + { + $config = (new HtmlSanitizerConfig()) + ->allowStaticElements() + ->allowLinkHosts(['trusted.example']) + ->allowRelativeLinks() + ; + + foreach (['0;u//evil.example/', '0;ur//evil.example/', '0;url//evil.example/'] as $content) { + $this->assertSame( + '', + (new HtmlSanitizer($config))->sanitizeFor('head', '') + ); + } + + $this->assertSame( + '', + (new HtmlSanitizer($config))->sanitizeFor('head', '') + ); + } + public function testCustomAttributeSanitizer() { $config = (new HtmlSanitizerConfig()) diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -189,7 +189,7 @@ 'allowedHosts' => null, 'forceHttps' => false, 'allowRelative' => true, - 'expected' => 'http:link.php', + 'expected' => null, ]; yield [ @@ -403,6 +403,114 @@ ]; yield [ + 'input' => '///evil.com', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => '////evil.com/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'https://://evil.com/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'https://:/evil.com/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => '//:/evil.com/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => '//://evil.com/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => '//trusted.com/link.php', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => '//trusted.com/link.php', + ]; + + yield [ + 'input' => '//evil.com/link.php', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'https://0/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => '//0/x', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'https://0/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['0'], + 'forceHttps' => false, + 'allowRelative' => false, + 'expected' => 'https://0/', + ]; + + yield [ + 'input' => 'https://0/', + 'allowedSchemes' => ['https'], + 'allowedHosts' => null, + 'forceHttps' => false, + 'allowRelative' => false, + 'expected' => 'https://0/', + ]; + + yield [ 'input' => 'https:\\evil.com', 'allowedSchemes' => ['https'], 'allowedHosts' => ['trusted.com'], @@ -411,6 +519,60 @@ 'expected' => null, ]; + yield [ + 'input' => 'https:evil.com', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'https:evil.com/login', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'HTTPS:evil.com/login', + 'allowedSchemes' => ['https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'http:evil.com', + 'allowedSchemes' => ['http', 'https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'ws:evil.com', + 'allowedSchemes' => ['ws', 'wss'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'ftp:evil.com', + 'allowedSchemes' => ['ftp'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + // Allow relative yield [ 'input' => '/link.php', @@ -484,6 +646,42 @@ 'allowRelative' => false, 'expected' => null, ]; + + yield [ + 'input' => 'link.php?a=b#c', + 'allowedSchemes' => ['http', 'https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => 'link.php?a=b#c', + ]; + + yield [ + 'input' => 'https://trusted.com/link.php', + 'allowedSchemes' => ['http', 'https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => 'https://trusted.com/link.php', + ]; + + yield [ + 'input' => 'https://untrusted.com/link.php', + 'allowedSchemes' => ['http', 'https'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => null, + ]; + + yield [ + 'input' => 'mailto:someone@trusted.com', + 'allowedSchemes' => ['https', 'mailto'], + 'allowedHosts' => ['trusted.com'], + 'forceHttps' => false, + 'allowRelative' => true, + 'expected' => 'mailto:someone@trusted.com', + ]; } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php 2026-11-04 11:23:00.000000000 +0000 @@ -52,7 +52,13 @@ return null; } - if (false !== strpbrk($input, '\\') || preg_match('~^(?:https?|ftp|wss?):(/[^/]|///)~i', $input)) { + // Schemes that browsers parse per WHATWG, where the authority is optional + // and the number of slashes is not significant, must carry exactly the two + // slashes RFC 3986 requires; otherwise the host seen by the browser is not + // the host this sanitizer checks against the allowed hosts. The same holds + // for scheme-relative URLs: "///host" is a path for RFC 3986 but an + // authority for browsers. + if (false !== strpbrk($input, '\\') || preg_match('~^(?:https?|ftp|wss?):(?!//[^/])|^///~i', $input)) { return null; } @@ -87,13 +93,20 @@ // 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']) { + // An empty authority is never a valid destination, and rebuilding the URL + // around it changes the number of slashes in front of the path, which + // browsers would then read as an authority + if ('' === $url['host']) { + return null; + } + + // No host and relative not allowed; "0" is a host, not a missing one + if (!$allowRelative && null === $url['host']) { return null; } // Forbidden host - if ($url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) { + if (null !== $url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) { return null; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,8 +18,13 @@ * Sanitizes the URL embedded in the content attribute of a * element, since the http-equiv value is not visible from a per-attribute sanitizer. * + * The value is parsed following the "shared declarative refresh steps" of the HTML + * specification: the time can be fractional, the separator can be a semicolon, a comma + * or ASCII whitespace, and the "url" keyword is optional. + * * The content attribute carries an unrelated value for other meta types (description, - * keywords, generator…), which is passed through unchanged. + * keywords, generator…), which is passed through unchanged as long as it cannot be read + * as a refresh directive by a browser. */ final class MetaRefreshAttributeSanitizer implements AttributeSanitizerInterface { @@ -35,12 +40,64 @@ public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string { - if (!preg_match('/^(\s*\d+\s*[;,]\s*url\s*=\s*)(["\']?)(.+?)\2(\s*)$/i', $value, $m)) { + // A leading time is the only marker of a refresh directive: without one, the value + // cannot trigger a navigation and belongs to another meta type. Whitespace means + // the ASCII whitespace of the specification, which \s does not match exactly. + if (!preg_match('/^([ \t\n\f\r]*(?:\d[\d.]*|\.[\d.]*))(.*)$/s', $value, $m)) { + return $value; + } + + [, $time, $rest] = $m; + + // Nothing follows the time: the directive reloads the current document. + if ('' === $rest) { + return $value; + } + + // The time is followed by a semicolon, a comma or ASCII whitespace, the latter + // optionally followed by a semicolon or a comma. Anything else is dropped instead + // of being trusted, so that a spelling this parser does not know about cannot slip + // an unsanitized URL through. + if (!preg_match('/^((?:[;,]|[ \t\n\f\r]+[;,]?)[ \t\n\f\r]*)(.*)$/s', $rest, $m)) { + return null; + } + + [, $separator, $url] = $m; + + $keyword = ''; + $quote = ''; + $trailing = ''; + + // An incomplete "url=" keyword is part of the URL and disables the quote handling. + $stripQuotes = 'u' !== strtolower($url[0] ?? ''); + + if (preg_match('/^(url[ \t\n\f\r]*=[ \t\n\f\r]*)(.*)$/is', $url, $m)) { + [, $keyword, $url] = $m; + $stripQuotes = true; + } + + if ($stripQuotes && ('"' === ($first = $url[0] ?? '') || "'" === $first)) { + $quote = $first; + $url = substr($url, 1); + + // Everything after the closing quote is ignored by browsers. + if (false !== $end = strpos($url, $quote)) { + $url = substr($url, 0, $end); + } + } else { + $trimmed = rtrim($url, " \t\n\f\r"); + $trailing = substr($url, \strlen($trimmed)); + $url = $trimmed; + } + + // An empty URL resolves to the document's own URL: the directive reloads the + // current document, exactly like a directive made of a time only. + if ('' === $url) { return $value; } $sanitized = UrlSanitizer::sanitize( - $m[3], + $url, $config->getAllowedLinkSchemes(), $config->getForceHttpsUrls(), $config->getAllowedLinkHosts(), @@ -51,6 +108,6 @@ return null; } - return $m[1].$m[2].$sanitized.$m[2].$m[4]; + return $time.$separator.$keyword.$quote.$sanitized.$quote.$trailing; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php 2026-11-04 11:23:00.000000000 +0000 @@ -27,27 +27,106 @@ public function getSupportedAttributes(): ?array { - return ['src', 'href', 'lowsrc', 'background', 'ping', 'action', 'formaction', 'poster', 'cite', 'data', 'codebase', 'archive', 'longdesc']; + return ['src', 'srcset', 'imagesrcset', 'href', 'lowsrc', 'background', 'ping', 'action', 'formaction', 'poster', 'cite', 'data', 'codebase', 'archive', 'longdesc']; } public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string { if (\in_array($element, ['a', 'area'], true) || \in_array($attribute, ['action', 'formaction', 'cite'], true)) { - return UrlSanitizer::sanitize( - $value, - $config->getAllowedLinkSchemes(), - $config->getForceHttpsUrls(), - $config->getAllowedLinkHosts(), - $config->getAllowRelativeLinks(), - ); + $allowedSchemes = $config->getAllowedLinkSchemes(); + $allowedHosts = $config->getAllowedLinkHosts(); + $allowRelative = $config->getAllowRelativeLinks(); + } else { + $allowedSchemes = $config->getAllowedMediaSchemes(); + $allowedHosts = $config->getAllowedMediaHosts(); + $allowRelative = $config->getAllowRelativeMedias(); } - return UrlSanitizer::sanitize( - $value, - $config->getAllowedMediaSchemes(), - $config->getForceHttpsUrls(), - $config->getAllowedMediaHosts(), - $config->getAllowRelativeMedias(), - ); + if (\in_array($attribute, ['srcset', 'imagesrcset'], true)) { + return self::sanitizeSrcset($value, $allowedSchemes, $config->getForceHttpsUrls(), $allowedHosts, $allowRelative); + } + + return UrlSanitizer::sanitize($value, $allowedSchemes, $config->getForceHttpsUrls(), $allowedHosts, $allowRelative); + } + + /** + * Sanitizes an image candidate strings list: a comma-separated list of candidates, + * each made of an URL optionally followed by width or pixel density descriptors. + * + * Candidates whose URL doesn't pass the policy are dropped; the attribute itself is + * dropped when no candidate is left, as an empty list would be invalid anyway. + * + * @see https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute + */ + private static function sanitizeSrcset(string $value, ?array $allowedSchemes, bool $forceHttps, ?array $allowedHosts, bool $allowRelative): ?string + { + $candidates = []; + $length = \strlen($value); + $position = 0; + + while ($position < $length) { + $position += strspn($value, " \t\n\f\r,", $position); + + if ($position >= $length) { + break; + } + + $urlLength = strcspn($value, " \t\n\f\r", $position); + $url = substr($value, $position, $urlLength); + $position += $urlLength; + + // An URL may contain commas, so only trailing ones separate candidates + if (str_ends_with($url, ',')) { + $url = rtrim($url, ','); + $descriptors = ''; + } else { + $descriptors = self::consumeSrcsetDescriptors($value, $position, $length); + } + + if (null === $url = UrlSanitizer::sanitize($url, $allowedSchemes, $forceHttps, $allowedHosts, $allowRelative)) { + continue; + } + + $candidates[] = '' === $descriptors ? $url : $url.' '.$descriptors; + } + + return $candidates ? implode(', ', $candidates) : null; + } + + /** + * Consumes the descriptors of the current candidate, up to the next separating comma, + * ignoring commas nested in parentheses as the HTML specification requires. + */ + private static function consumeSrcsetDescriptors(string $value, int &$position, int $length): string + { + $start = $position; + $inParens = false; + + while ($position < $length) { + $char = $value[$position]; + + if ($inParens) { + $inParens = ')' !== $char; + } elseif ('(' === $char) { + $inParens = true; + } elseif (',' === $char) { + break; + } + + ++$position; + } + + $consumed = strtr(substr($value, $start, $position - $start), "\t\n\f\r", ' '); + ++$position; + + $descriptors = []; + + foreach (explode(' ', $consumed) as $descriptor) { + if ('' !== $descriptor) { + $descriptors[] = $descriptor; + } + } + + return implode(' ', $descriptors); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/CurlHttpClient.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/CurlHttpClient.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/CurlHttpClient.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/CurlHttpClient.php 2026-11-04 11:23:00.000000000 +0000 @@ -336,9 +336,16 @@ $responses = [$responses]; } - if ($this->multi->handle instanceof \CurlMultiHandle) { + if ($this->multi->handle instanceof \CurlMultiHandle && !$this->multi->performing) { $active = 0; - while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) { + // curl forbids removing handles from its callbacks, which closing a response would do otherwise + $this->multi->performing = true; + + try { + while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) { + } + } finally { + $this->multi->performing = false; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php 2026-11-04 11:23:00.000000000 +0000 @@ -190,7 +190,7 @@ } if ($dnsCache->offsetExists($host)) { - return $dnsCache[$host]; + return $options['resolve'][$host] = $dnsCache[$host]; } if ((\FILTER_FLAG_IPV4 & $ipFlags) && $ip = gethostbynamel($host)) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/AmpResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/AmpResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/AmpResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/AmpResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -46,6 +46,7 @@ private static string $nextId = 'a'; private AmpClientState $multi; + private Canary $countCanary; private ?array $options; private \Closure $onProgress; @@ -131,6 +132,14 @@ $multi->lastTimeout = null; $multi->openHandles[$id] = $id; ++$multi->responseCount; + // Decorators can call __destruct() before PHP does, so the count is decremented when the response is freed + $this->countCanary = new Canary(static function () use ($multi) { + // Clear the DNS cache when all requests completed + if (0 >= --$multi->responseCount) { + $multi->responseCount = 0; + $multi->dnsCache = []; + } + }); $this->canary = new Canary(static function () use ($canceller, $multi, $id) { $canceller->cancel(); @@ -155,15 +164,7 @@ public function __destruct() { - try { - $this->doDestruct(); - } finally { - // Clear the DNS cache when all requests completed - if (0 >= --$this->multi->responseCount) { - $this->multi->responseCount = 0; - $this->multi->dnsCache = []; - } - } + $this->doDestruct(); } private static function schedule(self $response, array &$runningResponses): void diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/AsyncResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/AsyncResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/AsyncResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/AsyncResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -315,7 +315,14 @@ } if (null === $chunk) { - throw new \LogicException(\sprintf('"%s" is not compliant with HttpClientInterface: its "stream()" method didn\'t yield any chunks when it should have.', get_debug_type($client))); + foreach ($asyncMap as $response) { + if (null !== $asyncMap[$response]->client) { + throw new \LogicException(\sprintf('"%s" is not compliant with HttpClientInterface: its "stream()" method didn\'t yield any chunks when it should have.', get_debug_type($client))); + } + } + + // No chunk is yielded for responses canceled while streaming, e.g. from the "on_progress" callback + return; } if (null === $chunk->getError() && $chunk->isLast()) { $r->yieldedState = self::LAST_CHUNK_YIELDED; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php 2026-11-04 11:23:00.000000000 +0000 @@ -48,28 +48,36 @@ $this->checkStatusCode(); } - if (null === $this->content) { - $content = null; + $content = null; + $isLast = false; + $wasCanceled = $this->info['canceled']; + if (null === $this->content) { foreach (self::stream([$this]) as $chunk) { - if (!$chunk->isLast()) { + if (!$isLast = $chunk->isLast()) { $content .= $chunk->getContent(); } } - - if (null !== $content) { - return $content; - } - - if (null === $this->content) { - throw new TransportException('Cannot get the content of the response twice: buffering is disabled.'); - } } else { foreach (self::stream([$this]) as $chunk) { // Chunks are buffered in $this->content already + $isLast = $chunk->isLast(); } } + // The stream drops responses that are canceled while it runs, e.g. from the "on_progress" callback + if (!$isLast && !$wasCanceled && $this->info['canceled']) { + throw new TransportException($this->info['error']); + } + + if (null !== $content) { + return $content; + } + + if (null === $this->content) { + throw new TransportException('Cannot get the content of the response twice: buffering is disabled.'); + } + rewind($this->content); return stream_get_contents($this->content); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/CurlResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/CurlResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/CurlResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/CurlResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -174,14 +174,22 @@ $multi->openHandles[$id] = [$ch, $options]; curl_multi_add_handle($multi->handle, $ch); - $this->canary = new Canary(static function () use ($ch, $multi, $id) { + // Decorators call __destruct() on responses that can still be read (e.g. through toStream()), so the canary closes the debug buffer + $this->canary = new Canary(static function () use ($ch, $multi, $id, &$info, $debugBuffer) { unset($multi->pauseExpiries[$id], $multi->openHandles[$id], $multi->handlesActivity[$id]); curl_setopt($ch, \CURLOPT_PRIVATE, '_0'); $multi->handlesToRemove[$id] = $ch; if (!$multi->performing) { + // Removing an unfinished handle calls the progress function one last time self::removeHandles($multi); } + + if (\is_resource($debugBuffer)) { + rewind($debugBuffer); + $info['debug'] = stream_get_contents($debugBuffer); + self::closeDebugBuffer($ch, $debugBuffer); + } }); } @@ -198,17 +206,17 @@ $info['starttransfer_time'] = 0.0; } - if ($this->debugBuffer) { + if (\is_resource($this->debugBuffer)) { rewind($this->debugBuffer); $info['debug'] = stream_get_contents($this->debugBuffer); } else { - $info['debug'] = ''; + $info['debug'] ??= ''; } $waitFor = curl_getinfo($this->handle, \CURLINFO_PRIVATE); if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) { curl_setopt($this->handle, \CURLOPT_VERBOSE, false); - if ($this->debugBuffer) { + if (\is_resource($this->debugBuffer)) { rewind($this->debugBuffer); ftruncate($this->debugBuffer, 0); } @@ -247,19 +255,22 @@ } } - // Not reached when doDestruct() throws, as the exception can keep the response alive - if ($this->debugBuffer) { - if (isset($this->canary)) { - // Removing an unfinished handle calls the progress function one last time - $this->canary->cancel(); - } - - curl_setopt($this->handle, \CURLOPT_STDERR, null); - fclose($this->debugBuffer); - $this->debugBuffer = null; + // Unused pushed responses have no canary to close their debug buffer + if (!isset($this->canary) && \is_resource($this->debugBuffer)) { + self::closeDebugBuffer($this->handle, $this->debugBuffer); } } + /** + * @param resource $debugBuffer + */ + private static function closeDebugBuffer(\CurlHandle $ch, $debugBuffer): void + { + curl_setopt($ch, \CURLOPT_VERBOSE, false); + curl_setopt($ch, \CURLOPT_STDERR, null); + fclose($debugBuffer); + } + private static function schedule(self $response, array &$runningResponses): void { if (isset($runningResponses[$i = (int) $response->multi->handle])) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/NativeResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/NativeResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/NativeResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/NativeResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -41,6 +41,7 @@ private ?Dechunker $dechunker; private NativeClientState $multi; + private Canary $countCanary; /** * @internal @@ -64,6 +65,14 @@ $info['user_data'] = $options['user_data']; $info['max_duration'] = $options['max_duration']; ++$multi->responseCount; + // Decorators can call __destruct() before PHP does, so the count is decremented when the response is freed + $this->countCanary = new Canary(static function () use ($multi) { + // Clear the DNS cache when all requests completed + if (0 >= --$multi->responseCount) { + $multi->responseCount = 0; + $multi->dnsCache = []; + } + }); $this->initializer = static fn (self $response) => null === $response->remaining; @@ -97,15 +106,7 @@ public function __destruct() { - try { - $this->doDestruct(); - } finally { - // Clear the DNS cache when all requests completed - if (0 >= --$this->multi->responseCount) { - $this->multi->responseCount = 0; - $this->multi->dnsCache = []; - } - } + $this->doDestruct(); } private function open(): void diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/TraceableResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/TraceableResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Response/TraceableResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Response/TraceableResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -57,9 +57,7 @@ public function __destruct() { try { - if (method_exists($this->response, '__destruct')) { - $this->response->__destruct(); - } + unset($this->response); } finally { if ($this->event?->isStarted()) { $this->event->stop(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/ScopingHttpClient.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/ScopingHttpClient.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/ScopingHttpClient.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/ScopingHttpClient.php 2026-11-04 11:23:00.000000000 +0000 @@ -70,6 +70,7 @@ throw $e; } + $requestOptions = $options; $defaultOptions = $this->defaultOptionsByRegexp[$this->defaultRegexp]; $options = self::mergeDefaultOptions($options, $defaultOptions, true); if (\is_string($options['base_uri'] ?? null)) { @@ -77,6 +78,13 @@ $resolved = true; } $url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null, $defaultOptions['query'] ?? [])); + + // A network-path reference can point outside of the base URI's authority: the options of the default scope must not follow it + $resolvedUrl = self::parseUrl($url); + if ($resolvedUrl['scheme'].$resolvedUrl['authority'] !== $options['base_uri']['scheme'].$options['base_uri']['authority']) { + $options = $requestOptions; + $e = null; + } } if ($resolved) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,12 +12,14 @@ namespace Symfony\Component\HttpClient\Tests; use Symfony\Component\HttpClient\AsyncDecoratorTrait; +use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Component\HttpClient\DecoratorTrait; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\AsyncResponse; +use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Contracts\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; @@ -356,6 +358,30 @@ $response->getStatusCode(); } + /** + * @requires extension curl + */ + public function testCancelFromOnProgressEndsTheStream() + { + foreach ([$this->getHttpClient(__FUNCTION__, null, new CurlHttpClient()), new RetryableHttpClient(new CurlHttpClient(), null, 0)] as $client) { + $cancel = false; + $response = $client->request('GET', 'http://localhost:8057/max-duration', ['max_duration' => 10, 'on_progress' => static function (int $dlNow) use (&$response, &$cancel) { + if ($cancel && $dlNow) { + $cancel = false; + $response->cancel(); + } + }]); + + $this->assertSame(200, $response->getStatusCode()); + $cancel = true; + + foreach ($client->stream($response) as $chunk) { + } + + $this->assertSame('Response has been canceled.', $response->getInfo('error')); + } + } + public function testMaxDuration() { $client = $this->getHttpClient(__FUNCTION__, static function (ChunkInterface $chunk, AsyncContext $context) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Internal\CurlClientState; +use Symfony\Component\Process\Process; /** * @requires extension curl @@ -48,6 +49,14 @@ parent::testTimeoutIsNotAFatalError(); } + public function testCancelFromOnProgressInStreamWritesNothingToStdout() + { + $process = new Process([\PHP_BINARY, __DIR__.'/Fixtures/cancel_from_on_progress_in_stream.php']); + $process->mustRun(); + + self::assertSame('Response has been canceled.', $process->getOutput()); + } + public function testHandleIsReinitOnReset() { $httpClient = $this->getHttpClient(__FUNCTION__); @@ -398,6 +407,15 @@ } } + public function testCancelFromOnProgressWritesNothingToStderr() + { + $process = new Process([\PHP_BINARY, __DIR__.'/Fixtures/cancel_from_on_progress.php']); + $process->mustRun(); + + self::assertSame('Response has been canceled.', $process->getOutput()); + self::assertSame('', $process->getErrorOutput()); + } + /** * @group integration */ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,24 @@ +request('GET', 'http://localhost:8057/timeout-body', ['on_progress' => static function (int $dlNow) use (&$response) { + if ($dlNow) { + $response->cancel(); + } +}]); + +// getContent() throws only when the response is canceled before its headers are processed, which depends on timing +try { + $response->getContent(); +} catch (TransportException) { +} + +echo $response->getInfo('error'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress_in_stream.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress_in_stream.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress_in_stream.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Fixtures/cancel_from_on_progress_in_stream.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,33 @@ +request('GET', 'http://localhost:8057/max-duration', ['on_progress' => static function (int $dlNow) use (&$response, &$cancel) { + if ($cancel && $dlNow) { + $cancel = false; + $response->cancel(); + } +}]); + +$response->getStatusCode(); + +// Let body data arrive, so that the cancel happens in the curl_multi_exec() call of CurlHttpClient::stream() +usleep(100000); +$cancel = true; + +try { + foreach ($client->stream($response) as $chunk) { + } +} catch (TransportException) { +} + +echo $response->getInfo('error'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php 2026-11-04 11:23:00.000000000 +0000 @@ -20,6 +20,7 @@ use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient; use Symfony\Component\HttpClient\Response\StreamWrapper; use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\TraceableHttpClient; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; @@ -447,6 +448,54 @@ $this->assertSame($expected, $logger->logs); } + public function testGetContentThrowsWhenCanceledBeforeTheHeaders() + { + $client = $this->getHttpClient(__FUNCTION__); + $response = $client->request('GET', 'http://localhost:8057/timeout-body'); + $response->cancel(); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Response has been canceled.'); + + $response->getContent(); + } + + public function testGetContentThrowsWhenCanceledDuringTheBody() + { + $client = $this->getHttpClient(__FUNCTION__); + $cancel = false; + $response = $client->request('GET', 'http://localhost:8057/max-duration', ['max_duration' => 10, 'on_progress' => static function (int $dlNow) use (&$response, &$cancel) { + if ($cancel && $dlNow) { + $cancel = false; + $response->cancel(); + } + }]); + + $this->assertSame(200, $response->getStatusCode()); + $cancel = true; + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Response has been canceled.'); + + $response->getContent(); + } + + public function testCancelingAResponseWhileStreamingKeepsStreamingTheOthers() + { + $client = $this->getHttpClient(__FUNCTION__); + $kept = $client->request('GET', 'http://localhost:8057/chunked'); + $canceled = $client->request('GET', 'http://localhost:8057/timeout-body'); + + foreach ($client->stream([$kept, $canceled]) as $response => $chunk) { + if ($chunk->isFirst() && $response === $canceled) { + $response->cancel(); + } + } + + $this->assertSame('Symfony is awesome!', $kept->getContent()); + $this->assertTrue($canceled->getInfo('canceled')); + } + public function testDnsFailure() { $client = $this->getHttpClient(__FUNCTION__); @@ -888,6 +937,19 @@ $this->expectNotToPerformAssertions(); } + public function testDestructingATracedResponseKeepsTheResolveOptionOfPendingRequests() + { + $client = new TraceableHttpClient($this->getHttpClient(__FUNCTION__)); + + $response = $client->request('GET', 'http://localhost:8057/'); + $response->getContent(); + + $pending = $client->request('GET', 'http://symfony.invalid:8057/', ['resolve' => ['symfony.invalid' => '127.0.0.1']]); + unset($response); + + $this->assertSame(200, $pending->getStatusCode()); + } + public function testHeadRequestWithClosureBody() { $p = TestHttpServer::start(8067); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -495,6 +495,11 @@ return new MockHttpClient($responses); } + public function testGetContentThrowsWhenCanceledDuringTheBody() + { + $this->markTestSkipped('MockResponse calls "on_progress" for the whole body before returning the headers.'); + } + public function testHttp2PushVulcain() { $this->markTestSkipped('MockHttpClient doesn\'t support HTTP/2 PUSH.'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -144,6 +144,29 @@ } } + /** + * @group dns-sensitive + */ + public function testCachedHostIsResolvedToTheCheckedIp() + { + DnsMock::withMockedHosts(['example.com' => [['type' => 'A', 'ip' => '104.26.14.6']]]); + + $resolved = []; + $client = new NoPrivateNetworkHttpClient(new MockHttpClient(function (string $method, string $url, array $options) use (&$resolved) { + $resolved[] = $options['resolve'] ?? []; + + return new MockResponse('foo', ['primary_ip' => '104.26.14.6']); + })); + + $client->request('GET', 'http://example.com/')->getContent(); + + DnsMock::withMockedHosts(['example.com' => [['type' => 'A', 'ip' => '127.0.0.1']]]); + + $client->request('GET', 'http://example.com/')->getContent(); + + $this->assertSame([['example.com' => '104.26.14.6'], ['example.com' => '104.26.14.6']], $resolved); + } + public function testCustomOnProgressCallback() { $ipAddr = '104.26.14.6'; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,12 +13,14 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Psr18NetworkException; use Symfony\Component\HttpClient\Psr18RequestException; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\TraceableHttpClient; use Symfony\Contracts\HttpClient\Test\TestHttpServer; class Psr18ClientTest extends TestCase @@ -45,6 +47,23 @@ $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); } + + /** + * @requires extension curl + */ + public function testSendRequestWithTraceableClient() + { + $factory = new Psr17Factory(); + $client = new Psr18Client(new TraceableHttpClient(new CurlHttpClient()), $factory, $factory); + + $response = $client->sendRequest($factory->createRequest('GET', 'http://localhost:8057')); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + } public function testPostRequest() { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -124,4 +124,101 @@ $this->assertSame(200, $response->getStatusCode()); $this->assertSame('https://foo.example.com/app/santysisi', $response->getInfo('url')); } + + /** + * @dataProvider provideUrlsOnTheBaseUriOrigin + */ + public function testForBaseUriAppliesOptionsOnTheBaseUriOrigin(string $url, string $expectedUrl) + { + $client = ScopingHttpClient::forBaseUri(new MockHttpClient(null, null), 'https://safe.example/api/', [ + 'auth_bearer' => 'scope-secret', + 'headers' => ['X-Api-Key' => 'scope-secret'], + 'local_cert' => '/scope/client.pem', + ]); + + $response = $client->request('GET', $url); + $requestOptions = $response->getRequestOptions(); + + $this->assertSame($expectedUrl, $response->getInfo('url')); + $this->assertContains('Authorization: Bearer scope-secret', $requestOptions['headers']); + $this->assertContains('X-Api-Key: scope-secret', $requestOptions['headers']); + $this->assertSame('/scope/client.pem', $requestOptions['local_cert']); + } + + public static function provideUrlsOnTheBaseUriOrigin(): iterable + { + yield 'relative path' => ['users/1', 'https://safe.example/api/users/1']; + yield 'absolute path outside the base path' => ['/outside-scope', 'https://safe.example/outside-scope']; + yield 'dot segments outside the base path' => ['../escape', 'https://safe.example/escape']; + yield 'network-path reference' => ['//safe.example/api/users/1', 'https://safe.example/api/users/1']; + yield 'network-path reference with the default port' => ['//safe.example:443/api/users/1', 'https://safe.example/api/users/1']; + yield 'network-path reference with an uppercase host' => ['//SAFE.Example/api/users/1', 'https://safe.example/api/users/1']; + yield 'absolute URL' => ['https://safe.example/api/users/1', 'https://safe.example/api/users/1']; + } + + /** + * @dataProvider provideUrlsOutsideTheBaseUriOrigin + */ + public function testForBaseUriDoesNotApplyOptionsOutsideTheBaseUriOrigin(string $url, string $expectedUrl) + { + $client = ScopingHttpClient::forBaseUri(new MockHttpClient(null, null), 'https://safe.example/api/', [ + 'auth_bearer' => 'scope-secret', + 'headers' => ['X-Api-Key' => 'scope-secret'], + 'local_cert' => '/scope/client.pem', + ]); + + $response = $client->request('GET', $url); + $requestOptions = $response->getRequestOptions(); + + $this->assertSame($expectedUrl, $response->getInfo('url')); + $this->assertNotContains('Authorization: Bearer scope-secret', $requestOptions['headers']); + $this->assertNotContains('X-Api-Key: scope-secret', $requestOptions['headers']); + $this->assertNull($requestOptions['local_cert'] ?? null); + } + + public static function provideUrlsOutsideTheBaseUriOrigin(): iterable + { + yield 'absolute URL' => ['https://evil.example/steal', 'https://evil.example/steal']; + yield 'network-path reference' => ['//evil.example/steal', 'https://evil.example/steal']; + yield 'network-path reference with userinfo naming the base host' => ['//safe.example@evil.example/steal', 'https://safe.example@evil.example/steal']; + yield 'network-path reference with another port' => ['//safe.example:8443/api/users/1', 'https://safe.example:8443/api/users/1']; + yield 'network-path reference with the default port of another scheme' => ['//safe.example:80/api/users/1', 'https://safe.example:80/api/users/1']; + yield 'network-path reference with userinfo' => ['//user@safe.example/api/users/1', 'https://user@safe.example/api/users/1']; + } + + public function testNetworkPathReferenceGetsTheOptionsOfTheScopeItResolvesTo() + { + $client = new ScopingHttpClient(new MockHttpClient(), [ + 'https://safe\.example/' => [ + 'base_uri' => 'https://safe.example/api/', + 'auth_bearer' => 'safe-secret', + 'headers' => ['X-Api-Key' => 'safe-secret'], + ], + 'https://other\.example/' => [ + 'auth_bearer' => 'other-secret', + ], + ], 'https://safe\.example/'); + + $response = $client->request('GET', '//other.example/users/1'); + $requestOptions = $response->getRequestOptions(); + + $this->assertSame('https://other.example/users/1', $response->getInfo('url')); + $this->assertContains('Authorization: Bearer other-secret', $requestOptions['headers']); + $this->assertNotContains('X-Api-Key: safe-secret', $requestOptions['headers']); + } + + public function testNetworkPathReferenceGetsTheDefaultOptionsWhenTheDefaultRegexpMatches() + { + $client = new ScopingHttpClient(new MockHttpClient(), [ + 'https://(safe|mirror)\.example/' => [ + 'base_uri' => 'https://safe.example/api/', + 'auth_bearer' => 'safe-secret', + ], + ], 'https://(safe|mirror)\.example/'); + + $response = $client->request('GET', '//mirror.example/api/users/1'); + + $this->assertSame('https://mirror.example/api/users/1', $response->getInfo('url')); + $this->assertContains('Authorization: Bearer safe-secret', $response->getRequestOptions()['headers']); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpClient/Tests/TraceableHttpClientTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,10 +12,12 @@ namespace Symfony\Component\HttpClient\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Component\HttpClient\Exception\ClientException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Response\TraceableResponse; use Symfony\Component\HttpClient\TraceableHttpClient; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; @@ -114,6 +116,34 @@ $this->assertSame('Symfony is awesome!', implode('', $chunks)); } + /** + * @requires extension curl + */ + public function testToStreamOutlivesTheResponse() + { + $sut = new TraceableHttpClient(new CurlHttpClient()); + $stream = $sut->request('GET', 'http://localhost:8057/chunked')->toStream(); + + $this->assertSame('Symfony is awesome!', stream_get_contents($stream)); + } + + public function testDestructLeavesTheWrappedResponseAlone() + { + $response = new class extends MockResponse { + public int $destructCount = 0; + + public function __destruct() + { + ++$this->destructCount; + } + }; + $content = false; + $traceableResponse = new TraceableResponse(new MockHttpClient(), $response, $content); + unset($traceableResponse); + + $this->assertSame(0, $response->destructCount); + } + public function testToArrayChecksStatusCodeBeforeDecoding() { $this->expectException(ClientExceptionInterface::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php 2026-11-04 11:23:00.000000000 +0000 @@ -164,7 +164,7 @@ $filename = $this->file->getFilename(); } - if ('' === $filenameFallback && (!preg_match('/^[\x20-\x7e]*$/', $filename) || str_contains($filename, '%'))) { + if ('' === $filenameFallback && (!preg_match('/^[\x20-\x7e]*$/D', $filename) || str_contains($filename, '%'))) { $encoding = mb_detect_encoding($filename, null, true) ?: '8bit'; for ($i = 0, $filenameLength = mb_strlen($filename, $encoding); $i < $filenameLength; ++$i) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/HeaderUtils.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/HeaderUtils.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/HeaderUtils.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/HeaderUtils.php 2026-11-04 11:23:00.000000000 +0000 @@ -176,7 +176,7 @@ } // filenameFallback is not ASCII. - if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) { + if (!preg_match('/^[\x20-\x7e]*$/D', $filenameFallback)) { throw new \InvalidArgumentException('The filename fallback must only contain ASCII characters.'); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/IpUtils.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/IpUtils.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/IpUtils.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/IpUtils.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,6 +18,13 @@ */ class IpUtils { + /** + * Ranges that a server must not connect to: the private networks, plus the documentation, benchmarking + * and other special-purpose ranges listed below. This is a deny list for outgoing requests, so it also + * covers the IPv6 prefixes that embed an IPv4 address (IPv4-mapped, IPv4-compatible, 6to4, Teredo, + * NAT64). A remote client can present such an address as its own, so this list must not serve as an + * allow list of trusted proxies: use PRIVATE_RANGES for that. + */ public const PRIVATE_SUBNETS = [ '127.0.0.0/8', // RFC1700 (Loopback) '10.0.0.0/8', // RFC1918 @@ -45,6 +52,25 @@ '64:ff9b:1::/48', // NAT64 local-use prefix (RFC 8215) ]; + /** + * Ranges reserved for private networks, which a reverse proxy in front of the application can live in. + * + * Unlike PRIVATE_SUBNETS, this list holds no prefix that a remote client can present as its own + * address, so it is the one to pass to Request::setTrustedProxies(). + * An IPv4-mapped IPv6 address is matched as the IPv4 host it embeds. + */ + public const PRIVATE_RANGES = [ + '127.0.0.0/8', // RFC1700 (Loopback) + '10.0.0.0/8', // RFC1918 + '172.16.0.0/12', // RFC1918 + '192.168.0.0/16', // RFC1918 + '169.254.0.0/16', // RFC3927 + '100.64.0.0/10', // RFC6598 + '::1/128', // Loopback + 'fc00::/7', // Unique Local Address + 'fe80::/10', // Link Local Address + ]; + private static array $checkedIps = []; /** @@ -66,11 +92,21 @@ } $method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4'; + $embeddedIp4 = null; + + // An IPv4-mapped IPv6 address is the address of an IPv4 host, so it is also checked against IPv4 subnets as that host + if ('checkIp6' === $method && filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) && false !== ($packed = @inet_pton($requestIp)) && 16 === \strlen($packed) && "\0\0\0\0\0\0\0\0\0\0\xFF\xFF" === substr($packed, 0, 12)) { + $embeddedIp4 = inet_ntop(substr($packed, 12)); + } foreach ($ips as $ip) { if (self::$method($requestIp, $ip)) { return true; } + + if (null !== $embeddedIp4 && !str_contains($ip, ':') && self::checkIp4($embeddedIp4, $ip)) { + return true; + } } return false; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Request.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Request.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Request.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Request.php 2026-11-04 11:23:00.000000000 +0000 @@ -1879,8 +1879,13 @@ $requestUri = '/'.$requestUri; } - if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) { - // full $baseUrl matches + if (str_starts_with($requestUri, '//') || str_starts_with($requestUri, '/\\')) { + // the URI starts with a host that the client chose: URLs built on it would point there + return ''; + } + + if ($baseUrl && (null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) && \in_array($requestUri[\strlen($prefix)] ?? '', ['', '/', '?'], true)) { + // full $baseUrl matches, and is not only the start of a longer path segment return $prefix; } @@ -1894,17 +1899,19 @@ $truncatedRequestUri = substr($requestUri, 0, $pos); } - $basename = basename($baseUrl ?? ''); - if (empty($basename) || !strpos(rawurldecode($truncatedRequestUri), $basename)) { - // no match whatsoever; set it blank + // With mod_rewrite or ISAPI_Rewrite, the script filename can come after + // a prefix in the path: the base URL then ends with the script filename + $decodedRequestUri = rawurldecode($truncatedRequestUri); + if (!$baseUrl || !$pos = ($this->isIisRewrite() ? stripos($decodedRequestUri, $baseUrl) : strpos($decodedRequestUri, $baseUrl))) { + // the script filename isn't in the path, or only at its start, which is handled above return ''; } - // If using mod_rewrite or ISAPI_Rewrite strip the script filename - // out of baseUrl. $pos !== 0 makes sure it is not matching a value - // from PATH_INFO or QUERY_STRING - if (\strlen($requestUri) >= \strlen($baseUrl) && (false !== $pos = strpos($requestUri, $baseUrl)) && 0 !== $pos) { - $baseUrl = substr($requestUri, 0, $pos + \strlen($baseUrl)); + $baseUrl = $this->getUrlencodedPrefix($truncatedRequestUri, substr($decodedRequestUri, 0, $pos + \strlen($baseUrl))) ?? ''; + + if (!\in_array($requestUri[\strlen($baseUrl)] ?? '', ['', '/', '?'], true)) { + // the script filename is only the start of a longer path segment + return ''; } return rtrim($baseUrl, '/'.\DIRECTORY_SEPARATOR); @@ -2014,13 +2021,12 @@ return null; } + // Each byte of the prefix takes 1 or 3 characters in the string. + // A {n} quantifier would make PCRE fail to compile for a long prefix. $len = \strlen($prefix); + preg_match_all('#%[[:xdigit:]]{2}|.#s', substr($string, 0, 3 * $len), $match); - if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { - return $match[0]; - } - - return null; + return implode('', \array_slice($match[0], 0, $len)); } private static function createRequestFromFactory(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): static diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher/MethodRequestMatcher.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher/MethodRequestMatcher.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher/MethodRequestMatcher.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher/MethodRequestMatcher.php 2026-11-04 11:23:00.000000000 +0000 @@ -33,6 +33,11 @@ public function __construct(array|string $methods) { $this->methods = array_reduce(array_map('strtoupper', (array) $methods), static fn (array $methods, string $method) => array_merge($methods, preg_split('/\s*,\s*/', $method)), []); + + // HEAD requests are served by the GET handler, as the router does + if (\in_array('GET', $this->methods, true) && !\in_array('HEAD', $this->methods, true)) { + $this->methods[] = 'HEAD'; + } } public function matches(Request $request): bool diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/RequestMatcher.php 2026-11-04 11:23:00.000000000 +0000 @@ -145,6 +145,11 @@ public function matchMethod(string|array|null $method) { $this->methods = null !== $method ? array_map('strtoupper', (array) $method) : []; + + // HEAD requests are served by the GET handler, as the router does + if (\in_array('GET', $this->methods, true) && !\in_array('HEAD', $this->methods, true)) { + $this->methods[] = 'HEAD'; + } } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -23,7 +23,7 @@ */ class SessionHandlerFactory { - public static function createHandler(object|string $connection, array $options = []): AbstractSessionHandler + public static function createHandler(#[\SensitiveParameter] object|string $connection, #[\SensitiveParameter] array $options = []): AbstractSessionHandler { if ($query = \is_string($connection) ? parse_url($connection) : false) { parse_str($query['query'] ?? '', $query); @@ -95,6 +95,6 @@ return new PdoSessionHandler($connection, $options); } - throw new \InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection)); + throw new \InvalidArgumentException('No session handler supports the given DSN.'); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -68,6 +68,22 @@ $this->assertSame('attachment; filename=f__.html; filename*=utf-8\'\'f%C3%B6%C3%B6.html', $response->headers->get('Content-Disposition')); } + public function testSetContentDispositionGeneratesSafeFallbackFilenameForTrailingNewline() + { + $response = new BinaryFileResponse(__FILE__); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, "foo\n"); + + $this->assertSame('attachment; filename=foo_; filename*=utf-8\'\'foo%0A', $response->headers->get('Content-Disposition')); + } + + public function testSetContentDispositionRejectsTrailingNewlineInFallback() + { + $response = new BinaryFileResponse(__FILE__); + $this->expectException(\InvalidArgumentException::class); + + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'foo', "foo\n"); + } + public function testSetContentDispositionGeneratesSafeFallbackFilenameForWronglyEncodedFilename() { $response = new BinaryFileResponse(__FILE__); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/IpUtilsTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -86,6 +86,7 @@ [false, '}__test|O:21:"JDatabaseDriverMysqli":3:{s:2', '::1'], [false, '2a01:198:603:0:396e:4789:8e99:890f', 'unknown'], [false, '', '::1'], + [false, "::1\0", '::1/128'], [false, '127.0.0.1', '::1'], [false, '0.0.0.0/8', '::1'], [false, '::1', '127.0.0.1'], @@ -264,4 +265,52 @@ $this->assertLessThan($maxCheckedIps, \count($checkedIps)); } + + /** + * @dataProvider getIpv4MappedData + */ + public function testIpv4MappedAddressesAreCheckedAsTheIpv4HostTheyEmbed(bool $matches, string $remoteAddr, string $cidr) + { + $this->assertSame($matches, IpUtils::checkIp($remoteAddr, $cidr)); + } + + public static function getIpv4MappedData(): array + { + return [ + [true, '::ffff:10.0.0.5', '10.0.0.0/8'], + [true, '::ffff:a00:5', '10.0.0.0/8'], + [true, '::ffff:127.0.0.1', '127.0.0.1'], + [true, '::ffff:10.0.0.5', '::ffff:10.0.0.0/104'], + [false, '::ffff:8.8.8.8', '10.0.0.0/8'], + [false, '::10.0.0.5', '10.0.0.0/8'], + [false, '2002:a00:5::', '10.0.0.0/8'], + [false, '10.0.0.5', '::ffff:10.0.0.0/104'], + [false, "::ffff:10.0.0.5\0", '10.0.0.0/8'], + ]; + } + + /** + * @dataProvider getAddressesARemoteClientCanPresent + */ + public function testPrivateRangesDoNotCoverAddressesARemoteClientCanPresent(string $ip) + { + $this->assertFalse(IpUtils::checkIp($ip, IpUtils::PRIVATE_RANGES)); + $this->assertTrue(IpUtils::isPrivateIp($ip)); + } + + public static function getAddressesARemoteClientCanPresent(): array + { + return [ + 'IPv4-mapped' => ['::ffff:8.8.8.8'], + 'IPv4-compatible' => ['::8.8.8.8'], + '6to4' => ['2002:808:808::1'], + 'Teredo' => ['2001:0:4136:e378:8000:63bf:3fff:fdd2'], + 'NAT64' => ['64:ff9b::8.8.8.8'], + ]; + } + + public function testPrivateRangesAreASubsetOfPrivateSubnets() + { + $this->assertSame([], array_diff(IpUtils::PRIVATE_RANGES, IpUtils::PRIVATE_SUBNETS)); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcher/MethodRequestMatcherTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcher/MethodRequestMatcherTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcher/MethodRequestMatcherTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcher/MethodRequestMatcherTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -46,6 +46,16 @@ ['get', 'GET', true], ['get', ['GET', 'POST'], true], ['get', 'POST', false], + // HEAD requests are served by the GET handler + ['head', 'get', true], + ['head', ['get', 'post'], true], + ['head', 'post,get', true], + ['head', 'GET', true], + ['head', 'head', true], + ['head', ['post', 'head'], true], + ['head', 'post', false], + ['get', 'head', false], + ['post', 'get', false], ]; } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcherTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcherTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcherTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestMatcherTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -45,6 +45,15 @@ ['get', 'GET', true], ['get', ['GET', 'POST'], true], ['get', 'POST', false], + // HEAD requests are served by the GET handler + ['head', 'get', true], + ['head', ['get', 'post'], true], + ['head', 'GET', true], + ['head', 'head', true], + ['head', ['post', 'head'], true], + ['head', 'post', false], + ['get', 'head', false], + ['post', 'get', false], ]; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; @@ -1871,6 +1872,37 @@ $this->assertSame($expectedPathInfo, $request->getPathInfo(), 'pathInfo'); } + public function testGetBaseUrlDoesNotStartWithTwoSlashes() + { + foreach (['//evil.example/public/index.php/admin', '/\\evil.example/public/index.php/admin'] as $requestUri) { + $request = new Request([], [], [], [], [], [ + 'REQUEST_URI' => $requestUri, + 'SCRIPT_FILENAME' => '/var/www/public/index.php', + 'SCRIPT_NAME' => '/public/index.php', + 'PHP_SELF' => '/public/index.php', + ]); + + $this->assertSame('', $request->getBaseUrl()); + $this->assertSame('', $request->getBasePath()); + $this->assertSame($requestUri, $request->getPathInfo()); + } + } + + public function testGetBaseUrlDoesNotStartWithTwoSlashesWhenTheScriptNameIsTheRequestPath() + { + foreach (['//evil.example/index.php', '/\\evil.example/index.php'] as $requestUri) { + $request = new Request([], [], [], [], [], [ + 'REQUEST_URI' => $requestUri, + 'SCRIPT_FILENAME' => '/var/www/public/index.php', + 'SCRIPT_NAME' => $requestUri, + 'PHP_SELF' => $requestUri, + ]); + + $this->assertSame('', $request->getBaseUrl()); + $this->assertSame('', $request->getBasePath()); + } + } + public static function getBaseUrlData() { return [ @@ -1991,9 +2023,119 @@ 'SCRIPT_NAME' => '/api/index.php', 'PHP_SELF' => '/api/index.php', ], + '/api', + '/index.phpfoo', + ], + [ + '/index.phpfoo', + [ + 'SCRIPT_FILENAME' => '/var/www/index.php', + 'SCRIPT_NAME' => '/index.php', + 'PHP_SELF' => '/index.php', + ], + '', + '/index.phpfoo', + ], + [ + '/api/index.php?foo=bar', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], '/api/index.php', + '/', + ], + [ + '/sub/api/index.phpfoo', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '', + '/sub/api/index.phpfoo', + ], + [ + '/api%2Ffoo', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '/api%2F', + '/foo', + ], + [ + '/x/index.php/foo', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '', + '/x/index.php/foo', + ], + [ + '/sub/index.php?next=/api/index.php', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '', + '/sub/index.php', + ], + [ + '/sub/api/index.php?foo=bar', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '/sub/api/index.php', + '/', + ], + 'Percent-encoded script name after a prefix' => [ + '/sub/foo%20bar/app.php/home', + [ + 'SCRIPT_FILENAME' => '/var/www/foo bar/app.php', + 'SCRIPT_NAME' => '/foo bar/app.php', + 'PHP_SELF' => '/foo bar/app.php', + ], + '/sub/foo%20bar/app.php', + '/home', + ], + 'Script name after a long prefix' => [ + '/'.str_repeat('a', 2000).'/api/index.php/foo', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '/'.str_repeat('a', 2000).'/api/index.php', '/foo', ], + 'Long script name' => [ + '/'.str_repeat('a', 2000).'/index.php/foo', + [ + 'SCRIPT_FILENAME' => '/var/www/'.str_repeat('a', 2000).'/index.php', + 'SCRIPT_NAME' => '/'.str_repeat('a', 2000).'/index.php', + 'PHP_SELF' => '/'.str_repeat('a', 2000).'/index.php/foo', + ], + '/'.str_repeat('a', 2000).'/index.php', + '/foo', + ], + 'Script name taken from the request path' => [ + '/x/index.php/foo', + [ + 'SCRIPT_FILENAME' => '/var/www/app/index.php', + 'SCRIPT_NAME' => '/x/index.php/foo', + 'PHP_SELF' => '/x/index.php/foo', + ], + '', + '/x/index.php/foo', + ], ]; } @@ -2051,6 +2193,20 @@ '/RoutingTest', '/foo/bar', ]; + + yield 'Rewrite with case mismatch after a prefix' => [ + [ + 'IIS_WasUrlRewritten' => '1', + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/Sub/RoutingTest/Index.php/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + 'UNENCODED_URL' => '/Sub/RoutingTest/Index.php/foo/bar', + ], + '/Sub/RoutingTest/Index.php', + '/foo/bar', + ]; } /** @@ -2755,6 +2911,29 @@ $this->assertSame($result, Request::getTrustedProxies()); } + public function testPrivateRangesDoNotTrustAddressesEmbeddingARemoteIpv4() + { + Request::setTrustedProxies(IpUtils::PRIVATE_RANGES, Request::HEADER_X_FORWARDED_FOR); + + foreach (['::ffff:8.8.8.8', '::8.8.8.8', '2002:808:808::1', '2001:0:4136:e378:8000:63bf:3fff:fdd2', '64:ff9b::8.8.8.8'] as $remoteAddr) { + $request = Request::create('/'); + $request->server->set('REMOTE_ADDR', $remoteAddr); + $request->headers->set('X-Forwarded-For', '1.2.3.4'); + + $this->assertFalse($request->isFromTrustedProxy(), $remoteAddr); + $this->assertSame($remoteAddr, $request->getClientIp()); + } + + foreach (['10.0.0.5', '::ffff:10.0.0.5', '::ffff:127.0.0.1', 'fd00::1'] as $remoteAddr) { + $request = Request::create('/'); + $request->server->set('REMOTE_ADDR', $remoteAddr); + $request->headers->set('X-Forwarded-For', '1.2.3.4'); + + $this->assertTrue($request->isFromTrustedProxy(), $remoteAddr); + $this->assertSame('1.2.3.4', $request->getClientIp()); + } + } + public function testTrustedValuesCache() { $request = Request::create('http://example.com/'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -77,4 +77,36 @@ $this->assertInstanceOf(\Closure::class, $reflection->getProperty('ttl')->getValue($handler)); } + + public function testCredentialsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + foreach (['redis://s3cr3t@localhost:999999', 'redis://localhost?auth=s3cr3t&host=foo'] as $dsn) { + try { + SessionHandlerFactory::createHandler($dsn, []); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testCreateHandlerWithUnsupportedDsnDoesNotLeakCredentials() + { + try { + SessionHandlerFactory::createHandler('unsupported://user:s3cr3t@localhost'); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('No session handler supports the given DSN.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/UrlHelperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/UrlHelperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/Tests/UrlHelperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/Tests/UrlHelperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -53,6 +53,9 @@ ['http://localhost/foo/bar?baz=1#baz', '?baz=1#baz', '/foo/bar?foo=1'], ['http://localhost/foo/baz?baz=1#baz', 'baz?baz=1#baz', '/foo/bar?foo=1'], + ['http://localhost/foo/bar', '', '/foo/bar'], + ['http://localhost/foo/bar?foo=1', '', '/foo/bar?foo=1'], + ['mailto:hello@example.localhost', 'mailto:hello@example.localhost', '/'], ['tel:123', 'tel:123', '/'], ]; @@ -134,6 +137,70 @@ ]; } + public function testGenerateAbsoluteUrlWithRequestContextAndBaseUrl() + { + if (!class_exists(RequestContext::class)) { + $this->markTestSkipped('The Routing component is needed to run tests that depend on its request context.'); + } + + $requestContext = new RequestContext('/app.php', 'GET', 'example.test', 'https', 80, 443, '/foo'); + $requestContext->setQueryString('a=1'); + + $helper = new UrlHelper(new RequestStack(), $requestContext); + + $this->assertSame('https://example.test/app.php/foo?b=2', $helper->getAbsoluteUrl('?b=2')); + $this->assertSame('https://example.test/app.php/foo?a=1#hash', $helper->getAbsoluteUrl('#hash')); + $this->assertSame('https://example.test/app.php/foo?a=1', $helper->getAbsoluteUrl('')); + + $requestContext->setPathInfo('foo'); + $this->assertSame('https://example.test/app.php/foo?b=2', $helper->getAbsoluteUrl('?b=2')); + } + + public function testGenerateAbsoluteUrlWithQueryAndScriptFileName() + { + $request = Request::create('http://localhost/index.php/foo?x=1'); + $request->server->set('SCRIPT_NAME', '/index.php'); + $request->server->set('SCRIPT_FILENAME', '/var/www/index.php'); + + $stack = new RequestStack(); + $stack->push($request); + $helper = new UrlHelper($stack); + + $this->assertSame('http://localhost/index.php/foo?b=2', $helper->getAbsoluteUrl('?b=2')); + } + + public function testGenerateAbsoluteUrlWithTrustedPrefix() + { + Request::setTrustedProxies(['127.0.0.1'], Request::HEADER_X_FORWARDED_PREFIX); + + try { + $stack = new RequestStack(); + $stack->push(Request::create('http://localhost/foo?x=1', 'GET', [], [], [], ['HTTP_X_FORWARDED_PREFIX' => '/sub'])); + $helper = new UrlHelper($stack); + + $this->assertSame('http://localhost/sub/foo?x=1#h', $helper->getAbsoluteUrl('#h')); + $this->assertSame('http://localhost/sub/foo?b=2', $helper->getAbsoluteUrl('?b=2')); + $this->assertSame('http://localhost/sub/foo?x=1', $helper->getAbsoluteUrl('')); + } finally { + Request::setTrustedProxies([], -1); + } + } + + public function testGenerateAbsoluteUrlWhenRequestingTheScriptName() + { + $request = Request::create('http://localhost/index.php?x=1'); + $request->server->set('SCRIPT_NAME', '/index.php'); + $request->server->set('SCRIPT_FILENAME', '/var/www/index.php'); + + $stack = new RequestStack(); + $stack->push($request); + $helper = new UrlHelper($stack); + + $this->assertSame('http://localhost/index.php?x=1#h', $helper->getAbsoluteUrl('#h')); + $this->assertSame('http://localhost/index.php?b=2', $helper->getAbsoluteUrl('?b=2')); + $this->assertSame('http://localhost/index.php?x=1', $helper->getAbsoluteUrl('')); + } + public function testGenerateAbsoluteUrlWithScriptFileName() { $request = Request::create('http://localhost/app/web/app_dev.php'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/UrlHelper.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/UrlHelper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpFoundation/UrlHelper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpFoundation/UrlHelper.php 2026-11-04 11:23:00.000000000 +0000 @@ -37,10 +37,20 @@ return $this->getAbsoluteUrlFromContext($path); } - if ('#' === $path[0]) { - $path = $request->getRequestUri().$path; - } elseif ('?' === $path[0]) { - $path = $request->getPathInfo().$path; + if ('' === $path || '#' === $path[0] || '?' === $path[0]) { + [$requestPath, $queryString] = explode('?', $request->getRequestUri(), 2) + [1 => null]; + $pathInfo = $request->getPathInfo(); + + // the path info of "/index.php" is "/", but the URL has no slash after the base URL + if (!str_ends_with($requestPath, $pathInfo)) { + $pathInfo = substr($pathInfo, 1); + } + + if (null !== $queryString && !str_starts_with($path, '?')) { + $path = '?'.$queryString.$path; + } + + $path = $request->getBaseUrl().$pathInfo.$path; } if (!$path || '/' !== $path[0]) { @@ -92,7 +102,9 @@ $port = ':'.$context->getHttpsPort(); } - if ('#' === $path[0]) { + $isCurrentPathReference = '' === $path || str_starts_with($path, '#') || str_starts_with($path, '?'); + + if ('' === $path || '#' === $path[0]) { $queryString = $context->getQueryString(); $path = $context->getPathInfo().($queryString ? '?'.$queryString : '').$path; } elseif ('?' === $path[0]) { @@ -101,6 +113,8 @@ if ('/' !== $path[0]) { $path = rtrim($context->getBaseUrl(), '/').'/'.$path; + } elseif ($isCurrentPathReference) { + $path = $context->getBaseUrl().$path; } return $scheme.'://'.$host.$port.$path; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php 2026-11-04 11:23:00.000000000 +0000 @@ -205,7 +205,9 @@ { if (!is_numeric($time)) { $now = time(); - $time = strtotime($time, $now) - $now; + $then = strtotime($time, $now); + // ignore daylight saving time changes, so that "1 day" is always 86400 seconds + $time = $then - $now + idate('Z', $then) - idate('Z', $now); } return $time; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php 2026-11-04 11:23:00.000000000 +0000 @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; @@ -47,6 +48,7 @@ private RequestStack $requestStack; private ?string $projectDir; private bool $debug; + private array $parentContexts = []; /** * @param RequestContext|null $context The RequestContext (can be null when $matcher implements RequestContextAwareInterface) @@ -82,15 +84,44 @@ * After a sub-request is done, we need to reset the routing context to the parent request so that the URL generator * operates on the correct context again. */ - public function onKernelFinishRequest(): void + public function onKernelFinishRequest(?FinishRequestEvent $event = null): void { - $this->setCurrentRequest($this->requestStack->getParentRequest()); + if (null === $event) { + $this->setCurrentRequest($this->requestStack->getParentRequest()); + + return; + } + + $id = spl_object_id($event->getRequest()); + + if (!isset($this->parentContexts[$id])) { + return; + } + + // The context of the parent is restored instead of being computed again: + // while a sub-request runs, its trusted proxies also apply to the parent request + $parentContext = $this->parentContexts[$id]; + unset($this->parentContexts[$id]); + + $this->context + ->setBaseUrl($parentContext->getBaseUrl()) + ->setPathInfo($parentContext->getPathInfo()) + ->setMethod($parentContext->getMethod()) + ->setHost($parentContext->getHost()) + ->setScheme($parentContext->getScheme()) + ->setHttpPort($parentContext->getHttpPort()) + ->setHttpsPort($parentContext->getHttpsPort()) + ->setQueryString($parentContext->getQueryString()); } public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (null !== $this->requestStack->getParentRequest()) { + $this->parentContexts[spl_object_id($request)] = clone $this->context; + } + $this->setCurrentRequest($request); if ($request->attributes->has('_controller')) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Kernel.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Kernel.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Kernel.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Kernel.php 2026-11-04 11:23:00.000000000 +0000 @@ -77,11 +77,11 @@ */ private static array $freshCache = []; - public const VERSION = '6.4.49'; - public const VERSION_ID = 60449; + public const VERSION = '6.4.50'; + public const VERSION_ID = 60450; public const MAJOR_VERSION = 6; public const MINOR_VERSION = 4; - public const RELEASE_VERSION = 49; + public const RELEASE_VERSION = 50; public const EXTRA_VERSION = ''; public const END_OF_MAINTENANCE = '11/2026'; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Profiler/FileProfilerStorage.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Profiler/FileProfilerStorage.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Profiler/FileProfilerStorage.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Profiler/FileProfilerStorage.php 2026-11-04 11:23:00.000000000 +0000 @@ -82,7 +82,7 @@ $urlFilter = str_starts_with($url, '!') ? str_contains($csvUrl, substr($url, 1)) : !str_contains($csvUrl, $url); } - if ($ip && !str_contains($csvIp, $ip) || $urlFilter || $method && !str_contains($csvMethod, $method) || $statusCode && !str_contains($csvStatusCode, $statusCode)) { + if ($ip && !str_contains($csvIp, $ip) || $urlFilter || $method && !str_contains($csvMethod, $method) || null !== $statusCode && '' !== $statusCode && !str_contains($csvStatusCode, $statusCode)) { continue; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpKernel\Tests\EventListener; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\Cache; @@ -21,6 +22,9 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\CacheAttributeController; +/** + * @group time-sensitive + */ class CacheAttributeListenerTest extends TestCase { private CacheAttributeListener $listener; @@ -28,9 +32,11 @@ private Cache $cache; private Request $request; private ResponseEvent $event; + private string $defaultTimezone; protected function setUp(): void { + $this->defaultTimezone = date_default_timezone_get(); $this->listener = new CacheAttributeListener(); $this->response = new Response(); $this->cache = new Cache(); @@ -38,6 +44,11 @@ $this->event = $this->createEventMock($this->request, $this->response); } + protected function tearDown(): void + { + date_default_timezone_set($this->defaultTimezone); + } + public function testWontReassignResponseWhenResponseIsUnsuccessful() { $response = $this->event->getResponse(); @@ -169,6 +180,28 @@ $this->assertSame('86400', $this->response->headers->getCacheControlDirective('stale-if-error')); } + public function testCacheMaxAgeIgnoresDaylightSavingTime() + { + date_default_timezone_set('America/Los_Angeles'); + ClockMock::withClockMock((new \DateTimeImmutable('2025-11-01 12:00:00', new \DateTimeZone('America/Los_Angeles')))->getTimestamp()); + + $this->request->attributes->set('_cache', [new Cache( + maxage: '1 day', + smaxage: '1 day', + maxStale: '1 day', + staleWhileRevalidate: '1 day', + staleIfError: '1 day', + )]); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame(86400, $this->response->getMaxAge()); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('max-stale')); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('stale-while-revalidate')); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('stale-if-error')); + } + /** * @testWith ["test.getDate()"] * ["date"] diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -202,6 +202,29 @@ $this->assertStringContainsString('Welcome', $response->getContent()); } + public function testRoutingContextOfTheParentIsRestoredAfterNestedMainRequest() + { + $context = new RequestContext(); + $matcher = $this->createStub(UrlMatcherInterface::class); + $matcher->method('getContext')->willReturn($context); + + $requestStack = new RequestStack(); + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new RouterListener($matcher, $requestStack, $context)); + $kernel = new HttpKernel($dispatcher, new ControllerResolver(), $requestStack, new ArgumentResolver()); + + $request = Request::create('http://example.com/'); + $request->attributes->set('_controller', function () use ($kernel, $context) { + $nestedRequest = Request::create('https://example.org/'); + $nestedRequest->attributes->set('_controller', function () { return new Response(); }); + $kernel->handle($nestedRequest); + + return new Response($context->getScheme().'://'.$context->getHost()); + }); + + $this->assertSame('http://example.com', $kernel->handle($request)->getContent()); + } + public function testRequestWithBadHost() { $kernel = $this->createStub(HttpKernelInterface::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/Fragment/InlineFragmentRendererTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/Fragment/InlineFragmentRendererTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/Fragment/InlineFragmentRendererTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/Fragment/InlineFragmentRendererTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -16,14 +16,20 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface; use Symfony\Component\HttpKernel\Controller\ControllerReference; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\EventListener\RouterListener; use Symfony\Component\HttpKernel\Fragment\InlineFragmentRenderer; use Symfony\Component\HttpKernel\HttpKernel; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\Matcher\UrlMatcherInterface; +use Symfony\Component\Routing\RequestContext; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -272,6 +278,56 @@ $strategy->render('/', $request); } + public function testRoutingContextOfTheParentIgnoresUntrustedHeadersAfterSubRequest() + { + $context = new RequestContext(); + $matcher = $this->createStub(UrlMatcherInterface::class); + $matcher->method('getContext')->willReturn($context); + $matcher->method('match')->willReturn(['_controller' => function () { return new Response('fragment'); }]); + + $requestStack = new RequestStack(); + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new RouterListener($matcher, $requestStack, $context)); + $kernel = new HttpKernel($dispatcher, new ControllerResolver(), $requestStack, new ArgumentResolver()); + $renderer = new InlineFragmentRenderer($kernel); + + $request = Request::create('http://example.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_HOST' => 'evil.example', 'HTTP_X_FORWARDED_PROTO' => 'https']); + $request->attributes->set('_controller', function () use ($renderer, $request, $context) { + $renderer->render('/fragment', $request); + + return new Response($context->getScheme().'://'.$context->getHost()); + }); + + $this->assertSame('http://example.com', $kernel->handle($request)->getContent()); + } + + public function testRoutingContextOfTheParentIgnoresUntrustedHeadersAfterSubRequestAnsweredBeforeRouting() + { + $context = new RequestContext(); + $matcher = $this->createStub(UrlMatcherInterface::class); + $matcher->method('getContext')->willReturn($context); + + $requestStack = new RequestStack(); + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new RouterListener($matcher, $requestStack, $context)); + $dispatcher->addListener(KernelEvents::REQUEST, function (RequestEvent $event) { + if (!$event->isMainRequest()) { + $event->setResponse(new Response('fragment')); + } + }, 64); + $kernel = new HttpKernel($dispatcher, new ControllerResolver(), $requestStack, new ArgumentResolver()); + $renderer = new InlineFragmentRenderer($kernel); + + $request = Request::create('http://example.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_FORWARDED_HOST' => 'evil.example', 'HTTP_X_FORWARDED_PROTO' => 'https']); + $request->attributes->set('_controller', function () use ($renderer, $request, $context) { + $renderer->render('/fragment', $request); + + return new Response($context->getScheme().'://'.$context->getHost()); + }); + + $this->assertSame('http://example.com', $kernel->handle($request)->getContent()); + } + public function testFirstTrustedProxyIsSetAsRemote() { Request::setTrustedProxies(['1.1.1.1'], -1); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/Profiler/FileProfilerStorageTest.php symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/Profiler/FileProfilerStorageTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/HttpKernel/Tests/Profiler/FileProfilerStorageTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/HttpKernel/Tests/Profiler/FileProfilerStorageTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -234,6 +234,19 @@ $this->assertCount(1, $this->storage->find(null, null, 10, null, null, null, '404'), '->find() retrieve a record by Status code 404'); } + public function testRetrieveByStatusCodeZero() + { + $succeeded = new Profile('exitcode0'); + $succeeded->setStatusCode(0); + $this->storage->write($succeeded); + + $failed = new Profile('exitcode1'); + $failed->setStatusCode(1); + $this->storage->write($failed); + + $this->assertSame(['exitcode0'], array_column($this->storage->find(null, null, 10, null, null, null, '0'), 'token'), '->find() retrieve a record by exit code 0'); + } + public function testRetrieveByUrl() { $profile = new Profile('simple_quote'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php 2026-11-04 11:23:00.000000000 +0000 @@ -66,7 +66,7 @@ * @throws InvalidArgumentException When namespace contains invalid characters * @throws InvalidArgumentException When the initial ttl is not valid */ - public function __construct(Connection|string $connOrUrl, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300) + public function __construct(#[\SensitiveParameter] Connection|string $connOrUrl, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300) { $this->init($options, $gcProbability, $initialTtl); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/MongoDbStore.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/MongoDbStore.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/MongoDbStore.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/MongoDbStore.php 2026-11-04 11:23:00.000000000 +0000 @@ -96,7 +96,7 @@ * readPreference is primary for all read queries. * @see https://docs.mongodb.com/manual/applications/replication/ */ - public function __construct(Collection|Database|Client|Manager|string $mongo, array $options = [], float $initialTtl = 300.0) + public function __construct(#[\SensitiveParameter] Collection|Database|Client|Manager|string $mongo, #[\SensitiveParameter] array $options = [], float $initialTtl = 300.0) { if (isset($options['gcProbablity'])) { trigger_deprecation('symfony/lock', '6.3', 'The "gcProbablity" option (notice the typo in its name) is deprecated in "%s"; use the "gcProbability" option instead.', __CLASS__); @@ -154,14 +154,14 @@ * * @see https://php.net/mongodb.connection-handling */ - private function skimUri(string $uri): string + private function skimUri(#[\SensitiveParameter] string $uri): string { if (!str_starts_with($uri, 'mongodb://') && !str_starts_with($uri, 'mongodb+srv://')) { - throw new InvalidArgumentException(\sprintf('The given MongoDB Connection URI "%s" is invalid. Expecting "mongodb://" or "mongodb+srv://".', $uri)); + throw new InvalidArgumentException('The given MongoDB Connection URI is invalid. Expecting "mongodb://" or "mongodb+srv://".'); } if (false === $params = parse_url($uri)) { - throw new InvalidArgumentException(\sprintf('The given MongoDB Connection URI "%s" is invalid.', $uri)); + throw new InvalidArgumentException('The given MongoDB Connection URI is invalid.'); } $pathDb = rawurldecode(ltrim($params['path'] ?? '', '/')); if ('' !== $pathDb) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/StoreFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/StoreFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Store/StoreFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Store/StoreFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -108,6 +108,6 @@ return new InMemoryStore(); } - throw new InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection)); + throw new InvalidArgumentException('No lock store supports the given DSN.'); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -19,6 +19,7 @@ use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table; +use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Exception\LockConflictedException; use Symfony\Component\Lock\Key; use Symfony\Component\Lock\PersistingStoreInterface; @@ -364,6 +365,23 @@ $store->save($key); } + public function testDsnIsNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + new DoctrineDbalStore('pgsql://user:s3cr3t@localhost/db', [], 2.0); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + public function testConfigureSchemaDifferentDatabase() { $conn = $this->createStub(Connection::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/MongoDbStoreDsnTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/MongoDbStoreDsnTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/MongoDbStoreDsnTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/MongoDbStoreDsnTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -64,4 +64,41 @@ new MongoDbStore('mongodb://localhost/?collection=locks'); } + + public function testInvalidUriIsNotExposedInExceptionMessage() + { + foreach (['mongo://user:s3cr3t@localhost/app?collection=locks', 'mongodb://user:s3cr3t@localhost:999999/app?collection=locks'] as $uri) { + try { + new MongoDbStore($uri); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } + } + + public function testCredentialsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + foreach ([ + ['mongodb://user:s3cr3t@localhost/?collection=locks', []], + ['mongodb://user:s3cr3t@localhost:999999/app?collection=locks', []], + ['mongodb://localhost/?collection=locks', ['uriOptions' => ['password' => 's3cr3t']]], + ] as [$uri, $options]) { + try { + new MongoDbStore($uri, $options); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\AbstractAdapter; use Symfony\Component\Cache\Adapter\MemcachedAdapter; +use Symfony\Component\Lock\Exception\InvalidArgumentException; use Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore; use Symfony\Component\Lock\Store\DoctrineDbalStore; use Symfony\Component\Lock\Store\FlockStore; @@ -93,4 +94,15 @@ yield ['flock', FlockStore::class]; yield ['flock://'.sys_get_temp_dir(), FlockStore::class]; } + + public function testCreateStoreWithUnsupportedDsnDoesNotLeakCredentials() + { + try { + StoreFactory::createStore('unsupported://user:s3cr3t@localhost'); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('No lock store supports the given DSN.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/AmazonSqsTransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Bridge\AmazonSqs\Transport\AmazonSqsTransportFactory; +use Symfony\Component\Messenger\Exception\InvalidArgumentException; +use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; class AmazonSqsTransportFactoryTest extends TestCase { @@ -30,4 +32,23 @@ $this->assertFalse($factory->supports('redis://localhost', [])); $this->assertFalse($factory->supports('invalid-dsn', [])); } + + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $factory = new AmazonSqsTransportFactory(); + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $factory->createTransport('sqs://default/queue', ['secret_key' => 's3cr3t', 'foo' => 'bar'], $this->createStub(SerializerInterface::class)); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Tests/Transport/ConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,6 +18,7 @@ use AsyncAws\Sqs\Result\ReceiveMessageResult; use AsyncAws\Sqs\SqsClient; use AsyncAws\Sqs\ValueObject\Message; +use AsyncAws\Sqs\ValueObject\MessageAttributeValue; use Composer\InstalledVersions; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -300,6 +301,37 @@ $this->assertNull($connection->get()); } + public function testGetIgnoresMessengerHeadersThatAreNotAJsonObject() + { + $client = $this->createMock(SqsClient::class); + $client->expects($this->exactly(3))->method('receiveMessage')->willReturnOnConsecutiveCalls( + ResultMockFactory::create(ReceiveMessageResult::class, ['Messages' => [ + new Message(['MessageId' => 1, 'ReceiptHandle' => 'a', 'Body' => 'foo', 'MessageAttributes' => [ + 'X-Symfony-Messenger' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => '"bar"']), + 'baz' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => 'qux']), + ]]), + ]]), + ResultMockFactory::create(ReceiveMessageResult::class, ['Messages' => [ + new Message(['MessageId' => 2, 'ReceiptHandle' => 'b', 'Body' => 'foo', 'MessageAttributes' => [ + 'X-Symfony-Messenger' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => '123']), + 'baz' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => 'qux']), + ]]), + ]]), + ResultMockFactory::create(ReceiveMessageResult::class, ['Messages' => [ + new Message(['MessageId' => 3, 'ReceiptHandle' => 'c', 'Body' => 'foo', 'MessageAttributes' => [ + 'X-Symfony-Messenger' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => '{"type":"bar"}']), + 'baz' => new MessageAttributeValue(['DataType' => 'String', 'StringValue' => 'qux']), + ]]), + ]]), + ); + + $connection = new Connection(['auto_setup' => false], $client, 'https://sqs.us-east-2.amazonaws.com/123456789012/MyQueue'); + + $this->assertSame(['baz' => 'qux'], $connection->get()['headers']); + $this->assertSame(['baz' => 'qux'], $connection->get()['headers']); + $this->assertSame(['type' => 'bar', 'baz' => 'qux'], $connection->get()['headers']); + } + public function testUnexpectedSqsError() { $this->expectException(HttpException::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/AmazonSqsTransportFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -30,14 +30,14 @@ $this->logger = $logger; } - public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface + public function createTransport(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options, SerializerInterface $serializer): TransportInterface { unset($options['transport_name']); return new AmazonSqsTransport(Connection::fromDsn($dsn, $options, null, $this->logger), $serializer); } - public function supports(#[\SensitiveParameter] string $dsn, array $options): bool + public function supports(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options): bool { return str_starts_with($dsn, 'sqs://') || preg_match('#^https://sqs\.[\w\-]+\.amazonaws\.(?:com(?:\.cn)?|eu)/.+#', $dsn); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/AmazonSqs/Transport/Connection.php 2026-11-04 11:23:00.000000000 +0000 @@ -104,7 +104,7 @@ * * auto_setup: Whether the queue should be created automatically during send / get (Default: true) * * debug: Log all HTTP requests and responses as LoggerInterface::DEBUG (Default: false) */ - public static function fromDsn(#[\SensitiveParameter] string $dsn, array $options = [], ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): self + public static function fromDsn(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options = [], ?HttpClientInterface $client = null, ?LoggerInterface $logger = null): self { if (false === $params = parse_url($dsn)) { throw new InvalidArgumentException('The given Amazon SQS DSN is invalid.'); @@ -256,6 +256,7 @@ $attributes = $message->getMessageAttributes(); if (isset($attributes[self::MESSAGE_ATTRIBUTE_NAME]) && 'String' === $attributes[self::MESSAGE_ATTRIBUTE_NAME]->getDataType()) { $headers = json_decode($attributes[self::MESSAGE_ATTRIBUTE_NAME]->getStringValue(), true); + $headers = \is_array($headers) ? $headers : []; unset($attributes[self::MESSAGE_ATTRIBUTE_NAME]); } foreach ($attributes as $name => $attribute) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpSenderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpSenderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpSenderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpSenderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -181,6 +181,41 @@ $sender->send($envelope); } + public function testItDoesNotResendTheStampHeadersOfTheReceivedMessage() + { + $amqpEnvelope = $this->createStub(\AMQPEnvelope::class); + $amqpEnvelope->method('getHeaders')->willReturn([ + 'type' => 'OldType', + 'X-Message-Stamp-Foo' => '[{}]', + 'x-custom' => 'bar', + ]); + + $envelope = new Envelope(new DummyMessage('Oy'), [ + new AmqpReceivedStamp($amqpEnvelope, 'original_queue'), + new RedeliveryStamp(1), + ]); + $encoded = ['body' => '...', 'headers' => ['type' => DummyMessage::class]]; + + $serializer = $this->createStub(SerializerInterface::class); + $serializer->method('encode')->willReturn($encoded); + + $connection = $this->createMock(Connection::class); + $connection->method('getQueueNames')->willReturn(['original_queue']); + $connection->expects($this->once())->method('publish') + ->with($encoded['body'], $encoded['headers'], 0, $this->callback(function (AmqpStamp $stamp) use ($encoded) { + $headers = array_merge($stamp->getAttributes()['headers'], $encoded['headers']); + + $this->assertArrayNotHasKey('X-Message-Stamp-Foo', $headers); + $this->assertSame('bar', $headers['x-custom']); + $this->assertSame(DummyMessage::class, $headers['type']); + + return true; + })); + + $sender = new AmqpSender($connection, $serializer); + $sender->send($envelope); + } + public function testItKeepsTheReceivedRoutingKeyWhenSendingToTheTransportTheMessageWasReceivedFrom() { $amqpEnvelope = $this->createStub(\AMQPEnvelope::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpTransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpTransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpTransportFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpTransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -40,4 +40,23 @@ $this->assertEquals($expectedTransport, $factory->createTransport('amqp://localhost', ['host' => 'localhost'], $serializer)); } + + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $factory = new AmqpTransportFactory(); + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $factory->createTransport('amqp://localhost', ['password' => 's3cr3t', 'foo' => 'bar'], $this->createStub(SerializerInterface::class)); + $this->fail('An exception should have been thrown.'); + } catch (\LogicException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/ConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -243,6 +243,28 @@ Connection::fromDsn($dsn, $options); } + public function testCredentialsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $connection = Connection::fromDsn('amqp://user:s3cr3t@localhost', ['read_timeout' => -1]); + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $connection->channel(); + $this->fail('An exception should have been thrown.'); + } catch (\AMQPConnectionException $e) { + // ext-amqp does not mark the arguments of its own constructor as sensitive + $trace = array_filter($e->getTrace(), static fn ($frame) => \AMQPConnection::class !== ($frame['class'] ?? null)); + + $this->assertStringNotContainsString('s3cr3t', json_encode($trace, \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + public function testItUsesANormalConnectionByDefault() { $factory = new TestAmqpFactory( diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,7 +13,7 @@ class AmqpFactory { - public function createConnection(array $credentials): \AMQPConnection + public function createConnection(#[\SensitiveParameter] array $credentials): \AMQPConnection { return new \AMQPConnection($credentials); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpStamp.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpStamp.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpStamp.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpStamp.php 2026-11-04 11:23:00.000000000 +0000 @@ -51,7 +51,12 @@ $attr = $previousStamp->attributes ?? []; // the broker also routes a message to the routing keys of its CC header, and keeps that header when delivering it - $attr['headers'] ??= array_diff_key($amqpEnvelope->getHeaders(), ['CC' => true]); + // stamps are encoded again by the serializer: copying them would bring back the ones removed from the envelope + $attr['headers'] ??= array_filter( + array_diff_key($amqpEnvelope->getHeaders(), ['CC' => true]), + static fn (string $name): bool => !str_starts_with($name, 'X-Message-Stamp-'), + \ARRAY_FILTER_USE_KEY + ); $attr['content_type'] ??= $amqpEnvelope->getContentType(); $attr['content_encoding'] ??= $amqpEnvelope->getContentEncoding(); $attr['delivery_mode'] ??= $amqpEnvelope->getDeliveryMode(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/AmqpTransportFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -22,14 +22,14 @@ */ class AmqpTransportFactory implements TransportFactoryInterface { - public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface + public function createTransport(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options, SerializerInterface $serializer): TransportInterface { unset($options['transport_name']); return new AmqpTransport(Connection::fromDsn($dsn, $options), $serializer); } - public function supports(#[\SensitiveParameter] string $dsn, array $options): bool + public function supports(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options): bool { return str_starts_with($dsn, 'amqp://') || str_starts_with($dsn, 'amqps://'); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Amqp/Transport/Connection.php 2026-11-04 11:23:00.000000000 +0000 @@ -100,7 +100,7 @@ private int $inFlightMessages = 0; private int $publishSeqNo = 0; - public function __construct(array $connectionOptions, array $exchangeOptions, array $queuesOptions, ?AmqpFactory $amqpFactory = null) + public function __construct(#[\SensitiveParameter] array $connectionOptions, array $exchangeOptions, array $queuesOptions, ?AmqpFactory $amqpFactory = null) { if (!\extension_loaded('amqp')) { throw new LogicException(\sprintf('You cannot use the "%s" as the "amqp" extension is not installed.', __CLASS__)); @@ -173,7 +173,7 @@ * * verify: Enable or disable peer verification. If peer verification is enabled then the common name in the * server certificate must match the server name. Peer verification is enabled by default. */ - public static function fromDsn(#[\SensitiveParameter] string $dsn, array $options = [], ?AmqpFactory $amqpFactory = null): self + public static function fromDsn(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options = [], ?AmqpFactory $amqpFactory = null): self { if (false === $params = parse_url($dsn)) { // this is a valid URI that parse_url cannot handle when you want to pass all parameters as options @@ -246,7 +246,7 @@ return new self($amqpOptions, $exchangeOptions, $queuesOptions, $amqpFactory); } - private static function validateOptions(array $options): void + private static function validateOptions(#[\SensitiveParameter] array $options): void { if ($invalidOptions = array_diff(array_keys($options), self::AVAILABLE_OPTIONS)) { throw new LogicException(\sprintf('Invalid option(s) "%s" passed to the AMQP Messenger transport.', implode('", "', $invalidOptions))); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Tests/Transport/ConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Tests/Transport/ConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Tests/Transport/ConnectionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Tests/Transport/ConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -114,7 +114,7 @@ $id = 1234; $beanstalkdEnvelope = [ 'body' => 'foo', - 'headers' => 'bar', + 'headers' => ['type' => 'bar'], ]; $tube = 'baz'; @@ -149,6 +149,27 @@ $this->assertNull($connection->get()); } + public function testGetWhenTheJobIsNotAMessengerEnvelope() + { + $client = $this->createMock(PheanstalkInterface::class); + $client->method('watchOnly')->willReturn($client); + $client->expects($this->exactly(5))->method('reserveWithTimeout')->willReturnOnConsecutiveCalls( + new Job(1, 'foo'), + new Job(2, '"foo"'), + new Job(3, '{"foo":"bar"}'), + new Job(4, '{"body":["foo"],"headers":{"type":"bar"}}'), + new Job(5, '{"body":"foo","headers":"bar"}'), + ); + + $connection = new Connection([], $client); + + $this->assertSame(['id' => '1', 'body' => null, 'headers' => null], $connection->get()); + $this->assertSame(['id' => '2', 'body' => null, 'headers' => null], $connection->get()); + $this->assertSame(['id' => '3', 'body' => null, 'headers' => null], $connection->get()); + $this->assertSame(['id' => '4', 'body' => null, 'headers' => ['type' => 'bar']], $connection->get()); + $this->assertSame(['id' => '5', 'body' => 'foo', 'headers' => null], $connection->get()); + } + public function testGetWhenABeanstalkdExceptionOccurs() { $tube = 'baz'; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/Connection.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/Connection.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/Connection.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Beanstalkd/Transport/Connection.php 2026-11-04 11:23:00.000000000 +0000 @@ -147,8 +147,8 @@ return [ 'id' => (string) $job->getId(), - 'body' => $beanstalkdEnvelope['body'], - 'headers' => $beanstalkdEnvelope['headers'], + 'body' => \is_string($beanstalkdEnvelope['body'] ?? null) ? $beanstalkdEnvelope['body'] : null, + 'headers' => \is_array($beanstalkdEnvelope['headers'] ?? null) ? $beanstalkdEnvelope['headers'] : null, ]; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/ConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/ConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/ConnectionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/ConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -654,6 +654,36 @@ $this->assertEquals(['type' => DummyMessage::class], $doctrineEnvelope['headers']); } + public function testFindAcceptsIntegerIds() + { + foreach ([1, \PHP_INT_MIN, \PHP_INT_MAX, '00012', '2147483648', '9223372036854775807', '-1', '-9223372036854775808', '009223372036854775807', '-009223372036854775808'] as $id) { + $queryBuilder = $this->getQueryBuilderStub(); + $driverConnection = $this->getDBALConnection(true); + $driverConnection->method('createQueryBuilder')->willReturn($queryBuilder); + $queryBuilder->method('getSQL')->willReturn(''); + $driverConnection->expects($this->once())->method('executeQuery')->with('', [$id, 'default'])->willReturn($this->getResultMock([ + 'id' => $id, + 'body' => 'message', + 'headers' => '[]', + ])); + + $message = (new Connection([], $driverConnection))->find($id); + + $this->assertNotNull($message); + $this->assertSame($id, $message['id']); + } + } + + public function testFindReturnsNullForAnInvalidId() + { + foreach (['12abc', 'abc', '', '1.5', ' 1', null, '9223372036854775808', '-9223372036854775809', '10000000000000000000', '-10000000000000000000'] as $id) { + $driverConnection = $this->getDBALConnection(true); + $driverConnection->expects($this->never())->method('executeQuery'); + + $this->assertNull((new Connection([], $driverConnection))->find($id)); + } + } + public function testFindAll() { $queryBuilder = $this->getQueryBuilderStub(); @@ -794,6 +824,33 @@ $this->assertTrue($hasCoveringIndex, 'Expected covering index on [queue_name, available_at, delivered_at, id] not found'); } + public function testSetupRestoresSchemaAssetsFilterWhenSchemaUpdateFails() + { + $assetFilter = static fn () => true; + $configuration = new \Doctrine\DBAL\Configuration(); + $configuration->setSchemaAssetsFilter($assetFilter); + + $driverConnection = $this->createStub(DBALConnection::class); + $driverConnection->method('getConfiguration')->willReturn($configuration); + $exception = new \RuntimeException('Schema update failed.'); + if (method_exists(DBALConnection::class, 'createSchemaManager')) { + $driverConnection->method('createSchemaManager')->willThrowException($exception); + } else { + $driverConnection->method('getSchemaManager')->willThrowException($exception); + } + + $connection = new Connection([], $driverConnection); + + try { + $connection->setup(); + $this->fail('An exception should have been thrown.'); + } catch (\RuntimeException $e) { + $this->assertSame($exception, $e); + } + + $this->assertSame($assetFilter, $configuration->getSchemaAssetsFilter()); + } + public function testConfigureSchemaDifferentDbalConnection() { $driverConnection = $this->getDBALConnection(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php 2026-11-04 11:23:00.000000000 +0000 @@ -306,8 +306,13 @@ return $tableName === $this->configuration['table_name']; }); - $this->updateSchema(); - $configuration->setSchemaAssetsFilter($assetFilter); + + try { + $this->updateSchema(); + } finally { + $configuration->setSchemaAssetsFilter($assetFilter); + } + $this->autoSetup = false; } @@ -338,6 +343,15 @@ public function find(mixed $id): ?array { + // Some platforms cast malformed IDs to existing integer IDs, others fail on IDs that don't fit in a BIGINT. + if (\is_string($id) && preg_match('/^(-?)0*([0-9]{1,19})$/D', $id, $m)) { + if (19 === \strlen($m[2]) && 0 < strcmp($m[2], $m[1] ? '9223372036854775808' : '9223372036854775807')) { + return null; + } + } elseif (!\is_int($id)) { + return null; + } + $queryBuilder = $this->createQueryBuilder() ->where('m.id = ? and m.queue_name = ?'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/ConnectionTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -28,6 +28,20 @@ Connection::fromDsn('redis://'); } + public function testFromDsnWithNonArrayHostQueryParameterDoesNotLeakCredentials() + { + $redis = $this->createMock(\Redis::class); + $redis->expects($this->never())->method('connect'); + + try { + Connection::fromDsn('redis://user:s3cr3t@localhost/queue?host=foo', [], $redis); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Invalid Redis DSN: query parameter "host" must be an array.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } + public function testFromDsn() { $this->assertEquals( @@ -40,6 +54,94 @@ ); } + public function testInvalidDsnDoesNotExposeCredentialsInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + foreach (['redis://user:s3cr3t@localhost:999999/queue', 'redis://user:s3cr3t@localhost:999999/queue,redis://localhost:6379', 'redis://user:s3cr3t@/tmp/redis.sock,redis://localhost:6379', 'redis://localhost:6379?auth=s3cr3t,redis://localhost:999999'] as $dsn) { + try { + Connection::fromDsn($dsn, ['lazy' => true]); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testConnectionFailureDoesNotExposeCredentialsInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $redis = new class extends \Redis { + public function __construct() + { + } + + public function connect($host, ...$args): bool + { + return false; + } + + public function isConnected(): bool + { + return false; + } + }; + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + foreach (['redis://s3cr3t@localhost/queue', 'redis://user:s3cr3t@localhost/queue', 'redis://localhost/queue?auth=s3cr3t'] as $dsn) { + try { + Connection::fromDsn($dsn, [], $redis); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testClusterFailureDoesNotExposeCredentialsInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $redis = new class extends \RedisCluster { + public function __construct() + { + } + + public function setOption($option, $value): bool + { + throw new \RedisException('Connection failed.'); + } + }; + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + try { + Connection::fromDsn('redis://user:s3cr3t@localhost:6379,redis://localhost:6380', [], $redis); + $this->fail('An exception should have been thrown.'); + } catch (\RedisException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + public function testFromDsnOnUnixSocket() { $this->assertEquals( @@ -202,6 +304,16 @@ yield 'Colon and falsy password' => ['0', 'redis://:0@localhost/queue']; } + public function testAuthDecodesCredentialsOnce() + { + $redis = $this->createRedisMock(); + $redis->expects($this->once())->method('auth') + ->with(['user%2Fname', 'pass%40word']) + ->willReturn(true); + + Connection::fromDsn('redis://user%252Fname:pass%2540word@localhost/queue', [], $redis); + } + public function testAuthFromOptions() { $redis = $this->createRedisMock(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisReceiverTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisReceiverTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisReceiverTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisReceiverTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -68,28 +68,36 @@ $receiver->get(); } - public function testItIgnoresAMessageFieldThatDoesNotDecodeToAnArray() + public function testItRejectsAStreamEntryThatIsNotAMessengerEnvelope() { - $connection = $this->createStub(Connection::class); - $connection->method('get')->willReturn(['id' => '1', 'data' => ['message' => '12345']]); + $connection = $this->createMock(Connection::class); + $connection->method('get')->willReturnOnConsecutiveCalls( + ['id' => '1', 'data' => ['message' => '12345']], + ['id' => '2', 'data' => ['message' => '"a string"']], + ['id' => '3', 'data' => ['message' => 'false']], + ['id' => '4', 'data' => ['message' => 'foo']], + ['id' => '5', 'data' => ['foo' => 'bar']], + ['id' => '6', 'data' => ['message' => '{"body":["foo"],"headers":{"type":"bar"}}']], + ); + $rejected = []; + $connection->expects($this->exactly(6))->method('reject')->willReturnCallback(static function (string $id) use (&$rejected) { + $rejected[] = $id; + }); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('decode')->with(['body' => null, 'headers' => ['type' => 'bar']])->willThrowException(new MessageDecodingFailedException()); + + $receiver = new RedisReceiver($connection, $serializer); + + for ($i = 1; $i <= 6; ++$i) { + try { + $receiver->get(); + $this->fail(\sprintf('Entry "%d" should be rejected.', $i)); + } catch (MessageDecodingFailedException) { + } + } - $receiver = new RedisReceiver($connection, new PhpSerializer()); - - $this->assertSame([], $receiver->get()); - - $connection = $this->createStub(Connection::class); - $connection->method('get')->willReturn(['id' => '2', 'data' => ['message' => '"a string"']]); - - $receiver = new RedisReceiver($connection, new PhpSerializer()); - - $this->assertSame([], $receiver->get()); - - $connection = $this->createStub(Connection::class); - $connection->method('get')->willReturn(['id' => '3', 'data' => ['message' => 'false']]); - - $receiver = new RedisReceiver($connection, new PhpSerializer()); - - $this->assertSame([], $receiver->get()); + $this->assertSame(['1', '2', '3', '4', '5', '6'], $rejected); } public static function redisEnvelopeProvider(): \Generator diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisTransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisTransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisTransportFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Tests/Transport/RedisTransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -15,6 +15,7 @@ use Symfony\Component\Messenger\Bridge\Redis\Transport\Connection; use Symfony\Component\Messenger\Bridge\Redis\Transport\RedisTransport; use Symfony\Component\Messenger\Bridge\Redis\Transport\RedisTransportFactory; +use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; /** @@ -74,6 +75,25 @@ } } + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $factory = new RedisTransportFactory(); + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $factory->createTransport('redis://localhost:999999/queue', ['auth' => 's3cr3t', 'lazy' => true], new PhpSerializer()); + $this->fail('An exception should have been thrown.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + private function skipIfRedisUnavailable() { try { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php 2026-11-04 11:23:00.000000000 +0000 @@ -79,7 +79,7 @@ /** @var array */ private array $inflightIds = []; - public function __construct(array $options, \Redis|Relay|\RedisCluster|null $redis = null) + public function __construct(#[\SensitiveParameter] array $options, \Redis|Relay|\RedisCluster|null $redis = null) { if (version_compare(phpversion('redis'), '4.3.0', '<')) { throw new LogicException('The redis transport requires php-redis 4.3.0 or higher.'); @@ -195,7 +195,7 @@ /** * @param string|string[]|null $auth */ - private static function initializeRedis(\Redis|Relay $redis, string $host, int $port, string|array|null $auth, array $params): \Redis|Relay + private static function initializeRedis(\Redis|Relay $redis, string $host, int $port, #[\SensitiveParameter] string|array|null $auth, #[\SensitiveParameter] array $params): \Redis|Relay { if ($redis->isConnected()) { return $redis; @@ -215,7 +215,7 @@ } if (!$isConnected) { - throw new InvalidArgumentException('Redis connection failed: '.(preg_match('/^Redis::p?connect\(\): (.*)/', $error ?? $redis->getLastError() ?? '', $matches) ? \sprintf(' (%s)', $matches[1]) : '')); + throw new InvalidArgumentException('Redis connection failed: '.(preg_match('/^Redis::p?connect\(\): (.*)/', $error ?? '', $matches) ? \sprintf(' (%s)', $matches[1]) : '')); } $redis->setOption($redis instanceof \Redis ? \Redis::OPT_SERIALIZER : Relay::OPT_SERIALIZER, $params['serializer']); @@ -234,7 +234,7 @@ /** * @param string|string[]|null $auth */ - private static function initializeRedisCluster(?\RedisCluster $redis, array $hosts, string|array|null $auth, array $params): \RedisCluster + private static function initializeRedisCluster(?\RedisCluster $redis, array $hosts, #[\SensitiveParameter] string|array|null $auth, #[\SensitiveParameter] array $params): \RedisCluster { $redis ??= new \RedisCluster(null, $hosts, $params['timeout'], $params['read_timeout'], (bool) ($params['persistent'] ?? false), $auth, ...\defined('Redis::SCAN_PREFIX') ? [$params['ssl'] ?? null] : []); $redis->setOption(\Redis::OPT_SERIALIZER, $params['serializer']); @@ -242,7 +242,7 @@ return $redis; } - public static function fromDsn(#[\SensitiveParameter] string $dsn, array $options = [], \Redis|Relay|\RedisCluster|null $redis = null): self + public static function fromDsn(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options = [], \Redis|Relay|\RedisCluster|null $redis = null): self { if (!str_contains($dsn, ',')) { $params = self::parseDsn($dsn, $options); @@ -251,26 +251,27 @@ $params['host'] = 'tls://'.$params['host']; } } else { - $dsns = explode(',', $dsn); - $paramss = array_map(static function ($dsn) use (&$options) { - return self::parseDsn($dsn, $options); - }, $dsns); + $allParams = []; + foreach (explode(',', $dsn) as $partialDsn) { + $allParams[] = self::parseDsn($partialDsn, $options); + } // Merge all the URLs, the last one overrides the previous ones - $params = array_merge(...$paramss); + $params = array_merge(...$allParams); $tls = 'rediss' === $params['scheme']; // Regroup all the hosts in an array interpretable by RedisCluster - $params['host'] = array_map(static function ($params) use ($tls) { - if (!isset($params['host'])) { + $params['host'] = []; + foreach ($allParams as $hostParams) { + if (!isset($hostParams['host'])) { throw new InvalidArgumentException('Missing host in DSN, it must be defined when using Redis Cluster.'); } if ($tls) { - $params['host'] = 'tls://'.$params['host']; + $hostParams['host'] = 'tls://'.$hostParams['host']; } - return $params['host'].':'.($params['port'] ?? 6379); - }, $paramss, $dsns); + $params['host'][] = $hostParams['host'].':'.($hostParams['port'] ?? 6379); + } } if ($invalidOptions = array_diff(array_keys($options), array_keys(self::DEFAULT_OPTIONS), ['host', 'port'])) { @@ -297,7 +298,7 @@ $tcpScheme = $tls ? 'tls' : 'tcp'; if (!\is_array($hosts = $query['host'])) { - throw new InvalidArgumentException(\sprintf('Invalid Redis DSN: "%s".', $dsn)); + throw new InvalidArgumentException('Invalid Redis DSN: query parameter "host" must be an array.'); } foreach ($hosts as $host => $parameters) { if (\is_string($parameters)) { @@ -331,7 +332,7 @@ return new self($options, $redis); } - private static function parseDsn(string $dsn, array &$options): array + private static function parseDsn(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array &$options): array { $url = $dsn; $scheme = str_starts_with($dsn, 'rediss:') ? 'rediss' : 'redis'; @@ -343,10 +344,10 @@ $url = preg_replace_callback('#^'.$scheme.':(//)?(?:(?:(?[^:@]*+):)?(?[^@]*+)@)?#', static function ($m) use (&$auth) { if (isset($m['password'])) { if (!\in_array($m['user'], ['', 'default'], true)) { - $auth['user'] = rawurldecode($m['user']); + $auth['user'] = $m['user']; } - $auth['pass'] = rawurldecode($m['password']); + $auth['pass'] = $m['password']; } return 'file:'.($m[1] ?? ''); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisReceiver.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisReceiver.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisReceiver.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisReceiver.php 2026-11-04 11:23:00.000000000 +0000 @@ -59,13 +59,15 @@ $redisEnvelope = json_decode($message['data']['message'] ?? '', true); if (!\is_array($redisEnvelope)) { - return []; + $this->connection->reject($message['id']); + + throw new MessageDecodingFailedException('Could not decode the Redis stream entry: its "message" field should contain a JSON object.'); } try { if (\array_key_exists('body', $redisEnvelope) && \array_key_exists('headers', $redisEnvelope)) { $envelope = $this->serializer->decode([ - 'body' => $redisEnvelope['body'], + 'body' => \is_string($redisEnvelope['body']) ? $redisEnvelope['body'] : null, 'headers' => $redisEnvelope['headers'], ]); } else { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Bridge/Redis/Transport/RedisTransportFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -23,14 +23,14 @@ */ class RedisTransportFactory implements TransportFactoryInterface { - public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface + public function createTransport(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options, SerializerInterface $serializer): TransportInterface { unset($options['transport_name']); return new RedisTransport(Connection::fromDsn($dsn, $options), $serializer); } - public function supports(#[\SensitiveParameter] string $dsn, array $options): bool + public function supports(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options): bool { return str_starts_with($dsn, 'redis:') || str_starts_with($dsn, 'rediss:'); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Handler/BatchHandlerTrait.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Handler/BatchHandlerTrait.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Handler/BatchHandlerTrait.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Handler/BatchHandlerTrait.php 2026-11-04 11:23:00.000000000 +0000 @@ -22,7 +22,18 @@ { if ($jobs = $this->jobs) { $this->jobs = []; - $this->process($jobs); + + try { + $this->process($jobs); + } catch (\Throwable $e) { + foreach ($jobs as [, $ack]) { + if (!$ack->isAcknowledged()) { + $ack->nack($e); + } + } + + throw $e; + } } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php 2026-11-04 11:23:00.000000000 +0000 @@ -52,20 +52,28 @@ $exceptions = []; $alreadyHandled = false; + $handledStamps = []; foreach ($this->handlersLocator->getHandlers($envelope) as $handlerDescriptor) { - if ($this->messageHasAlreadyBeenHandled($envelope, $handlerDescriptor)) { + // a flush dispatch never runs handlers, since the envelope of a failed message can miss the stamps of the handlers that succeeded + if ($envelope->last(FlushBatchHandlersStamp::class) || $this->messageHasAlreadyBeenHandled($envelope, $handlerDescriptor)) { $alreadyHandled = true; continue; } + $ackPending = false; + try { $handler = $handlerDescriptor->getHandler(); $batchHandler = $handlerDescriptor->getBatchHandler(); /** @var AckStamp $ackStamp */ if ($batchHandler && $ackStamp = $envelope->last(AckStamp::class)) { - $ack = new Acknowledger(get_debug_type($batchHandler), static function (?\Throwable $e = null, $result = null) use ($envelope, $ackStamp, $handlerDescriptor) { + $handledBefore = \count($handledStamps); + $ack = new Acknowledger(get_debug_type($batchHandler), static function (?\Throwable $e = null, $result = null) use ($envelope, $ackStamp, $handlerDescriptor, &$handledStamps, $handledBefore) { if (null !== $e) { + // a retry must skip the handlers that succeeded after this batch handler + // a nack sent during the dispatch must not wait for them, since outer middlewares may undo their work + $envelope = $envelope->with(...\array_slice($handledStamps, $handledBefore)); $e = new HandlerFailedException($envelope, [$handlerDescriptor->getName() => $e]); } else { $envelope = $envelope->with(HandledStamp::fromDescriptor($handlerDescriptor, $result)); @@ -81,6 +89,7 @@ } if (!$ack->isAcknowledged()) { + $ackPending = true; $envelope = $envelope->with(new NoAutoAckStamp($handlerDescriptor)); } elseif ($ack->getError()) { throw $ack->getError(); @@ -93,6 +102,9 @@ $handledStamp = HandledStamp::fromDescriptor($handlerDescriptor, $result); $envelope = $envelope->with($handledStamp); + if (!$ackPending) { + $handledStamps[] = $handledStamp; + } $this->logger?->info('Message {class} handled by {handler}', $context + ['handler' => $handledStamp->getHandlerName()]); } catch (\Throwable $e) { $exceptions[$handlerDescriptor->getName()] = $e; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Handler/BatchHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Handler/BatchHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Handler/BatchHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Handler/BatchHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -34,6 +34,37 @@ $handler(new \stdClass()); } + + public function testSynchronousProcessFailureIsRethrown() + { + $handler = new BatchHandlerTestHandler(null, new \RuntimeException('process failed')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('process failed'); + + $handler(new \stdClass()); + } + + public function testProcessFailureNacksTheJobsLeftInTheBatch() + { + $handler = new BatchHandlerTestHandler(null, new \RuntimeException('process failed'), 1); + $results = []; + + foreach (['first', 'second'] as $job) { + $handler(new \stdClass(), new Acknowledger('handler', static function (?\Throwable $e, $result) use (&$results, $job) { + $results[$job] = $e ? $e->getMessage() : $result; + })); + } + + $error = null; + try { + $handler->flush(true); + } catch (\RuntimeException $error) { + } + + $this->assertSame('process failed', $error?->getMessage()); + $this->assertSame(['first' => 'the result', 'second' => 'process failed'], $results); + } } class BatchHandlerTestHandler implements BatchHandlerInterface @@ -42,6 +73,8 @@ public function __construct( private ?\Throwable $error = null, + private ?\Throwable $processError = null, + private int $acksBeforeProcessError = 0, ) { } @@ -52,7 +85,11 @@ private function process(array $jobs): void { - foreach ($jobs as [, $ack]) { + foreach ($jobs as $i => [, $ack]) { + if ($this->processError && $i === $this->acksBeforeProcessError) { + throw $this->processError; + } + if ($this->error) { $ack->nack($this->error); } else { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Middleware/HandleMessageMiddlewareTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Middleware/HandleMessageMiddlewareTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Middleware/HandleMessageMiddlewareTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Middleware/HandleMessageMiddlewareTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -20,14 +20,18 @@ use Symfony\Component\Messenger\Handler\BatchHandlerTrait; use Symfony\Component\Messenger\Handler\HandlerDescriptor; use Symfony\Component\Messenger\Handler\HandlersLocator; +use Symfony\Component\Messenger\MessageBus; +use Symfony\Component\Messenger\Middleware\DispatchAfterCurrentBusMiddleware; use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; use Symfony\Component\Messenger\Middleware\StackMiddleware; use Symfony\Component\Messenger\Stamp\AckStamp; +use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentBusStamp; use Symfony\Component\Messenger\Stamp\HandledStamp; use Symfony\Component\Messenger\Stamp\HandlerArgumentsStamp; use Symfony\Component\Messenger\Stamp\NoAutoAckStamp; use Symfony\Component\Messenger\Test\Middleware\MiddlewareTestCase; use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage; +use Symfony\Component\Messenger\Tests\Fixtures\SecondMessage; class HandleMessageMiddlewareTest extends MiddlewareTestCase { @@ -251,6 +255,69 @@ $this->assertNull($envelopes[1]->last(NoAutoAckStamp::class)); } + public function testBatchHandlerNackOnFlushKeepsTheHandledStampsOfTheOtherHandlers() + { + $batchHandler = $this->createBatchHandlerNackingTheFirstBatch(2); + $middleware = new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor(static fn () => 'handled', ['alias' => 'before']), + new HandlerDescriptor($batchHandler, ['alias' => 'batch']), + new HandlerDescriptor(static fn () => 'handled', ['alias' => 'after']), + new HandlerDescriptor(static fn () => 'handled', ['alias' => 'last']), + ], + ])); + + $acks = []; + $middleware->handle(new Envelope(new DummyMessage('Hey'), [new AckStamp(static function (Envelope $envelope, ?\Throwable $e = null) use (&$acks) { + $acks[] = [$envelope, $e]; + })]), new StackMiddleware()); + + $this->assertSame([], $acks); + + $batchHandler->flush(true); + + $this->assertCount(1, $acks); + [$envelope, $e] = $acks[0]; + $this->assertInstanceOf(HandlerFailedException::class, $e); + $this->assertSame($envelope, $e->getEnvelope()); + $this->assertSame(['Closure@before', 'Closure@after', 'Closure@last'], array_map(static fn (HandledStamp $stamp) => $stamp->getHandlerName(), $envelope->all(HandledStamp::class))); + } + + public function testBatchHandlerNackWhileHandlingTheMessageRunsTheNextHandlersAgainOnRetry() + { + $dispatched = []; + $bus = new MessageBus([ + new DispatchAfterCurrentBusMiddleware(), + new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($this->createBatchHandlerNackingTheFirstBatch(1), ['alias' => 'batch']), + new HandlerDescriptor(static function () use (&$bus) { + $bus->dispatch(new SecondMessage(), [new DispatchAfterCurrentBusStamp()]); + }, ['alias' => 'after']), + ], + SecondMessage::class => [static function (SecondMessage $message) use (&$dispatched) { + $dispatched[] = $message; + }], + ])), + ]); + + $acks = []; + try { + $bus->dispatch(new DummyMessage('Hey'), [new AckStamp(static function (Envelope $envelope) use (&$acks) { + $acks[] = $envelope; + })]); + $this->fail('The batch handler should fail.'); + } catch (HandlerFailedException) { + } + + $this->assertSame([], $dispatched); + $this->assertCount(1, $acks); + + $bus->dispatch($acks[0]->withoutAll(AckStamp::class)); + + $this->assertCount(1, $dispatched); + } + public function testBatchHandlerNoAck() { $handler = new class implements BatchHandlerInterface { @@ -354,6 +421,43 @@ $middleware->handle($envelope, $this->getStackMock()); } + + private function createBatchHandlerNackingTheFirstBatch(int $batchSize): BatchHandlerInterface + { + return new class($batchSize) implements BatchHandlerInterface { + use BatchHandlerTrait; + + private bool $failed = false; + + public function __construct( + private int $batchSize, + ) { + } + + public function __invoke(DummyMessage $message, ?Acknowledger $ack = null) + { + return $this->handle($message, $ack); + } + + private function getBatchSize(): int + { + return $this->batchSize; + } + + private function process(array $jobs): void + { + foreach ($jobs as [, $ack]) { + if ($this->failed) { + $ack->ack(); + } else { + $ack->nack(new \RuntimeException('Batch failed.')); + } + } + + $this->failed = true; + } + }; + } } class HandleMessageMiddlewareTestCallable diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/PhpSerializerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/PhpSerializerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/PhpSerializerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/PhpSerializerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -44,6 +44,16 @@ $serializer->decode([]); } + public function testDecodingFailsWithABodyThatIsNotAString() + { + $serializer = $this->createPhpSerializer(); + + $this->expectException(MessageDecodingFailedException::class); + $this->expectExceptionMessage('Encoded envelope should have at least a "body", or maybe you should implement your own serializer'); + + $serializer->decode(['body' => ['foo']]); + } + public function testDecodingFailsWithBadFormat() { $serializer = $this->createPhpSerializer(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -172,6 +172,27 @@ $this->assertEquals(new DummyMessage('Hello'), $xmlSerializer->decode($xmlSerializer->encode($envelope))->getMessage()); } + public function testSerializedMessageStampIsNotUsedWhenTheMessageWasReplaced() + { + $serializer = new Serializer(); + + $envelope = $serializer->decode(['body' => '{"message": "Hello"}', 'headers' => ['type' => DummyMessage::class]]); + $stamps = array_merge(...array_values($envelope->all())); + + $this->assertSame('{"message": "Hello"}', $serializer->encode($envelope->with(new ReceivedStamp('transport')))['body']); + + $encoded = $serializer->encode(new Envelope(new DummySymfonySerializerUpcastedMessage('Hello', 'EUR'), $stamps)); + + $this->assertSame(DummySymfonySerializerUpcastedMessage::class, $encoded['headers']['type']); + $this->assertSame('{"message":"Hello","currency":"EUR"}', $encoded['body']); + $this->assertEquals(new DummySymfonySerializerUpcastedMessage('Hello', 'EUR'), $serializer->decode($encoded)->getMessage()); + + $encoded = $serializer->encode(new Envelope(new DummyMessage('Bye'), $stamps)); + + $this->assertSame('{"message":"Bye"}', $encoded['body']); + $this->assertEquals(new DummyMessage('Bye'), $serializer->decode($encoded)->getMessage()); + } + public function testEncodedIsHavingTheBodyAndTypeHeader() { $serializer = new Serializer(); @@ -335,6 +356,11 @@ 'Encoded envelope should have at least a "body" and some "headers", or maybe you should implement your own serializer.', ]; + yield 'body_not_a_string' => [ + ['body' => ['foo'], 'headers' => ['type' => 'bar']], + 'Encoded envelope should have at least a "body" and some "headers", or maybe you should implement your own serializer.', + ]; + yield 'no_headers' => [ ['body' => '{}'], 'Encoded envelope should have at least a "body" and some "headers", or maybe you should implement your own serializer.', @@ -344,6 +370,11 @@ ['body' => '{}', 'headers' => ['foo' => 'bar']], 'Encoded envelope does not have a "type" header.', ]; + + yield 'headers_type_not_a_string' => [ + ['body' => '{}', 'headers' => ['type' => ['bar']]], + 'Encoded envelope does not have a "type" header.', + ]; } public function testDecodingFailsWithBadClass() @@ -499,6 +530,14 @@ class DummySymfonySerializerNonSendableStamp implements NonSendableStampInterface { } +class DummySymfonySerializerUpcastedMessage +{ + public function __construct( + public string $message, + public string $currency, + ) { + } +} class DummySymfonySerializerInvalidConstructor { public function __construct(string $missingArgument) diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/TransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/TransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/Transport/TransportFactoryTest.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/Transport/TransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -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\Messenger\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Messenger\Exception\InvalidArgumentException; +use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; +use Symfony\Component\Messenger\Transport\TransportFactory; + +class TransportFactoryTest extends TestCase +{ + public function testOptionsAreNotExposedInTrace() + { + if (!class_exists(\SensitiveParameterValue::class)) { + $this->markTestSkipped('Sensitive parameter support requires PHP 8.2.'); + } + + $factory = new TransportFactory([]); + + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $factory->createTransport('foo://localhost', ['password' => 's3cr3t'], $this->createStub(SerializerInterface::class)); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertStringNotContainsString('s3cr3t', json_encode($e->getTrace(), \JSON_THROW_ON_ERROR)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/WorkerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/WorkerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Tests/WorkerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Tests/WorkerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -30,6 +30,7 @@ use Symfony\Component\Messenger\EventListener\ResetServicesListener; use Symfony\Component\Messenger\EventListener\SendFailedMessageForRetryListener; use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener; +use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\Exception\RejectRedeliveredMessageException; use Symfony\Component\Messenger\Exception\RuntimeException; use Symfony\Component\Messenger\Handler\Acknowledger; @@ -40,9 +41,13 @@ use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; +use Symfony\Component\Messenger\Middleware\MiddlewareInterface; +use Symfony\Component\Messenger\Middleware\StackInterface; use Symfony\Component\Messenger\Retry\MultiplierRetryStrategy; +use Symfony\Component\Messenger\Stamp\AckStamp; use Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp; use Symfony\Component\Messenger\Stamp\FlushBatchHandlersStamp; +use Symfony\Component\Messenger\Stamp\HandledStamp; use Symfony\Component\Messenger\Stamp\NoAutoAckStamp; use Symfony\Component\Messenger\Stamp\ReceivedStamp; use Symfony\Component\Messenger\Stamp\SentStamp; @@ -663,6 +668,704 @@ $this->assertSame($expectedMessages, $handler->processedMessages); } + public function testBatchAckDoesNotHideTheFailureOfAnotherHandler() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new DummyMessage('Bob'))], + ]); + + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor(new DummyBatchHandler()), + static function (DummyMessage $message) { + if ('Bob' === $message->getMessage()) { + throw new \RuntimeException('Handler failed.'); + } + }, + ], + ]))]); + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + + $worker = new Worker([$receiver], $bus, $dispatcher, clock: new MockClock()); + $worker->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(['Hey', 'Bob'], array_map(static fn (Envelope $envelope) => $envelope->getMessage()->getMessage(), $receiver->getRejectedEnvelopes())); + } + + public function testMultipleBatchHandlersAcknowledgeTheMessageOnce() + { + foreach ([ + 'both synchronous' => [1, 1, false, false], + 'first synchronous' => [1, 2, false, false], + 'second synchronous' => [2, 1, false, false], + 'both deferred' => [2, 3, false, false], + 'first deferred fails' => [2, 1, true, false], + 'second deferred fails' => [1, 2, false, true], + 'both synchronous fail' => [1, 1, true, true], + 'first synchronous fails' => [1, 1, true, false], + 'second synchronous fails' => [1, 1, false, true], + 'first fails before second is flushed' => [1, 2, true, false], + 'second fails before first is flushed' => [2, 1, false, true], + 'both fail with first synchronous' => [1, 2, true, true], + 'both fail with second synchronous' => [2, 1, true, true], + 'first fails' => [2, 3, true, false], + 'second fails' => [2, 3, false, true], + 'both fail' => [2, 3, true, true], + ] as $case => [$firstSize, $secondSize, $firstFails, $secondFails]) { + $first = $this->createCoordinatedBatchHandler($firstSize, $firstFails); + $second = $this->createCoordinatedBatchHandler($secondSize, $secondFails); + $receiver = new DummyReceiver([[new Envelope(new DummyMessage('Hey'))]]); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($first, ['alias' => 'first']), + new HandlerDescriptor($second, ['alias' => 'second']), + ], + ]))]); + $dispatcher = new EventDispatcher(); + $handled = []; + $failed = []; + $whilePending = null; + $dispatcher->addListener(WorkerMessageHandledEvent::class, static function (WorkerMessageHandledEvent $event) use (&$handled) { + $handled[] = $event->getEnvelope(); + }); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failed) { + $failed[] = $event->getThrowable(); + }); + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) use ($receiver, &$whilePending) { + $whilePending = [$receiver->getAcknowledgeCount(), $receiver->getRejectCount()]; + $event->getWorker()->stop(); + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $fails = $firstFails || $secondFails; + $this->assertSame($fails ? 0 : 1, $receiver->getAcknowledgeCount(), $case); + $this->assertSame($fails ? 1 : 0, $receiver->getRejectCount(), $case); + $this->assertCount($fails ? 0 : 1, $handled, $case); + $this->assertCount($fails ? 1 : 0, $failed, $case); + $this->assertSame(1, $first->calls, $case); + $this->assertSame(1, $second->calls, $case); + $dispatchFails = ($firstFails && 1 === $firstSize) || ($secondFails && 1 === $secondSize); + if (!$dispatchFails && ($firstSize > 1 || $secondSize > 1)) { + $this->assertSame([0, 0], $whilePending, $case); + } + if ($fails) { + $this->assertInstanceOf(HandlerFailedException::class, $failed[0], $case); + $this->assertCount((int) $firstFails + (int) $secondFails, $failed[0]->getWrappedExceptions(), $case); + $envelope = $failed[0]->getEnvelope(); + } else { + $envelope = $handled[0]; + } + $results = array_map(static fn (HandledStamp $stamp) => $stamp->getHandlerName(), $envelope->all(HandledStamp::class)); + $this->assertSame(!$firstFails, \in_array((new HandlerDescriptor($first, ['alias' => 'first']))->getName(), $results, true), $case); + $this->assertSame(!$secondFails, \in_array((new HandlerDescriptor($second, ['alias' => 'second']))->getName(), $results, true), $case); + $this->assertCount(2 - (int) $firstFails - (int) $secondFails, $results, $case); + $this->assertSame(array_fill(0, \count($results), 'Hey'), array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $envelope->all(HandledStamp::class)), $case); + } + } + + public function testDifferentBatchSizesDoNotAcknowledgeMessagesBeforeAllHandlersFinish() + { + $first = $this->createCoordinatedBatchHandler(2, false); + $second = $this->createCoordinatedBatchHandler(3, false); + $receiver = new DummyReceiver([[ + new Envelope(new DummyMessage('One')), + new Envelope(new DummyMessage('Two')), + new Envelope(new DummyMessage('Three')), + ]]); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($first, ['alias' => 'first']), + new HandlerDescriptor($second, ['alias' => 'second']), + ], + ]))]); + $counts = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) use ($receiver, &$counts) { + $counts[] = $receiver->getAcknowledgeCount(); + if (3 === \count($counts)) { + $event->getWorker()->stop(); + } + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame([0, 0, 2], $counts); + $this->assertSame(3, $receiver->getAcknowledgeCount()); + $this->assertSame(0, $receiver->getRejectCount()); + $this->assertSame(3, $first->calls); + $this->assertSame(3, $second->calls); + foreach ($receiver->getAcknowledgedEnvelopes() as $envelope) { + $this->assertCount(2, $envelope->all(HandledStamp::class)); + } + } + + public function testRetrySkipsTheBatchHandlerThatAlreadySucceeded() + { + $first = $this->createCoordinatedBatchHandler(2, true); + $second = $this->createCoordinatedBatchHandler(2, false); + $ordinaryCalls = 0; + $middleware = new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($first, ['alias' => 'first']), + new HandlerDescriptor($second, ['alias' => 'second']), + static function () use (&$ordinaryCalls) { + ++$ordinaryCalls; + }, + ], + ])); + $bus = new MessageBus([$middleware]); + $receiver = new DummyReceiver([[new Envelope(new DummyMessage('Hey'))]]); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) { + $event->getWorker()->stop(); + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + $first->fails = false; + $retry = $receiver->getRejectedEnvelopes()[0]->withoutAll(AckStamp::class)->withoutAll(NoAutoAckStamp::class); + $receiver = new DummyReceiver([[$retry]]); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame(1, $receiver->getAcknowledgeCount()); + $this->assertSame(0, $receiver->getRejectCount()); + $this->assertSame(2, $first->calls); + $this->assertSame(1, $second->calls); + $this->assertSame(1, $ordinaryCalls); + } + + public function testBatchHandlersFlushedDuringAFailedDispatchRunAgainOnRetry() + { + $first = $this->createCoordinatedBatchHandler(2, false); + $second = $this->createCoordinatedBatchHandler(2, false); + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new DummyMessage('Bob'))], + ]); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($first, ['alias' => 'first']), + new HandlerDescriptor($second, ['alias' => 'second']), + new HandlerDescriptor(static function (DummyMessage $message) { + if ('Bob' === $message->getMessage()) { + throw new \RuntimeException('Handler failed.'); + } + + return 'handler result'; + }, ['alias' => 'handler']), + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[$event->getEnvelope()->getMessage()->getMessage()] = $event->getThrowable(); + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(['Hey', 'Bob'], array_keys($failures)); + $this->assertCount(2, $failures['Hey']->getWrappedExceptions()); + $this->assertSame(['handler result'], array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $failures['Hey']->getEnvelope()->all(HandledStamp::class))); + } + + public function testEveryPendingBatchHandlerIsFlushed() + { + $first = $this->createCoordinatedBatchHandler(2, false); + $second = $this->createCoordinatedBatchHandler(3, false); + $one = new DummyReceiver([[new Envelope(new DummyMessage('One'))]]); + $two = new DummyReceiver([[new Envelope(new DummyMessage('Two'))]]); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($first, ['alias' => 'first', 'from_transport' => 'one']), + new HandlerDescriptor($second, ['alias' => 'second']), + ], + ]))]); + $dispatcher = new EventDispatcher(); + $runs = 0; + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) use (&$runs) { + if (2 === ++$runs) { + $event->getWorker()->stop(); + } + }); + + (new Worker(['one' => $one, 'two' => $two], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame(1, $one->getAcknowledgeCount()); + $this->assertSame(['One', 'One'], array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $one->getAcknowledgedEnvelopes()[0]->all(HandledStamp::class))); + $this->assertSame(1, $two->getAcknowledgeCount()); + $this->assertSame(['Two'], array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $two->getAcknowledgedEnvelopes()[0]->all(HandledStamp::class))); + } + + public function testBatchHandlerCannotAcknowledgeAMessageWhoseDispatchFailed() + { + $batchHandler = $this->createCoordinatedBatchHandler(2, false); + $failOnce = new class implements MiddlewareInterface { + private bool $failed = false; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + if (!$this->failed) { + $this->failed = true; + + throw new \RuntimeException('Failure after the handlers.'); + } + + return $stack->next()->handle($envelope, $stack); + } + }; + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([DummyMessage::class => [new HandlerDescriptor($batchHandler, ['alias' => 'batch'])]])), $failOnce]); + $receiver = new DummyReceiver([[new Envelope(new DummyMessage('One')), new Envelope(new DummyMessage('Two'))]]); + $dispatcher = new EventDispatcher(); + $runs = 0; + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) use (&$runs) { + if (2 === ++$runs) { + $event->getWorker()->stop(); + } + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertSame('One', $receiver->getRejectedEnvelopes()[0]->getMessage()->getMessage()); + $this->assertSame(1, $receiver->getAcknowledgeCount()); + $this->assertSame('Two', $receiver->getAcknowledgedEnvelopes()[0]->getMessage()->getMessage()); + } + + public function testWorkerReleasesTheEnvelopeOfAHandledMessage() + { + $message = new DummyMessage('Hey'); + $handledMessage = \WeakReference::create($message); + $receiver = new class([new Envelope($message)]) implements ReceiverInterface { + public function __construct( + private array $envelopes, + ) { + } + + public function get(): iterable + { + $envelopes = $this->envelopes; + $this->envelopes = []; + + return $envelopes; + } + + public function ack(Envelope $envelope): void + { + } + + public function reject(Envelope $envelope): void + { + } + }; + unset($message); + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + static fn () => null, + new HandlerDescriptor($this->createCoordinatedBatchHandler(1, false), ['alias' => 'batch']), + ], + ]))]); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $event) { + $event->getWorker()->stop(); + }); + + (new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()))->run(); + + $this->assertNull($handledMessage->get()); + } + + public function testBatchWithAFailedMessageIsFlushedInADispatchOfItsOwn() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new DummyMessage('Bob'))], + ]); + + $transaction = $this->createTransactionMiddleware(); + + $batchHandler = $this->createBatchHandler(static function (array $jobs) use ($transaction) { + foreach ($jobs as [$message, $ack]) { + $transaction->pending[] = $message->getMessage(); + $ack->ack('batch result'); + } + }); + + $bus = new MessageBus([$transaction, new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($batchHandler), + static function (DummyMessage $message) { + if ('Bob' === $message->getMessage()) { + throw new \RuntimeException('Handler failed.'); + } + }, + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[] = $event->getEnvelope(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(['Hey', 'Bob'], $transaction->committed); + $this->assertSame(1, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertCount(1, $failures); + $this->assertSame(['batch result'], array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $failures[0]->all(HandledStamp::class))); + } + + public function testBatchHandlerNackJoinsTheFailureOfTheMessage() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Bob'))], + ]); + + $processed = []; + $batchHandler = $this->createBatchHandler(static function (array $jobs) use (&$processed) { + foreach ($jobs as [$message, $ack]) { + $processed[] = $message->getMessage(); + $ack->nack(new \RuntimeException('Batch failed.')); + } + }); + + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor(static fn () => null, ['alias' => 'before']), + new HandlerDescriptor($batchHandler), + static fn () => throw new \RuntimeException('Handler failed.'), + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(['Bob'], $processed); + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertCount(1, $failures); + $this->assertSame(['Handler failed.', 'Batch failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures[0]->getWrappedExceptions()))); + $this->assertSame(['Closure@before'], array_map(static fn (HandledStamp $stamp) => $stamp->getHandlerName(), $failures[0]->getEnvelope()->all(HandledStamp::class))); + } + + public function testFailedFlushJoinsTheFailureOfTheMessage() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Bob'))], + ]); + + $bus = new class(new HandlerDescriptor(new DummyBatchHandler())) implements MessageBusInterface { + public function __construct( + private HandlerDescriptor $batchHandler, + ) { + } + + public function dispatch(object $message, array $stamps = []): Envelope + { + $envelope = Envelope::wrap($message, $stamps); + + if ($envelope->last(FlushBatchHandlersStamp::class)) { + throw new \RuntimeException('Flush failed.'); + } + + $envelope->last(AckStamp::class)->ack($envelope->with(new HandledStamp('rolled back', 'other batch handler'))); + + throw new HandlerFailedException($envelope->with(new NoAutoAckStamp($this->batchHandler)), ['handler' => new \RuntimeException('Handler failed.')]); + } + }; + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertCount(1, $failures); + $this->assertSame(['Handler failed.', 'Flush failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures[0]->getWrappedExceptions()))); + $this->assertSame([], $failures[0]->getEnvelope()->all(HandledStamp::class)); + } + + public function testAcksOfABatchFlushedDuringAFailedDispatchBecomeFailures() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new DummyMessage('Joe'))], + [new Envelope(new DummyMessage('Bob'))], + ]); + + $transaction = $this->createTransactionMiddleware(); + + $batchHandler = $this->createBatchHandler(static function (array $jobs) use ($transaction) { + foreach ($jobs as [$message, $ack]) { + $transaction->pending[] = $message->getMessage(); + + if ('Joe' === $message->getMessage()) { + $ack->nack(new \RuntimeException('Batch failed.')); + } else { + $ack->ack('batch result'); + } + } + }); + + $bus = new MessageBus([$transaction, new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($batchHandler), + static function (DummyMessage $message) { + if ('Bob' === $message->getMessage()) { + throw new \RuntimeException('Handler failed.'); + } + }, + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(3)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[$event->getEnvelope()->getMessage()->getMessage()] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame([], $transaction->committed); + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(3, $receiver->getRejectCount()); + $this->assertSame(['Hey', 'Joe', 'Bob'], array_keys($failures)); + + $this->assertSame([], $failures['Hey']->getEnvelope()->all(HandledStamp::class)); + $this->assertStringContainsString('was flushed during the dispatch of another message, and that dispatch failed.', $failures['Hey']->getMessage()); + $this->assertSame(['Batch failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures['Joe']->getWrappedExceptions()))); + } + + public function testAcksSentDuringAFailedFlushBecomeFailures() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new SecondHandlerDummyMessage('Ho'))], + ]); + + $throwAfterFlush = new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $envelope = $stack->next()->handle($envelope, $stack); + + if ($envelope->last(FlushBatchHandlersStamp::class) && $envelope->getMessage() instanceof SecondHandlerDummyMessage) { + throw new \RuntimeException('Flush dispatch failed.'); + } + + return $envelope; + } + }; + + $bus = new MessageBus([$throwAfterFlush, new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [new HandlerDescriptor(new DummyBatchHandler())], + SecondHandlerDummyMessage::class => [new HandlerDescriptor(new SecondDummyBatchHandler())], + ]))]); + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(1, $receiver->getAcknowledgeCount()); + $this->assertSame(['Ho'], array_map(static fn (Envelope $envelope) => $envelope->getMessage()->getMessage(), $receiver->getRejectedEnvelopes())); + } + + public function testBatchFlushedForAFailedMessageRunsAgainWhenTheFlushFails() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + [new Envelope(new DummyMessage('Bob'))], + ]); + + $throwAfterFirstFlush = new class implements MiddlewareInterface { + private bool $thrown = false; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $envelope = $stack->next()->handle($envelope, $stack); + + if (!$this->thrown && $envelope->last(FlushBatchHandlersStamp::class)) { + $this->thrown = true; + + throw new \RuntimeException('Flush dispatch failed.'); + } + + return $envelope; + } + }; + + $batchHandler = $this->createBatchHandler(static function (array $jobs) { + foreach ($jobs as [, $ack]) { + $ack->ack('batch result'); + } + }); + + $bus = new MessageBus([$throwAfterFirstFlush, new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($batchHandler), + static function (DummyMessage $message) { + if ('Bob' === $message->getMessage()) { + throw new \RuntimeException('Handler failed.'); + } + }, + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(2)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[$event->getEnvelope()->getMessage()->getMessage()] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(['Hey', 'Bob'], array_keys($failures)); + $this->assertSame(['Handler failed.', 'Flush dispatch failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures['Bob']->getWrappedExceptions()))); + $this->assertSame([], $failures['Bob']->getEnvelope()->all(HandledStamp::class)); + } + + public function testBatchHandlerThatFailsToFlushRunsAgainOnRetry() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + ]); + + $batchHandler = $this->createBatchHandler(static function () { + throw new \RuntimeException('Batch failed.'); + }); + + $bus = new MessageBus([new HandleMessageMiddleware(new HandlersLocator([ + DummyMessage::class => [ + new HandlerDescriptor($batchHandler), + new HandlerDescriptor(static fn () => 'handler result', ['alias' => 'handler']), + ], + ]))]); + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertCount(1, $failures); + $this->assertSame(['Batch failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures[0]->getWrappedExceptions()))); + $this->assertSame(['handler result'], array_map(static fn (HandledStamp $stamp) => $stamp->getResult(), $failures[0]->getEnvelope()->all(HandledStamp::class))); + } + + public function testFailedFlushDropsTheHandledStampOfTheBatchHandlersThatFailed() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + ]); + + $bus = new class(new HandlerDescriptor(new DummyBatchHandler())) implements MessageBusInterface { + public function __construct( + private HandlerDescriptor $batchHandler, + ) { + } + + public function dispatch(object $message, array $stamps = []): Envelope + { + $envelope = Envelope::wrap($message, $stamps); + + if ($envelope->last(FlushBatchHandlersStamp::class)) { + throw new HandlerFailedException($envelope, [$this->batchHandler->getName() => new \RuntimeException('Flush failed.')]); + } + + return $envelope->with(new HandledStamp('handler result', 'handler'), new NoAutoAckStamp($this->batchHandler), HandledStamp::fromDescriptor($this->batchHandler, 1)); + } + }; + + $failures = []; + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + $dispatcher->addListener(WorkerMessageFailedEvent::class, static function (WorkerMessageFailedEvent $event) use (&$failures) { + $failures[] = $event->getThrowable(); + }); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(1, $receiver->getRejectCount()); + $this->assertCount(1, $failures); + $this->assertSame(['Flush failed.'], array_map(static fn (\Throwable $e) => $e->getMessage(), array_values($failures[0]->getWrappedExceptions()))); + $this->assertSame(['handler'], array_map(static fn (HandledStamp $stamp) => $stamp->getHandlerName(), $failures[0]->getEnvelope()->all(HandledStamp::class))); + $this->assertSame(['handler'], array_map(static fn (HandledStamp $stamp) => $stamp->getHandlerName(), $receiver->getRejectedEnvelopes()[0]->all(HandledStamp::class))); + } + + public function testFailedFlushRejectsTheMessageWhenNoBatchHandlerReported() + { + $receiver = new DummyReceiver([ + [new Envelope(new DummyMessage('Hey'))], + ]); + + $bus = new class(new HandlerDescriptor(new DummyBatchHandler())) implements MessageBusInterface { + public function __construct( + private HandlerDescriptor $batchHandler, + ) { + } + + public function dispatch(object $message, array $stamps = []): Envelope + { + $envelope = Envelope::wrap($message, $stamps); + + if ($envelope->last(FlushBatchHandlersStamp::class)) { + throw new \RuntimeException('Flush failed.'); + } + + return $envelope->with(new NoAutoAckStamp($this->batchHandler), HandledStamp::fromDescriptor($this->batchHandler, 1)); + } + }; + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1)); + + $worker = new Worker([$receiver], $bus, $dispatcher, null, null, new MockClock()); + $worker->run(); + + $this->assertSame(0, $receiver->getAcknowledgeCount()); + $this->assertSame(1, $receiver->getRejectCount()); + } + public function testFlushBatchOnIdle() { $expectedMessages = [ @@ -867,6 +1570,94 @@ $rejectedEnvelope = $rejectedEnvelopes[0]; $this->assertNull($rejectedEnvelope->last(NoAutoAckStamp::class)); } + + private function createTransactionMiddleware(): MiddlewareInterface + { + // like doctrine_transaction: rolls back on failure and strips the handled stamps + return new class implements MiddlewareInterface { + public array $pending = []; + public array $committed = []; + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + try { + $envelope = $stack->next()->handle($envelope, $stack); + array_push($this->committed, ...$this->pending); + + return $envelope; + } catch (HandlerFailedException $e) { + throw new HandlerFailedException($e->getEnvelope()->withoutAll(HandledStamp::class), $e->getWrappedExceptions()); + } finally { + $this->pending = []; + } + } + }; + } + + private function createCoordinatedBatchHandler(int $batchSize, bool $fails): BatchHandlerInterface + { + return new class($batchSize, $fails) implements BatchHandlerInterface { + use BatchHandlerTrait; + + public int $calls = 0; + + public function __construct( + private int $batchSize, + public bool $fails, + ) { + } + + public function __invoke(DummyMessage $message, ?Acknowledger $ack = null) + { + ++$this->calls; + + return $this->handle($message, $ack); + } + + private function getBatchSize(): int + { + return $this->batchSize; + } + + private function process(array $jobs): void + { + foreach ($jobs as [$message, $ack]) { + if ($this->fails) { + $ack->nack(new \RuntimeException('Batch failed.')); + } else { + $ack->ack($message->getMessage()); + } + } + } + }; + } + + private function createBatchHandler(\Closure $process): BatchHandlerInterface + { + return new class($process) implements BatchHandlerInterface { + use BatchHandlerTrait; + + public function __construct( + private \Closure $process, + ) { + } + + public function __invoke(DummyMessage $message, ?Acknowledger $ack = null) + { + return $this->handle($message, $ack); + } + + private function getBatchSize(): int + { + return 3; + } + + private function process(array $jobs): void + { + ($this->process)($jobs); + } + }; + } } class DummyQueueReceiver extends DummyReceiver implements QueueReceiverInterface diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php 2026-11-04 11:23:00.000000000 +0000 @@ -42,7 +42,7 @@ public function decode(array $encodedEnvelope): Envelope { - if (empty($encodedEnvelope['body'])) { + if (!\is_string($encodedEnvelope['body'] ?? null) || '' === $encodedEnvelope['body']) { throw new MessageDecodingFailedException('Encoded envelope should have at least a "body", or maybe you should implement your own serializer.'); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php 2026-11-04 11:23:00.000000000 +0000 @@ -80,18 +80,15 @@ public function decode(array $encodedEnvelope): Envelope { - if (empty($encodedEnvelope['body']) || empty($encodedEnvelope['headers'])) { + if (!\is_string($encodedEnvelope['body'] ?? null) || '' === $encodedEnvelope['body'] || !($encodedEnvelope['headers'] ?? null)) { throw new MessageDecodingFailedException('Encoded envelope should have at least a "body" and some "headers", or maybe you should implement your own serializer.'); } - if (empty($encodedEnvelope['headers']['type'])) { + if (!\is_string($encodedEnvelope['headers']['type'] ?? null) || '' === $encodedEnvelope['headers']['type']) { throw new MessageDecodingFailedException('Encoded envelope does not have a "type" header.'); } $stamps = $this->decodeStamps($encodedEnvelope); - $stamps[] = $serializedMessageStamp = new SerializedMessageStamp($encodedEnvelope['body']); - self::$decoders ??= new \WeakMap(); - self::$decoders[$serializedMessageStamp] = $this; $serializerStamp = $this->findFirstSerializerStamp($stamps); @@ -106,6 +103,10 @@ throw new MessageDecodingFailedException('Could not decode message: '.$e->getMessage(), (int) $e->getCode(), $e); } + $stamps[] = $serializedMessageStamp = new SerializedMessageStamp($encodedEnvelope['body']); + self::$decoders ??= new \WeakMap(); + self::$decoders[$serializedMessageStamp] = [$this, \WeakReference::create($message)]; + return new Envelope($message, $stamps); } @@ -120,9 +121,13 @@ /** @var SerializedMessageStamp|null $serializedMessageStamp */ $serializedMessageStamp = $envelope->last(SerializedMessageStamp::class); - // the body can be reused only when it is what this serializer would produce - if ($serializedMessageStamp && ($decoder = self::$decoders[$serializedMessageStamp] ?? null) && [$decoder->serializer, $decoder->format, $decoder->context] !== [$this->serializer, $this->format, $this->context]) { - $serializedMessageStamp = null; + // the body can be reused only when it is what this serializer would produce for the decoded message + if ($serializedMessageStamp && ($decoded = self::$decoders[$serializedMessageStamp] ?? null)) { + [$decoder, $decodedMessage] = $decoded; + + if ($decodedMessage->get() !== $envelope->getMessage() || [$decoder->serializer, $decoder->format, $decoder->context] !== [$this->serializer, $this->format, $this->context]) { + $serializedMessageStamp = null; + } } $envelope = $envelope->withoutStampsOfType(NonSendableStampInterface::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -31,7 +31,7 @@ $this->factories = $factories; } - public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface + public function createTransport(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options, SerializerInterface $serializer): TransportInterface { foreach ($this->factories as $factory) { if ($factory->supports($dsn, $options)) { @@ -56,7 +56,7 @@ throw new InvalidArgumentException('No transport supports the given Messenger DSN.'.$packageSuggestion); } - public function supports(#[\SensitiveParameter] string $dsn, array $options): bool + public function supports(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options): bool { foreach ($this->factories as $factory) { if ($factory->supports($dsn, $options)) { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Transport/TransportFactoryInterface.php 2026-11-04 11:23:00.000000000 +0000 @@ -25,7 +25,7 @@ /** * @return TTransport */ - public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface; + public function createTransport(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options, SerializerInterface $serializer): TransportInterface; - public function supports(#[\SensitiveParameter] string $dsn, array $options): bool; + public function supports(#[\SensitiveParameter] string $dsn, #[\SensitiveParameter] array $options): bool; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Worker.php symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Worker.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Messenger/Worker.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Messenger/Worker.php 2026-11-04 11:23:00.000000000 +0000 @@ -24,6 +24,7 @@ use Symfony\Component\Messenger\Event\WorkerStoppedEvent; use Symfony\Component\Messenger\Exception\DelayedMessageHandlingException; use Symfony\Component\Messenger\Exception\EnvelopeAwareExceptionInterface; +use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\Exception\RejectRedeliveredMessageException; use Symfony\Component\Messenger\Exception\RuntimeException; use Symfony\Component\Messenger\Stamp\AckStamp; @@ -167,25 +168,56 @@ return; } - $acked = false; - $ack = function (Envelope $envelope, ?\Throwable $e = null) use ($transportName, &$acked) { - $acked = true; - $this->acks[] = [$transportName, $envelope, $e]; + $acks = []; + $dispatching = true; + $batch = null; + $combine = static function (array $acks) use (&$batch): ?array { + return self::combineBatchAcks($batch, $acks); + }; + $ack = function (Envelope $envelope, ?\Throwable $e = null) use ($transportName, &$acks, &$dispatching, $combine) { + if ($dispatching) { + $acks[] = [$transportName, $envelope, $e]; + } else { + $this->acks[] = [$transportName, $envelope, $e, $combine]; + } }; + $heldAcks = \count($this->acks); try { $e = null; $envelope = $this->bus->dispatch($envelope->with(new ReceivedStamp($transportName), new ConsumedByWorkerStamp(), new AckStamp($ack))); } catch (\Throwable $e) { + $this->failHeldAcks($heldAcks); + } + + if ($e instanceof HandlerFailedException && $e->getEnvelope()->last(NoAutoAckStamp::class)) { + $acks = []; + $e = $this->flushBatchHandlers($e, $acks); } + // when the bus throws, its exception wins over the acks sent during the dispatch + $dispatching = false; + $acked = !$e && $acks; $noAutoAckStamp = $envelope->last(NoAutoAckStamp::class); - if (!$acked && !$noAutoAckStamp) { + if ($acked || $noAutoAckStamp) { + $batch = [$envelope->withoutAll(AckStamp::class), []]; + + if ($batchAck = $combine($acks)) { + $this->acks[] = $batchAck; + } + } else { $this->acks[] = [$transportName, $envelope, $e]; - } elseif ($noAutoAckStamp) { + } + + // the ack closure references this list, so emptying it frees the envelopes without waiting for the garbage collector + $acks = []; + + if ($noAutoAckStamp) { $this->unacks ??= new \SplObjectStorage(); - $this->unacks[$noAutoAckStamp->getHandlerDescriptor()->getBatchHandler()] = [$envelope->withoutAll(AckStamp::class), $transportName, $this->clock->now()->format('U.u')]; + foreach ($envelope->all(NoAutoAckStamp::class) as $stamp) { + $this->unacks[$stamp->getHandlerDescriptor()->getBatchHandler()] = [$envelope, $transportName, $this->clock->now()->format('U.u')]; + } } $this->ack(); @@ -196,7 +228,12 @@ $acks = $this->acks; $this->acks = []; - foreach ($acks as [$transportName, $envelope, $e]) { + foreach ($acks as $ack) { + if (isset($ack[3]) && !$ack = $ack[3]([$ack])) { + continue; + } + + [$transportName, $envelope, $e] = $ack; $receiver = $this->receivers[$transportName]; if (null !== $e) { @@ -304,11 +341,26 @@ foreach ($unacks as $handler) { [$envelope, $transportName] = $unacks[$handler]; + $heldAcks = \count($this->acks); try { - $this->bus->dispatch($envelope->with(new FlushBatchHandlersStamp($force))); + $this->bus->dispatch($envelope->withoutAll(AckStamp::class)->with(new FlushBatchHandlersStamp($force))); } catch (\Throwable $e) { + $this->failHeldAcks($heldAcks); $envelope = $envelope->withoutAll(NoAutoAckStamp::class); - $this->acks[] = [$transportName, $envelope, $e]; + + if ($e instanceof HandlerFailedException) { + // a retry must run the batch handlers whose flush failed + $failed = $e->getWrappedExceptions(); + $envelope = $envelope->withoutAll(HandledStamp::class)->with(...array_filter($envelope->all(HandledStamp::class), static fn (HandledStamp $stamp) => !isset($failed[$stamp->getHandlerName()]))); + $e = new HandlerFailedException($envelope, $failed); + } + + if ($ackStamp = $envelope->last(AckStamp::class)) { + // the acks that the batch sent during the flush come first, and this failure is ignored when they already gave the message its outcome + $ackStamp->ack($envelope, $e); + } else { + $this->acks[] = [$transportName, $envelope, $e]; + } } } @@ -326,4 +378,123 @@ { return $this->metadata; } + + /** + * Combines the acks that the batch handlers of a message sent, once they all did. + * + * Returns null while some batch handlers didn't send theirs, or when the message already has its outcome. + * + * @param array{Envelope, list}|null $batch The dispatched envelope and the acks received so far, null once the message has its outcome + */ + private static function combineBatchAcks(?array &$batch, array $acks): ?array + { + if (null === $batch) { + return null; + } + + [$envelope, $received] = $batch; + foreach ($acks as [$transportName, $ackEnvelope, $e]) { + $received[] = [$transportName, $ackEnvelope, $e]; + } + + $pending = []; + $handledStamps = []; + foreach ($envelope->all(HandledStamp::class) as $stamp) { + $handledStamps[$stamp->getHandlerName()] = $stamp; + } + foreach ($envelope->all(NoAutoAckStamp::class) as $stamp) { + $name = $stamp->getHandlerDescriptor()->getName(); + $pending[$name] = true; + unset($handledStamps[$name]); + } + + $exceptions = []; + foreach ($received as [, $ackEnvelope, $e]) { + if ($e instanceof HandlerFailedException) { + foreach ($e->getWrappedExceptions() as $name => $exception) { + unset($pending[$name]); + $exceptions[$name] = $exception; + } + } elseif (null !== $e) { + // an error that isn't tied to a batch handler gives the message its outcome + $pending = []; + $exceptions[] = $e; + } elseif ($stamp = $ackEnvelope->last(HandledStamp::class)) { + $name = $stamp->getHandlerName(); + unset($pending[$name]); + $handledStamps[$name] = $stamp; + } + } + + if ($pending) { + $batch = [$envelope, $received]; + + return null; + } + + $batch = null; + + if (1 === \count($received)) { + return $received[0]; + } + + $envelope = $envelope->withoutAll(NoAutoAckStamp::class)->withoutAll(HandledStamp::class)->with(...array_values($handledStamps)); + + return [$received[0][0], $envelope, $exceptions ? new HandlerFailedException($envelope, $exceptions) : null]; + } + + /** + * Turns the acks that messages sent during a failed dispatch into failures. + * + * Their batch was flushed during that dispatch, so what the batch did for them may have been undone. + * The acks held from $offset on are the ones sent during that dispatch. + */ + private function failHeldAcks(int $offset): void + { + foreach (\array_slice($this->acks, $offset, null, true) as $i => [$transportName, $envelope, $e]) { + if (null !== $e || !$stamp = $envelope->last(HandledStamp::class)) { + continue; + } + + $stamps = $envelope->all(HandledStamp::class); + array_pop($stamps); + $envelope = $envelope->withoutAll(HandledStamp::class)->with(...$stamps); + $e = new RuntimeException(\sprintf('The batch of "%s" was flushed during the dispatch of another message, and that dispatch failed.', $stamp->getHandlerName())); + $this->acks[$i] = [$transportName, $envelope, new HandlerFailedException($envelope, [$stamp->getHandlerName() => $e])] + $this->acks[$i]; + } + } + + /** + * Flushes the batch handlers that still have a failed message in their batch, in a dispatch of their own. + * + * Their work for other messages then doesn't depend on the failed dispatch, and their outcome joins the failure. + */ + private function flushBatchHandlers(HandlerFailedException $e, array &$acks): HandlerFailedException + { + $envelope = $e->getEnvelope(); + $exceptions = $e->getWrappedExceptions(); + + $heldAcks = \count($this->acks); + try { + $this->bus->dispatch($envelope->withoutAll(AckStamp::class)->with(new FlushBatchHandlersStamp(true))); + } catch (\Throwable $flushError) { + // the failed flush may have undone what the batch did, so a retry runs it again + $this->failHeldAcks($heldAcks); + $acks = []; + $exceptions += $flushError instanceof HandlerFailedException ? $flushError->getWrappedExceptions() : [$flushError]; + } + + $pending = array_map(static fn (NoAutoAckStamp $stamp) => $stamp->getHandlerDescriptor()->getName(), $envelope->all(NoAutoAckStamp::class)); + $stamps = array_filter($envelope->all(HandledStamp::class), static fn (HandledStamp $stamp) => !\in_array($stamp->getHandlerName(), $pending, true)); + + foreach ($acks as [, $ackEnvelope, $ackError]) { + if (null === $ackError) { + $stamps[] = $ackEnvelope->last(HandledStamp::class); + } elseif ($ackError instanceof HandlerFailedException) { + $exceptions += $ackError->getWrappedExceptions(); + } + } + + return new HandlerFailedException($envelope->withoutAll(HandledStamp::class)->with(...$stamps), $exceptions); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Bridge/Mailjet/Tests/MailjetTransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Bridge/Mailjet/Tests/MailjetTransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Bridge/Mailjet/Tests/MailjetTransportFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Bridge/Mailjet/Tests/MailjetTransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -37,7 +37,7 @@ public static function incompleteDsnProvider(): iterable { - yield 'missing from' => ['mailjet://authtoken@default', 'Invalid "mailjet://authtoken@default" notifier DSN: Password is not set.']; + yield 'missing from' => ['mailjet://authtoken@default', 'Password is not set.']; } public static function unsupportedSchemeProvider(): iterable diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Tests/Transport/AbstractTransportFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Tests/Transport/AbstractTransportFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Tests/Transport/AbstractTransportFactoryTest.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Tests/Transport/AbstractTransportFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Tests\Transport; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Exception\IncompleteDsnException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\NullTransport; +use Symfony\Component\Notifier\Transport\TransportInterface; + +class AbstractTransportFactoryTest extends TestCase +{ + public function testMissingPasswordDoesNotLeakCredentials() + { + $factory = new class extends AbstractTransportFactory { + public function create(Dsn $dsn): TransportInterface + { + $this->getPassword($dsn); + + return new NullTransport(); + } + + protected function getSupportedSchemes(): array + { + return ['foo']; + } + }; + + try { + $factory->create(new Dsn('foo://s3cr3t@default?token=s3cr3t')); + $this->fail('An exception should have been thrown.'); + } catch (IncompleteDsnException $e) { + $this->assertSame('Invalid "foo://default" notifier DSN: Password is not set.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Notifier/Transport/AbstractTransportFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -47,6 +47,6 @@ protected function getPassword(Dsn $dsn): string { - return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn()); + return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.', $dsn->getScheme().'://'.$dsn->getHost()); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/PropertyAccess/PropertyAccessor.php symfony-6.4.50+dfsg/src/Symfony/Component/PropertyAccess/PropertyAccessor.php --- symfony-6.4.49+dfsg/src/Symfony/Component/PropertyAccess/PropertyAccessor.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/PropertyAccess/PropertyAccessor.php 2026-11-04 11:23:00.000000000 +0000 @@ -194,12 +194,12 @@ if (!isset($trace[$i]['file']) || __FILE__ !== $trace[$i]['file']) { return; } - if (preg_match('/^\S+::\S+\(\): Argument #\d+ \(\$\S+\) must be of type (\S+), (\S+) given/', $message, $matches)) { + if (preg_match('/^(?:\S+(?:\0.*)?::\S+|\S+@anonymous)\(\): Argument #\d+ \(\$\S+\) must be of type (\S+), (\S+) given/s', $message, $matches)) { [, $expectedType, $actualType] = $matches; throw new InvalidArgumentException(\sprintf('Expected argument of type "%s", "%s" given at property path "%s".', $expectedType, 'NULL' === $actualType ? 'null' : $actualType, $propertyPath), 0, $previous); } - if (preg_match('/^Cannot assign (\S+) to property \S+::\$\S+ of type (\S+)$/', $message, $matches)) { + if (preg_match('/^Cannot assign (\S+) to property \S+(?:\0.*)?::\$\S+ of type (\S+)$/s', $message, $matches)) { [, $actualType, $expectedType] = $matches; throw new InvalidArgumentException(\sprintf('Expected argument of type "%s", "%s" given at property path "%s".', $expectedType, 'NULL' === $actualType ? 'null' : $actualType, $propertyPath), 0, $previous); @@ -449,7 +449,7 @@ } } catch (\Error $e) { // handle uninitialized properties in PHP >= 7.4 - if (preg_match('/^Typed property ([\w\\\\@]+)::\$(\w+) must not be accessed before initialization$/', $e->getMessage(), $matches) || preg_match('/^Cannot access uninitialized non-nullable property ([\w\\\\@]+)::\$(\w+) by reference$/', $e->getMessage(), $matches)) { + if (preg_match('/^Typed property ([\w\\\\@]+)(?:\0.*)?::\$(\w+) must not be accessed before initialization$/s', $e->getMessage(), $matches) || preg_match('/^Cannot access uninitialized non-nullable property ([\w\\\\@]+)(?:\0.*)?::\$(\w+) by reference$/s', $e->getMessage(), $matches)) { $r = new \ReflectionProperty(str_contains($matches[1], '@anonymous') ? $class : $matches[1], $matches[2]); $type = ($type = $r->getType()) instanceof \ReflectionNamedType ? $type->getName() : (string) $type; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php symfony-6.4.50+dfsg/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -208,6 +208,28 @@ $this->propertyAccessor->getValue($object, 'uninitialized'); } + public function testGetValueThrowsExceptionIfUninitializedPropertyOfAnonymousClassIsReadByReference() + { + $this->expectException(UninitializedPropertyException::class); + $this->expectExceptionMessage('The property "class@anonymous::$uninitialized" is not readable because it is typed "string". You should initialize it or declare a default value instead.'); + + $object = new class { + public string $uninitialized; + + public function __construct() + { + unset($this->uninitialized); + } + + public function &__get($name) + { + return $this->$name; + } + }; + + $this->propertyAccessor->getValue($object, 'uninitialized'); + } + public function testGetValueThrowsExceptionIfUninitializedNotNullableOfParentClass() { $this->expectException(UninitializedPropertyException::class); @@ -760,6 +782,39 @@ $this->propertyAccessor->setValue($object, 'countable', 'This is a string, \Countable expected.'); } + public function testThrowTypeErrorOnSetterOfAnonymousClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected argument of type "int", "string" given at property path "foo".'); + $object = new class { + public function setFoo(int $foo): void + { + } + }; + + $this->propertyAccessor->setValue($object, 'foo', 'bar'); + } + + public function testThrowTypeErrorOnPropertyOfAnonymousClassWithSpacesInItsName() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected argument of type "int", "string" given at property path "foo".'); + // the name of anonymous classes declared in eval()'d code contains spaces + $object = eval('return new class { public int $foo = 0; };'); + + $this->propertyAccessor->setValue($object, 'foo', 'bar'); + } + + public function testThrowTypeErrorOnSetterOfAnonymousClassWithSpacesInItsName() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected argument of type "int", "string" given at property path "foo".'); + // the name of anonymous classes declared in eval()'d code contains spaces + $object = eval('return new class { public function setFoo(int $foo): void {} };'); + + $this->propertyAccessor->setValue($object, 'foo', 'bar'); + } + public function testAnonymousClassRead() { $value = 'bar'; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -17,6 +17,7 @@ use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy; use Symfony\Component\PropertyInfo\Tests\Fixtures\DummyCollection; +use Symfony\Component\PropertyInfo\Tests\Fixtures\DummyIntersectionType; use Symfony\Component\PropertyInfo\Tests\Fixtures\Extractor\ChildOfParentUsingTrait; use Symfony\Component\PropertyInfo\Tests\Fixtures\Extractor\ChildOfParentWithPromotedSelfDocBlock; use Symfony\Component\PropertyInfo\Tests\Fixtures\Extractor\ChildWithConstructorOverride; @@ -445,6 +446,16 @@ $this->assertNull($this->extractor->getTypes(Dummy::class, 'genericInterface')); } + public function testIntersectionTypesAreIgnored() + { + $this->assertNull($this->extractor->getTypes(DummyIntersectionType::class, 'objects')); + $this->assertNull($this->extractor->getTypes(DummyIntersectionType::class, 'arrayShapes')); + $this->assertNull($this->extractor->getTypes(DummyIntersectionType::class, 'nullable')); + $this->assertNull($this->extractor->getTypes(DummyIntersectionType::class, 'orNull')); + $this->assertNull($this->extractor->getTypes(DummyIntersectionType::class, 'orString')); + $this->assertEquals([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)], $this->extractor->getTypes(DummyIntersectionType::class, 'collection')); + } + /** * @dataProvider constructorTypesProvider */ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyIntersectionType.php symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyIntersectionType.php --- symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyIntersectionType.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyIntersectionType.php 2026-11-04 11:23:00.000000000 +0000 @@ -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\PropertyInfo\Tests\Fixtures; + +class DummyIntersectionType +{ + /** + * @var \Traversable&\Countable + */ + public $objects; + + /** + * @var array{limit?: int, offset?: int}&array{name?: string} + */ + public $arrayShapes; + + /** + * @var ?(\Traversable&\Countable) + */ + public $nullable; + + /** + * @var (\Traversable&\Countable)|null + */ + public $orNull; + + /** + * @var (\Traversable&\Countable)|string + */ + public $orString; + + /** + * @var array<\Traversable&\Countable> + */ + public $collection; +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,7 +18,9 @@ use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\Collection; use phpDocumentor\Reflection\Types\Compound; +use phpDocumentor\Reflection\Types\Expression; use phpDocumentor\Reflection\Types\Integer; +use phpDocumentor\Reflection\Types\Intersection; use phpDocumentor\Reflection\Types\Mixed_; use phpDocumentor\Reflection\Types\Null_; use phpDocumentor\Reflection\Types\Nullable; @@ -58,6 +60,11 @@ $varType = $varType->getActualType(); } + if ($this->isIntersection($varType)) { + // It's safer to fall back to other extractors here, as resolving intersection types correctly is not easy at the moment + return []; + } + if ($varType instanceof Scalar) { return [ new Type(Type::BUILTIN_TYPE_BOOL), @@ -104,6 +111,10 @@ $type = $type->getActualType(); } + if ($this->isIntersection($type)) { + return []; + } + $varTypes[] = $type; } @@ -220,4 +231,9 @@ return ['object', ltrim($docType, '\\')]; } + + private function isIntersection(DocType $type): bool + { + return $type instanceof Intersection || ($type instanceof Expression && $type->getValueType() instanceof Intersection); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\RateLimiter\Policy; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\RateLimiter\Exception\InvalidIntervalException; use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; @@ -35,11 +36,15 @@ throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__)); } + $this->interval = TimeUtil::dateIntervalToSeconds($interval); + if ($this->interval < 1) { + throw new InvalidIntervalException(\sprintf('The interval must be positive integer, "%d" given.', $this->interval)); + } + $this->storage = $storage; $this->lock = $lock; $this->id = $id; $this->limit = $limit; - $this->interval = TimeUtil::dateIntervalToSeconds($interval); } public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/Rate.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/Rate.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/Rate.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/Rate.php 2026-11-04 11:23:00.000000000 +0000 @@ -35,6 +35,10 @@ throw new InvalidIntervalException('The refill interval must be at least one second.'); } + if ($refillAmount < 1) { + throw new \InvalidArgumentException(\sprintf('The refill amount must be a positive integer, "%d" given.', $refillAmount)); + } + $this->refillTime = $refillTime; $this->refillAmount = $refillAmount; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php 2026-11-04 11:23:00.000000000 +0000 @@ -39,6 +39,10 @@ public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null) { + if ($limit < 1) { + throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__)); + } + $this->storage = $storage; $this->lock = $lock; $this->id = $id; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\RateLimiter\Exception\InvalidIntervalException; use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter; use Symfony\Component\RateLimiter\Policy\Window; use Symfony\Component\RateLimiter\RateLimit; @@ -401,6 +402,33 @@ yield ['P1Y']; } + public function testRejectIntervalBelowOneSecond() + { + $this->expectException(InvalidIntervalException::class); + $this->expectExceptionMessage('The interval must be positive integer, "0" given.'); + + $this->createLimiter('PT0S'); + } + + public function testRejectNegativeInterval() + { + $interval = new \DateInterval('PT1S'); + $interval->invert = 1; + + $this->expectException(InvalidIntervalException::class); + $this->expectExceptionMessage('The interval must be positive integer, "-1" given.'); + + new FixedWindowLimiter('test', 10, $interval, $this->storage); + } + + public function testRejectSubsecondInterval() + { + $this->expectException(InvalidIntervalException::class); + $this->expectExceptionMessage('The interval must be positive integer, "0" given.'); + + new FixedWindowLimiter('test', 10, \DateInterval::createFromDateString('200 milliseconds'), $this->storage); + } + private function createLimiter(string $dateIntervalString = 'PT1M'): FixedWindowLimiter { return new FixedWindowLimiter('test', 10, new \DateInterval($dateIntervalString), $this->storage); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/RateTest.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/RateTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/RateTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/RateTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -60,6 +60,22 @@ new Rate(\DateInterval::createFromDateString('200 milliseconds')); } + public function testRejectZeroRefillAmount() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The refill amount must be a positive integer, "0" given.'); + + new Rate(new \DateInterval('PT1S'), 0); + } + + public function testRejectNegativeRefillAmount() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The refill amount must be a positive integer, "-1" given.'); + + new Rate(new \DateInterval('PT1S'), -1); + } + public function testCalculateTimeForTokensBelowTheCapIsNotChanged() { self::assertSame(2147483647, Rate::perSecond(1)->calculateTimeForTokens(2147483647)); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -202,6 +202,22 @@ $this->assertSame(98, $rateLimit->getRemainingTokens()); } + public function testRejectLimitBelowOne() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set the limit of "Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter" to 0, as that would never accept any hit.'); + + new SlidingWindowLimiter('test', 0, new \DateInterval('PT12S'), $this->storage); + } + + public function testRejectNegativeLimit() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set the limit of "Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter" to 0, as that would never accept any hit.'); + + new SlidingWindowLimiter('test', -1, new \DateInterval('PT12S'), $this->storage); + } + private function createLimiter(): SlidingWindowLimiter { return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Matcher/Dumper/CompiledUrlMatcherDumper.php symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Matcher/Dumper/CompiledUrlMatcherDumper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Matcher/Dumper/CompiledUrlMatcherDumper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Matcher/Dumper/CompiledUrlMatcherDumper.php 2026-11-04 11:23:00.000000000 +0000 @@ -318,6 +318,17 @@ $state->vars = []; $hostRegex = '(?i:'.preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]).')\.'; $state->hostVars = $state->vars; + + if ($state->hostVars) { + // Group 1 captures the path so that host variables cannot match any part of it. + // Group 1 is mapped to the first host variable, then the group of that variable overwrites it. + // The first lookahead skips the capture when the host cannot match. + // It is left out when requirements contain named groups, because a regexp can't define the same name twice. + $hostCheck = preg_replace('#\?P<[^>]++>#', '?:', $rx[1]); + $hostCheck = preg_match('#\(\?(?:<[^=!]|\')#', $hostCheck) ? '' : '(?=(?i:'.$hostCheck.')\.)'; + $hostRegex = $hostCheck.'(?=[^/]*+(.*+))'.$hostRegex.'(?=\1)'; + array_unshift($state->hostVars, $state->hostVars[0]); + } } else { $hostRegex = '(?:(?:[^./]*+\.)++)'; $state->hostVars = []; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher1.php symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher1.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher1.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher1.php 2026-11-04 11:23:00.000000000 +0000 @@ -55,20 +55,20 @@ .'|(*:287)' .')' .'|/aba/([^/]++)(*:309)' - .')|(?i:([^\\.]++)\\.example\\.com)\\.(?' + .')|(?=(?i:(?:[^\\.]++)\\.example\\.com)\\.)(?=[^/]*+(.*+))(?i:([^\\.]++)\\.example\\.com)\\.(?=\\1)(?' .'|/route1(?' - .'|3/([^/]++)(*:371)' - .'|4/([^/]++)(*:389)' + .'|3/([^/]++)(*:428)' + .'|4/([^/]++)(*:446)' .')' .')|(?i:c\\.example\\.com)\\.(?' - .'|/route15/([^/]++)(*:441)' + .'|/route15/([^/]++)(*:498)' .')|(?:(?:[^./]*+\\.)++)(?' - .'|/route16/([^/]++)(*:489)' + .'|/route16/([^/]++)(*:546)' .'|/a/(?' - .'|a\\.\\.\\.(*:510)' + .'|a\\.\\.\\.(*:567)' .'|b/(?' - .'|([^/]++)(*:531)' - .'|c/([^/]++)(*:549)' + .'|([^/]++)(*:588)' + .'|c/([^/]++)(*:606)' .')' .')' .')' @@ -93,13 +93,13 @@ 279 => [[['_route' => 'foo3'], ['_locale', 'foo'], null, null, false, true, null]], 287 => [[['_route' => 'bar3'], ['_locale', 'bar'], null, null, false, true, null]], 309 => [[['_route' => 'foo4'], ['foo'], null, null, false, true, null]], - 371 => [[['_route' => 'route13'], ['var1', 'name'], null, null, false, true, null]], - 389 => [[['_route' => 'route14', 'var1' => 'val'], ['var1', 'name'], null, null, false, true, null]], - 441 => [[['_route' => 'route15'], ['name'], null, null, false, true, null]], - 489 => [[['_route' => 'route16', 'var1' => 'val'], ['name'], null, null, false, true, null]], - 510 => [[['_route' => 'a'], [], null, null, false, false, null]], - 531 => [[['_route' => 'b'], ['var'], null, null, false, true, null]], - 549 => [ + 428 => [[['_route' => 'route13'], ['var1', 'var1', 'name'], null, null, false, true, null]], + 446 => [[['_route' => 'route14', 'var1' => 'val'], ['var1', 'var1', 'name'], null, null, false, true, null]], + 498 => [[['_route' => 'route15'], ['name'], null, null, false, true, null]], + 546 => [[['_route' => 'route16', 'var1' => 'val'], ['name'], null, null, false, true, null]], + 567 => [[['_route' => 'a'], [], null, null, false, false, null]], + 588 => [[['_route' => 'b'], ['var'], null, null, false, true, null]], + 606 => [ [['_route' => 'c'], ['var'], null, null, false, true, null], [null, null, null, null, false, false, 0], ], diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher13.php symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher13.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher13.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher13.php 2026-11-04 11:23:00.000000000 +0000 @@ -11,17 +11,17 @@ ], [ // $regexpList 0 => '{^(?' - .'|(?i:([^\\.]++)\\.exampple\\.com)\\.(?' + .'|(?=(?i:(?:[^\\.]++)\\.exampple\\.com)\\.)(?=[^/]*+(.*+))(?i:([^\\.]++)\\.exampple\\.com)\\.(?=\\1)(?' .'|/abc([^/]++)(?' - .'|(*:56)' + .'|(*:114)' .')' .')' .')/?$}sD', ], [ // $dynamicRoutes - 56 => [ - [['_route' => 'r1'], ['foo', 'foo'], null, null, false, true, null], - [['_route' => 'r2'], ['foo', 'foo'], null, null, false, true, null], + 114 => [ + [['_route' => 'r1'], ['foo', 'foo', 'foo'], null, null, false, true, null], + [['_route' => 'r2'], ['foo', 'foo', 'foo'], null, null, false, true, null], [null, null, null, null, false, false, 0], ], ], diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher2.php symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher2.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher2.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Fixtures/dumper/compiled_url_matcher2.php 2026-11-04 11:23:00.000000000 +0000 @@ -57,20 +57,20 @@ .'|(*:287)' .')' .'|/aba/([^/]++)(*:309)' - .')|(?i:([^\\.]++)\\.example\\.com)\\.(?' + .')|(?=(?i:(?:[^\\.]++)\\.example\\.com)\\.)(?=[^/]*+(.*+))(?i:([^\\.]++)\\.example\\.com)\\.(?=\\1)(?' .'|/route1(?' - .'|3/([^/]++)(*:371)' - .'|4/([^/]++)(*:389)' + .'|3/([^/]++)(*:428)' + .'|4/([^/]++)(*:446)' .')' .')|(?i:c\\.example\\.com)\\.(?' - .'|/route15/([^/]++)(*:441)' + .'|/route15/([^/]++)(*:498)' .')|(?:(?:[^./]*+\\.)++)(?' - .'|/route16/([^/]++)(*:489)' + .'|/route16/([^/]++)(*:546)' .'|/a/(?' - .'|a\\.\\.\\.(*:510)' + .'|a\\.\\.\\.(*:567)' .'|b/(?' - .'|([^/]++)(*:531)' - .'|c/([^/]++)(*:549)' + .'|([^/]++)(*:588)' + .'|c/([^/]++)(*:606)' .')' .')' .')' @@ -95,13 +95,13 @@ 279 => [[['_route' => 'foo3'], ['_locale', 'foo'], null, null, false, true, null]], 287 => [[['_route' => 'bar3'], ['_locale', 'bar'], null, null, false, true, null]], 309 => [[['_route' => 'foo4'], ['foo'], null, null, false, true, null]], - 371 => [[['_route' => 'route13'], ['var1', 'name'], null, null, false, true, null]], - 389 => [[['_route' => 'route14', 'var1' => 'val'], ['var1', 'name'], null, null, false, true, null]], - 441 => [[['_route' => 'route15'], ['name'], null, null, false, true, null]], - 489 => [[['_route' => 'route16', 'var1' => 'val'], ['name'], null, null, false, true, null]], - 510 => [[['_route' => 'a'], [], null, null, false, false, null]], - 531 => [[['_route' => 'b'], ['var'], null, null, false, true, null]], - 549 => [ + 428 => [[['_route' => 'route13'], ['var1', 'var1', 'name'], null, null, false, true, null]], + 446 => [[['_route' => 'route14', 'var1' => 'val'], ['var1', 'var1', 'name'], null, null, false, true, null]], + 498 => [[['_route' => 'route15'], ['name'], null, null, false, true, null]], + 546 => [[['_route' => 'route16', 'var1' => 'val'], ['name'], null, null, false, true, null]], + 567 => [[['_route' => 'a'], [], null, null, false, false, null]], + 588 => [[['_route' => 'b'], ['var'], null, null, false, true, null]], + 606 => [ [['_route' => 'c'], ['var'], null, null, false, true, null], [null, null, null, null, false, false, 0], ], diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -939,6 +939,70 @@ $this->assertEquals('b', $matcher->match('/bar/abc.123')['_route']); } + public function testHostVariableDoesNotMatchThePath() + { + $coll = new RouteCollection(); + $coll->add('a', new Route('/admin/report/{id}', [], [], [], 'app.{domain}')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app.localhost')); + $this->assertEquals(['_route' => 'a', 'domain' => 'localhost', 'id' => '1'], $matcher->match('/admin/report/1')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app')); + + $this->expectException(ResourceNotFoundException::class); + $matcher->match('/x./admin/report/1'); + } + + public function testHostVariableWithPermissiveRequirementDoesNotMatchThePath() + { + $coll = new RouteCollection(); + $coll->add('a', new Route('/admin/report/{id}', [], ['domain' => '.+'], [], 'app.{domain}')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app.example.com')); + $this->assertEquals(['_route' => 'a', 'domain' => 'example.com', 'id' => '1'], $matcher->match('/admin/report/1')); + + $this->expectException(ResourceNotFoundException::class); + $matcher->match('/x./admin/report/1'); + } + + public function testHostVariableWithPermissiveRequirementMatchesTheHostOnly() + { + $coll = new RouteCollection(); + $coll->add('a', new Route('/{path}', [], ['domain' => '.+', 'path' => '.+'], [], 'app.{domain}')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app.example.com')); + $this->assertEquals(['_route' => 'a', 'domain' => 'example.com', 'path' => 'foo./bar'], $matcher->match('/foo./bar')); + } + + public function testHostVariableWithNamedGroupInRequirementDoesNotMatchThePath() + { + $coll = new RouteCollection(); + $coll->add('a', new Route('/admin/report/{id}', [], ['domain' => '(?.+)'], [], 'app.{domain}')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app.example.com')); + $parameters = $matcher->match('/admin/report/1'); + $this->assertSame('a', $parameters['_route']); + $this->assertSame('example.com', $parameters['domain']); + + $this->expectException(ResourceNotFoundException::class); + $matcher->match('/x./admin/report/1'); + } + + public function testStaticRouteWithHostVariableDoesNotMatchThePath() + { + $coll = new RouteCollection(); + $coll->add('a', new Route('/admin/report/{id}', [], [], [], 'app.{domain}', [], ['POST'])); + $coll->add('b', new Route('/admin/report/latest', [], [], [], 'app.{domain}')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app.localhost')); + $this->assertEquals(['_route' => 'b', 'domain' => 'localhost'], $matcher->match('/admin/report/latest')); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'GET', 'app')); + + $this->expectException(ResourceNotFoundException::class); + $matcher->match('/x./admin/report/latest'); + } + public function testSlashVariant() { $coll = new RouteCollection(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Authorization/Voter/Voter.php 2026-11-04 11:23:00.000000000 +0000 @@ -35,7 +35,7 @@ continue; } } catch (\TypeError $e) { - if (str_contains($e->getMessage(), 'supports(): Argument #1')) { + if ('supports' === ($e->getTrace()[0]['function'] ?? null) && str_contains($e->getMessage(), '(): Argument #1 (') && str_contains($e->getMessage(), ', called in '.__FILE__.' on line ')) { continue; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Resources/translations/security.vi.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Resources/translations/security.vi.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Resources/translations/security.vi.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Resources/translations/security.vi.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -64,7 +64,7 @@ Too many failed login attempts, please try again later. - Đăng nhập sai quá nhiều lần, vui lòng thử lại lần nữa. + Đăng nhập sai quá nhiều lần, vui lòng thử lại sau. Invalid or expired login link. @@ -76,7 +76,7 @@ Too many failed login attempts, please try again in %minutes% minutes. - Quá nhiều lần đăng nhập không thành công, vui lòng thử lại sau %minutes% phút.|Quá nhiều lần đăng nhập không thành công, vui lòng thử lại sau %minutes% phút. + Quá nhiều lần thử đăng nhập không thành công, vui lòng thử lại sau %minutes% phút. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Core/Tests/Authorization/Voter/VoterTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -71,6 +71,34 @@ $voter = new TypeErrorVoterTest_Voter(); $voter->vote($this->token, new \stdClass(), ['EDIT']); } + + public function testVoteWithNonStringAttributeOnAnonymousVoter() + { + $voter = new class extends VoterTest_Voter { + protected function supports(string $attribute, $object): bool + { + return true; + } + }; + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token, new \stdClass(), [new \stdClass()])); + } + + public function testVoteWithTypeErrorFromNestedSupportsCall() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('VoterTest_Voter::supports(): Argument #1 ($attribute) must be of type string, stdClass given'); + + (new NestedTypeErrorVoterTest_Voter())->vote($this->token, new \stdClass(), ['EDIT']); + } + + public function testVoteWithTypeErrorFromNestedVote() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('VoterTest_Voter::voteOnAttribute(): Argument #1 ($attribute) must be of type string, stdClass given'); + + (new NestedVoteVoterTest_Voter())->vote($this->token, new \stdClass(), ['EDIT']); + } } class VoterTest_Voter extends Voter @@ -119,3 +147,27 @@ return 'EDIT'; } } + +class NestedTypeErrorVoterTest_Voter extends VoterTest_Voter +{ + protected function supports(string $attribute, $object): bool + { + return parent::supports($object, $attribute); + } +} + +class NestedVoteVoterTest_Voter extends VoterTest_Voter +{ + protected function supports(string $attribute, $object): bool + { + return VoterInterface::ACCESS_GRANTED === (new WidenedSupportsVoterTest_Voter())->vote(new NullToken(), $object, [new \stdClass()]); + } +} + +class WidenedSupportsVoterTest_Voter extends VoterTest_Voter +{ + protected function supports($attribute, $object): bool + { + return true; + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php 2026-11-04 11:23:00.000000000 +0000 @@ -82,6 +82,8 @@ // announce the authentication token $token = $this->eventDispatcher->dispatch(new AuthenticationTokenCreatedEvent($token, $passport))->getAuthenticatedToken(); + $this->eventDispatcher->dispatch(new AuthenticationSuccessEvent($token), AuthenticationEvents::AUTHENTICATION_SUCCESS); + // authenticate this in the system return $this->handleAuthenticationSuccess($token, $passport, $request, $authenticator, $this->tokenStorage->getToken()); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationFailureHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -83,7 +83,7 @@ $failureUrl = ParameterBagUtils::getRequestParameterValue($request, $options['failure_path_parameter']); - if (\is_string($failureUrl) && (str_starts_with($failureUrl, '/') || str_starts_with($failureUrl, 'http'))) { + if (\is_string($failureUrl) && (str_starts_with($failureUrl, '/') || (str_starts_with($failureUrl, 'http') && \is_string(parse_url($failureUrl, \PHP_URL_SCHEME)) && \is_string(parse_url($failureUrl, \PHP_URL_HOST))))) { $options['failure_path'] = $failureUrl; } elseif ($this->logger && $failureUrl) { $this->logger->debug(\sprintf('Ignoring query parameter "%s": not a valid URL.', $options['failure_path_parameter'])); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authentication/DefaultAuthenticationSuccessHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -94,7 +94,7 @@ $targetUrl = ParameterBagUtils::getRequestParameterValue($request, $this->options['target_path_parameter']); - if (\is_string($targetUrl) && (str_starts_with($targetUrl, '/') || str_starts_with($targetUrl, 'http'))) { + if (\is_string($targetUrl) && (str_starts_with($targetUrl, '/') || (str_starts_with($targetUrl, 'http') && \is_string(parse_url($targetUrl, \PHP_URL_SCHEME)) && \is_string(parse_url($targetUrl, \PHP_URL_HOST))))) { return $targetUrl; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authenticator/LoginLinkAuthenticator.php 2026-11-04 11:23:00.000000000 +0000 @@ -54,9 +54,13 @@ public function authenticate(Request $request): Passport { - if (!$username = $request->get('user')) { + $username = $request->get('user'); + if ('' === ($username ?? '')) { throw new InvalidLoginLinkAuthenticationException('Missing user from link.'); } + if (!\is_string($username)) { + throw new InvalidLoginLinkAuthenticationException('Invalid user from link.'); + } $userBadge = new UserBadge($username, function () use ($request) { try { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Authenticator/X509Authenticator.php 2026-11-04 11:23:00.000000000 +0000 @@ -46,17 +46,8 @@ $username = null; if ($request->server->has($this->userKey)) { $username = $request->server->get($this->userKey); - } elseif ( - $request->server->has($this->credentialsKey) - && preg_match( - 'emailAddress' === $this->credentialUserIdentifier - ? '#(?:^|[,/])\s*(?:emailAddress|1\.2\.840\.113549\.1\.9\.1)=([^,/@]++@[^,/]++)#' - : '#(?:^|[,/])\s*'.preg_quote($this->credentialUserIdentifier, '#').'=([^,/]++)#', - $request->server->get($this->credentialsKey), - $matches, - ) - ) { - $username = trim($matches[1]); + } elseif (\is_string($dn = $request->server->get($this->credentialsKey))) { + $username = $this->extractUsernameFromDn($dn); } if (null === $username) { @@ -65,4 +56,77 @@ return $username; } + + private function extractUsernameFromDn(string $dn): ?string + { + $pattern = 'emailAddress' === $this->credentialUserIdentifier + ? '#^\s*(?:emailAddress|1\.2\.840\.113549\.1\.9\.1)=([^,/@]++@[^,/]++)$#D' + : '#^\s*'.preg_quote($this->credentialUserIdentifier, '#').'=([^,/]++)$#D'; + + foreach (self::splitDn($dn) as $rdn) { + if (preg_match($pattern, $rdn, $matches)) { + return trim($matches[1]); + } + } + + return null; + } + + /** + * Splits a serialized DN into its RDNs, unescaping each of them. + * + * RFC 2253, used by Apache and nginx by default, separates RDNs with commas and + * escapes special characters inside values with a backslash or as \HH. It also + * allows quoting values instead, as OpenSSL does with XN_FLAG_ONELINE. + * + * X509_NAME_oneline(), used by Apache's LegacyDNStringFormat and by nginx's + * $ssl_client_s_dn_legacy, prefixes the DN with a slash and separates RDNs with + * slashes. It escapes non-printable bytes as \xHH and, since OpenSSL 3, slashes and + * plus signs as \/ and \+. It never escapes a backslash, so any other backslash is + * a literal one. Older OpenSSL versions leave slashes inside values bare, which + * makes their output ambiguous: no parser can tell such a slash from a separator. + * + * Splitting happens on unescaped separators only, and each RDN is unescaped + * afterwards, so that a separator inside a value can never pose as a boundary. + * + * @return string[] + */ + private static function splitDn(string $dn): array + { + $legacy = str_starts_with($dn, '/'); + $separator = $legacy ? '/' : ','; + $rdns = []; + $rdn = ''; + $quoted = false; + + for ($i = $legacy ? 1 : 0, $length = \strlen($dn); $i < $length; ++$i) { + $char = $dn[$i]; + + if ($separator === $char && !$quoted) { + $rdns[] = $rdn; + $rdn = ''; + } elseif ('"' === $char && !$legacy) { + $quoted = !$quoted; + } elseif ('\\' !== $char || $i + 1 === $length) { + $rdn .= $char; + } elseif (!$legacy) { + if (2 === strspn(substr($dn, $i + 1, 2), '0123456789abcdefABCDEF')) { + $rdn .= \chr(hexdec(substr($dn, $i + 1, 2))); + $i += 2; + } else { + $rdn .= $dn[++$i]; + } + } elseif ('x' === $dn[$i + 1] && 2 === strspn(substr($dn, $i + 2, 2), '0123456789abcdefABCDEF')) { + $rdn .= \chr(hexdec(substr($dn, $i + 2, 2))); + $i += 3; + } elseif ('/' === $dn[$i + 1] || '+' === $dn[$i + 1]) { + $rdn .= $dn[++$i]; + } else { + $rdn .= $char; + } + } + $rdns[] = $rdn; + + return $rdns; + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php 2026-11-04 11:23:00.000000000 +0000 @@ -99,9 +99,10 @@ if (!$expires = $request->get('expires')) { throw new InvalidLoginLinkException('Missing "expires" parameter.'); } - if (!\is_string($expires) || !preg_match('/^\d+$/', $expires)) { + if (!\is_string($expires) || !preg_match('/^\d+$/D', $expires) || false === filter_var($expires, \FILTER_VALIDATE_INT)) { throw new InvalidLoginLinkException('Invalid "expires" parameter.'); } + $expires = (int) $expires; try { $this->signatureHasher->acceptSignatureHash($userIdentifier, $expires, $hash); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php 2026-11-04 11:23:00.000000000 +0000 @@ -46,6 +46,9 @@ if (false === $cookieParts[1] = base64_decode(strtr($cookieParts[1], '-_~', '+/='), true)) { throw new AuthenticationException('The user identifier contains a character from outside the base64 alphabet.'); } + if (false === $cookieParts[2] = filter_var($cookieParts[2], \FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]])) { + throw new AuthenticationException('The cookie contains invalid data.'); + } $cookieParts[0] = strtr($cookieParts[0], '.', '\\'); return new static(...$cookieParts); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/AuthenticatorManagerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -21,9 +21,12 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\AuthenticationEvents; +use Symfony\Component\Security\Core\Exception\AccountExpiredException; use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\InMemoryUser; +use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; @@ -34,6 +37,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Event\AuthenticationTokenCreatedEvent; use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\EventListener\UserCheckerListener; use Symfony\Component\Security\Http\Tests\Fixtures\DummySupportsAuthenticator; class AuthenticatorManagerTest extends TestCase @@ -271,6 +275,28 @@ $this->assertSame($modifiedToken, $this->tokenStorage->getToken()); } + public function testAuthenticateUserRunsThePostAuthCheckOfTheUserChecker() + { + $authenticator = $this->createMock(TestInteractiveAuthenticator::class); + $authenticator->method('createToken')->willReturn($this->token); + $authenticator->expects($this->never())->method('onAuthenticationSuccess'); + + $userChecker = $this->createMock(UserCheckerInterface::class); + $userChecker->expects($this->once())->method('checkPostAuth')->with($this->user)->willThrowException(new AccountExpiredException()); + $this->eventDispatcher->addListener(AuthenticationEvents::AUTHENTICATION_SUCCESS, [new UserCheckerListener($userChecker), 'postCheckCredentials']); + + $tokenStorage = new TokenStorage(); + $manager = new AuthenticatorManager([$authenticator], $tokenStorage, $this->eventDispatcher, 'main'); + + try { + $manager->authenticateUser($this->user, $authenticator, $this->request); + $this->fail('The user checker should have rejected the user.'); + } catch (AccountExpiredException $e) { + } + + $this->assertNull($tokenStorage->getToken()); + } + public function testInteractiveAuthenticator() { $authenticator = $this->createStub(TestInteractiveAuthenticator::class); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationFailureHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -202,6 +202,29 @@ $handler->onAuthenticationFailure($this->request, $this->exception); } + public function testFailurePathFromRequestWithoutHostIsIgnored() + { + foreach (['http', 'httpx', 'https:', 'http:///some-path', 'http:80', 'httpx:8080'] as $failurePath) { + $this->request->attributes->set('_failure_path', $failurePath); + + $handler = new DefaultAuthenticationFailureHandler($this->createStub(HttpKernelInterface::class), new HttpUtils(), [], new NullLogger()); + $result = $handler->onAuthenticationFailure($this->request, $this->exception); + + $this->assertSame('https://localhost/login', $result->getTargetUrl()); + } + } + + public function testCustomHttpSchemeRedirectionFromRequest() + { + foreach (['http+app://client/return', 'httpx://localhost/some-path', 'https+app://client/return'] as $failurePath) { + $this->request->attributes->set('_failure_path', $failurePath); + + $handler = new DefaultAuthenticationFailureHandler($this->createStub(HttpKernelInterface::class), new HttpUtils()); + + $this->assertSame($failurePath, $handler->onAuthenticationFailure($this->request, $this->exception)->getTargetUrl()); + } + } + public function testAbsoluteUrlRedirectionFromRequest() { $options = ['failure_path_parameter' => '_my_failure_path']; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authentication/DefaultAuthenticationSuccessHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -172,6 +172,33 @@ $handler->onAuthenticationSuccess($request, new NullToken()); } + public function testTargetPathFromRequestWithoutHostIsIgnored() + { + foreach (['http', 'httpx', 'https:', 'http:///some-path', 'http:80', 'httpx:8080'] as $targetPath) { + $request = Request::create('/'); + $request->attributes->set('_target_path', $targetPath); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->never())->method('generate'); + + $handler = new DefaultAuthenticationSuccessHandler(new HttpUtils($urlGenerator)); + + $this->assertSame('http://localhost/', $handler->onAuthenticationSuccess($request, new NullToken())->getTargetUrl()); + } + } + + public function testTargetPathWithCustomHttpSchemeFromRequest() + { + foreach (['http+app://client/return', 'httpx://localhost/some-path', 'https+app://client/return'] as $targetPath) { + $request = Request::create('/'); + $request->attributes->set('_target_path', $targetPath); + + $handler = new DefaultAuthenticationSuccessHandler(new HttpUtils()); + + $this->assertSame($targetPath, $handler->onAuthenticationSuccess($request, new NullToken())->getTargetUrl()); + } + } + public function testTargetPathWithAbsoluteUrlFromRequest() { $options = ['target_path_parameter' => '_my_target_path']; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/LoginLinkAuthenticatorTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/LoginLinkAuthenticatorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/LoginLinkAuthenticatorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/LoginLinkAuthenticatorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -106,6 +106,31 @@ (new LoginLinkAuthenticator($loginLinkHandler, new HttpUtils(), $this->successHandler, $this->failureHandler, []))->authenticate($request); } + public function testArrayUser() + { + $request = Request::create('/login/link/check?user[]=alice'); + $loginLinkHandler = $this->createMock(LoginLinkHandlerInterface::class); + $loginLinkHandler->expects($this->never())->method('consumeLoginLink'); + + $this->expectException(InvalidLoginLinkAuthenticationException::class); + $this->expectExceptionMessage('Invalid user from link.'); + + (new LoginLinkAuthenticator($loginLinkHandler, new HttpUtils(), $this->successHandler, $this->failureHandler, []))->authenticate($request); + } + + public function testUserWithZeroIdentifier() + { + $request = Request::create('/login/link/check?user=0'); + $user = new InMemoryUser('0', 'password'); + $loginLinkHandler = $this->createMock(LoginLinkHandlerInterface::class); + $loginLinkHandler->expects($this->once())->method('consumeLoginLink')->with($request)->willReturn($user); + + $passport = (new LoginLinkAuthenticator($loginLinkHandler, new HttpUtils(), $this->successHandler, $this->failureHandler, []))->authenticate($request); + + $this->assertSame('0', $passport->getBadge(UserBadge::class)->getUserIdentifier()); + $this->assertSame($user, $passport->getUser()); + } + public function testPassportBadges() { $this->setUpAuthenticator(); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/Authenticator/X509AuthenticatorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -74,15 +74,24 @@ public static function provideServerVarsNoUser() { - yield ['cert@example.com', 'CN=Sample certificate DN/emailAddress=cert@example.com']; - yield ['cert+something@example.com', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; + yield ['cert@example.com', '/CN=Sample certificate DN/emailAddress=cert@example.com']; + yield ['cert+something@example.com', '/CN=Sample certificate DN/emailAddress=cert+something@example.com']; yield ['cert@example.com', 'CN=Sample certificate DN,emailAddress=cert@example.com']; yield ['cert+something@example.com', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; yield ['cert+something@example.com', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; yield ['cert+something@example.com', 'emailAddress=cert+something@example.com']; yield ['firstname.lastname@mycompany.co.uk', 'emailAddress=firstname.lastname@mycompany.co.uk,CN=Firstname.Lastname,OU=london,OU=company design and engineering,OU=Issuer London,OU=Roaming,OU=Interactive,OU=Users,OU=Standard,OU=Business,DC=england,DC=core,DC=company,DC=co,DC=uk']; yield ['cert@example.com', '1.2.840.113549.1.9.1=cert@example.com,CN=Sample certificate DN']; - yield ['cert@example.com', 'CN=Sample certificate DN/1.2.840.113549.1.9.1=cert@example.com']; + yield ['cert@example.com', '/CN=Sample certificate DN/1.2.840.113549.1.9.1=cert@example.com']; + + yield 'escaped separator in a preceding value' => ['cert@example.com', 'CN=Sample\, certificate DN,emailAddress=cert@example.com']; + yield 'genuine attribute wins over one embedded in a CN' => ['cert@example.com', 'CN=jdoe\,emailAddress=admin@target.example,emailAddress=cert@example.com']; + yield 'slash inside a RFC 2253 value' => ['cert@example.com', 'CN=Sample/certificate DN,emailAddress=cert@example.com']; + yield 'escaped separator in a preceding value, legacy format' => ['cert@example.com', '/CN=Sample\/certificate DN/emailAddress=cert@example.com']; + yield 'hex escape in a preceding value' => ['cert@example.com', 'CN=Sample\2C certificate DN,emailAddress=cert@example.com']; + yield 'backslash then escaped slash in a preceding legacy value' => ['cert@example.com', '/CN=jdoe\\\\/emailAddress=admin@target.example/emailAddress=cert@example.com']; + yield 'hex escape in a preceding legacy value' => ['cert@example.com', '/CN=Sample\\x2Fcertificate DN/emailAddress=cert@example.com']; + yield 'quoted preceding value' => ['cert@example.com', 'CN="Sample, certificate DN",emailAddress=cert@example.com']; } /** @@ -101,6 +110,17 @@ yield ['CN=foo,OU=emailAddress=admin@target.example,O=Attacker']; yield ['CN=1.2.840.113549.1.9.1=admin@target.example,O=Attacker Corp']; yield ['CN=foo,OU=1.2.840.113549.1.9.1=admin@target.example,O=Attacker']; + + yield 'escaped comma' => ['CN=jdoe\,emailAddress=admin@target.example']; + yield 'escaped comma, OID' => ['CN=jdoe\,1.2.840.113549.1.9.1=admin@target.example']; + yield 'escaped comma before a legitimate attribute' => ['CN=jdoe\,emailAddress=admin@target.example,O=Attacker']; + yield 'hex escaped comma' => ['CN=jdoe\2CemailAddress=admin@target.example']; + yield 'slash in a RFC 2253 value' => ['CN=jdoe/emailAddress=admin@target.example']; + yield 'escaped slash in a legacy value' => ['/CN=jdoe\/emailAddress=admin@target.example']; + yield 'comma in a legacy value' => ['/CN=jdoe,emailAddress=admin@target.example']; + yield 'backslash then escaped slash in a legacy value' => ['/CN=jdoe\\\\/emailAddress=admin@target.example']; + yield 'quoted value' => ['CN="jdoe,emailAddress=admin@target.example,x=",O=Attacker']; + yield 'quoted value, XN_FLAG_ONELINE' => ['O = Attacker, CN = "jdoe,emailAddress=admin@target.example,x="']; } public function testSupportNoData() @@ -110,6 +130,13 @@ $this->assertFalse($this->authenticator->supports($request)); } + public function testSupportNonStringCredentials() + { + $request = $this->createRequest(['SSL_CLIENT_S_DN' => null]); + + $this->assertFalse($this->authenticator->supports($request)); + } + public function testAuthenticationCustomUserKey() { $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'TheUserKey'); @@ -130,7 +157,7 @@ $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'SSL_CLIENT_S_DN_Email', 'TheCertKey'); $request = $this->createRequest([ - 'TheCertKey' => 'CN=Sample certificate DN/emailAddress=cert@example.com', + 'TheCertKey' => '/CN=Sample certificate DN/emailAddress=cert@example.com', ]); $this->assertTrue($authenticator->supports($request)); @@ -160,8 +187,8 @@ public static function provideServerVarsUserIdentifier() { - yield ['Sample certificate DN', 'CN=Sample certificate DN/emailAddress=cert@example.com']; - yield ['Sample certificate DN', 'CN=Sample certificate DN/emailAddress=cert+something@example.com']; + yield ['Sample certificate DN', '/CN=Sample certificate DN/emailAddress=cert@example.com']; + yield ['Sample certificate DN', '/CN=Sample certificate DN/emailAddress=cert+something@example.com']; yield ['Sample certificate DN', 'CN=Sample certificate DN,emailAddress=cert@example.com']; yield ['Sample certificate DN', 'CN=Sample certificate DN,emailAddress=cert+something@example.com']; yield ['Sample certificate DN', 'emailAddress=cert+something@example.com,CN=Sample certificate DN']; @@ -169,6 +196,117 @@ yield ['user1', 'C=FR, O=My Organization, CN=user1, emailAddress=user1@myorg.fr']; } + /** + * @dataProvider provideDnsForCustomUserIdentifier + */ + public function testAuthenticationCustomCredentialsUserIdentifierSpansAWholeRdn(?string $username, string $dn) + { + $authenticator = new X509Authenticator($this->userProvider, new TokenStorage(), 'main', 'SSL_CLIENT_S_DN_Email', 'SSL_CLIENT_S_DN', null, 'UID'); + + $request = $this->createRequest(['SSL_CLIENT_S_DN' => $dn]); + + if (null === $username) { + $this->assertFalse($authenticator->supports($request)); + + return; + } + + $this->assertTrue($authenticator->supports($request)); + + $this->userProvider->createUser(new InMemoryUser($username, null)); + + $passport = $authenticator->authenticate($request); + $this->assertEquals($username, $passport->getUser()->getUserIdentifier()); + } + + public static function provideDnsForCustomUserIdentifier() + { + yield 'escaped comma in an O' => [null, 'C=US,O=Evil Co\,UID=admin,CN=RealAttacker']; + yield 'comma in a legacy O' => [null, '/C=US/O=Evil Co,UID=admin/CN=RealAttacker']; + yield 'slash in a RFC 2253 value' => [null, 'C=US,O=Evil Co/UID=admin,CN=RealAttacker']; + yield 'attribute embedded in a CN' => [null, 'CN=UID=admin,O=Attacker Corp']; + yield 'quoted O' => [null, 'C=US,O="Evil Co,UID=admin",CN=RealAttacker']; + + yield 'genuine attribute' => ['jdoe', 'C=US,O=Ex,UID=jdoe,CN=John Doe']; + yield 'genuine attribute, legacy format' => ['jdoe', '/C=US/O=Ex/UID=jdoe/CN=John Doe']; + yield 'genuine attribute wins over one embedded in an O' => ['jdoe', 'C=US,O=Evil Co\,UID=admin,UID=jdoe']; + yield 'surrounding spaces are trimmed' => ['jdoe', 'C=US, UID=jdoe , CN=John Doe']; + } + + /** + * @dataProvider provideDns + */ + public function testSplitDn(array $expected, string $dn) + { + $method = new \ReflectionMethod(X509Authenticator::class, 'splitDn'); + + $this->assertSame($expected, $method->invoke(null, $dn)); + } + + /** + * The serialized DNs are the output of X509_NAME_print_ex() with XN_FLAG_RFC2253 and of + * X509_NAME_oneline() from OpenSSL 3, for certificates whose CN holds the listed value. + */ + public static function provideDns() + { + yield 'empty' => [[''], '']; + yield 'single RDN' => [['emailAddress=cert@example.com'], 'emailAddress=cert@example.com']; + yield 'RFC 2253' => [['CN=jdoe', 'O=Ex', 'emailAddress=jdoe@example.com'], 'CN=jdoe,O=Ex,emailAddress=jdoe@example.com']; + yield 'legacy' => [['emailAddress=jdoe@example.com', 'O=Ex', 'CN=jdoe'], '/emailAddress=jdoe@example.com/O=Ex/CN=jdoe']; + yield 'legacy, one RDN' => [['CN=jdoe'], '/CN=jdoe']; + yield 'legacy, empty' => [[''], '/']; + + // CN "jdoe,emailAddress=admin@example.com" + yield 'RFC 2253, comma in a value' => [['CN=jdoe,emailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe\,emailAddress=admin@example.com,O=Ex']; + yield 'legacy, comma in a value' => [['O=Ex', 'CN=jdoe,emailAddress=admin@example.com'], '/O=Ex/CN=jdoe,emailAddress=admin@example.com']; + + // CN "jdoe/emailAddress=admin@example.com" + yield 'RFC 2253, slash in a value' => [['CN=jdoe/emailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe/emailAddress=admin@example.com,O=Ex']; + yield 'legacy, slash in a value' => [['O=Ex', 'CN=jdoe/emailAddress=admin@example.com'], '/O=Ex/CN=jdoe\/emailAddress=admin@example.com']; + + // CN "jdoe+emailAddress=admin@example.com" + yield 'RFC 2253, plus in a value' => [['CN=jdoe+emailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe\+emailAddress=admin@example.com,O=Ex']; + yield 'legacy, plus in a value' => [['O=Ex', 'CN=jdoe+emailAddress=admin@example.com'], '/O=Ex/CN=jdoe\+emailAddress=admin@example.com']; + + // CN "jdoe\,emailAddress=admin@example.com" + yield 'RFC 2253, backslash and comma in a value' => [['CN=jdoe\,emailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe\\\\\,emailAddress=admin@example.com,O=Ex']; + yield 'legacy, backslash and comma in a value' => [['O=Ex', 'CN=jdoe\,emailAddress=admin@example.com'], '/O=Ex/CN=jdoe\,emailAddress=admin@example.com']; + + // CN "jdoe\/emailAddress=admin@example.com" + yield 'RFC 2253, backslash and slash in a value' => [['CN=jdoe\/emailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe\\\\/emailAddress=admin@example.com,O=Ex']; + yield 'legacy, backslash and slash in a value' => [['O=Ex', 'CN=jdoe\/emailAddress=admin@example.com'], '/O=Ex/CN=jdoe\\\\/emailAddress=admin@example.com']; + + // CN "C:\" + yield 'RFC 2253, value ending with a backslash' => [['CN=C:\\', 'O=Ex'], 'CN=C:\\\\,O=Ex']; + yield 'legacy, value ending with a backslash' => [['O=Ex', 'CN=C:\\'], '/O=Ex/CN=C:\\']; + yield 'legacy, value ending with a backslash before a separator' => [['CN=C:\/O=Ex'], '/CN=C:\\\\/O=Ex']; + + // CN "jdoe\x2FemailAddress=admin@example.com" + yield 'RFC 2253, backslash x in a value' => [['CN=jdoe\x2FemailAddress=admin@example.com', 'O=Ex'], 'CN=jdoe\\\\x2FemailAddress=admin@example.com,O=Ex']; + + // CN "José tab\there" + yield 'RFC 2253, hex escapes' => [['CN=José tab'."\t".'here', 'O=Ex'], 'CN=Jos\C3\A9 tab\09here,O=Ex']; + yield 'RFC 2253, hex escapes, lowercase' => [['CN=José', 'O=Ex'], 'CN=Jos\c3\a9,O=Ex']; + yield 'legacy, hex escapes' => [['O=Ex', 'CN=José tab'."\t".'here'], '/O=Ex/CN=Jos\xC3\xA9 tab\x09here']; + yield 'legacy, hex escape of a slash' => [['O=Ex', 'CN=jdoe/emailAddress=admin@example.com'], '/O=Ex/CN=jdoe\x2FemailAddress=admin@example.com']; + yield 'legacy, backslash x without hex digits' => [['O=Ex', 'CN=jdoe\xZZ'], '/O=Ex/CN=jdoe\xZZ']; + + yield 'RFC 2253, trailing backslash' => [['CN=jdoe\\'], 'CN=jdoe\\']; + yield 'legacy, trailing backslash' => [['CN=jdoe\\'], '/CN=jdoe\\']; + yield 'RFC 2253, escaped separator at the end' => [['CN=jdoe,'], 'CN=jdoe\,']; + yield 'RFC 2253, empty RDNs' => [['', 'CN=jdoe', ''], ',CN=jdoe,']; + yield 'RFC 2253, spaces are kept' => [['CN=jdoe', ' O=Ex'], 'CN=jdoe, O=Ex']; + yield 'multi-valued RDN is kept whole' => [['O=Ex', 'emailAddress=jdoe@example.com+CN=jdoe'], 'O=Ex,emailAddress=jdoe@example.com+CN=jdoe']; + + // CN "jdoe,emailAddress=admin@example.com", from formats that quote values instead of escaping their characters + yield 'quoted value' => [['CN=jdoe,emailAddress=admin@example.com', 'O=Ex'], 'CN="jdoe,emailAddress=admin@example.com",O=Ex']; + yield 'quoted value, XN_FLAG_ONELINE' => [['O = Ex', ' CN = jdoe,emailAddress=admin@example.com'], 'O = Ex, CN = "jdoe,emailAddress=admin@example.com"']; + + // CN "jdoe\",emailAddress=admin@example.com" + yield 'escaped quote in a quoted value' => [['CN=jdoe",emailAddress=admin@example.com', 'O=Ex'], 'CN="jdoe\\",emailAddress=admin@example.com",O=Ex']; + yield 'legacy, quotes are literal' => [['O=Ex', 'CN="jdoe"', 'emailAddress=jdoe@example.com'], '/O=Ex/CN="jdoe"/emailAddress=jdoe@example.com']; + } + private function createRequest(array $server) { return new Request([], [], [], [], [], $server); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -282,6 +282,39 @@ $linker->consumeLoginLink($request); } + public function testConsumeLoginLinkWithOverflowingExpiration() + { + $this->expectException(InvalidLoginLinkException::class); + $this->expectExceptionMessage('Invalid "expires" parameter.'); + $request = Request::create('/login/verify?user=weaverryan&hash=thehash&expires=99999999999999999999'); + + $linker = $this->createLinker(); + $linker->consumeLoginLink($request); + } + + public function testConsumeLoginLinkWithExpirationAboveIntegerRange() + { + $this->expectException(InvalidLoginLinkException::class); + $this->expectExceptionMessage('Invalid "expires" parameter.'); + $expires = 8 === \PHP_INT_SIZE ? '9223372036854775808' : '2147483648'; + $request = Request::create('/login/verify?user=weaverryan&hash=thehash&expires='.$expires); + + $this->createLinker()->consumeLoginLink($request); + } + + public function testConsumeLoginLinkWithTrailingNewlineInExpiration() + { + $expires = time() + 500; + $signature = $this->createSignatureHash('weaverryan', $expires); + $request = Request::create(\sprintf('/login/verify?user=weaverryan&hash=%s&expires=%d%%0A', $signature, $expires)); + $this->userProvider->createUser(new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash')); + + $this->expectException(InvalidLoginLinkException::class); + $this->expectExceptionMessage('Invalid "expires" parameter.'); + + $this->createLinker()->consumeLoginLink($request); + } + public function testConsumeLoginLinkWithInvalidHash() { $user = new TestLoginLinkHandlerUser('weaverryan', 'ryan@symfonycasts.com', 'pwhash'); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/RememberMe/RememberMeDetailsTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/RememberMe/RememberMeDetailsTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Security/Http/Tests/RememberMe/RememberMeDetailsTest.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Security/Http/Tests/RememberMe/RememberMeDetailsTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,83 @@ + + * + * 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\RememberMe; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\RememberMe\RememberMeDetails; + +class RememberMeDetailsTest extends TestCase +{ + public function testFromRawCookie() + { + $details = RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:1700000000:value'); + + $this->assertSame('App\User', $details->getUserFqcn()); + $this->assertSame('user', $details->getUserIdentifier()); + $this->assertSame(1700000000, $details->getExpires()); + $this->assertSame('value', $details->getValue()); + } + + public function testFromRawCookieWithZeroExpires() + { + $details = RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:0:value'); + + $this->assertSame(0, $details->getExpires()); + } + + public function testFromRawCookieWithMaximumExpires() + { + $details = RememberMeDetails::fromRawCookie(base64_encode('App.User:dXNlcg~~:'.\PHP_INT_MAX.':value')); + + $this->assertSame(\PHP_INT_MAX, $details->getExpires()); + } + + public function testFromRawCookieWithEmptyExpires() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie contains invalid data.'); + + RememberMeDetails::fromRawCookie('App.User:dXNlcg~~::value'); + } + + public function testFromRawCookieWithNonNumericExpires() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie contains invalid data.'); + + RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:abc:value'); + } + + public function testFromRawCookieWithFloatExpires() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie contains invalid data.'); + + RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:1.5:value'); + } + + public function testFromRawCookieWithNegativeExpires() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie contains invalid data.'); + + RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:-1:value'); + } + + public function testFromRawCookieWithOverflowingExpires() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('The cookie contains invalid data.'); + + RememberMeDetails::fromRawCookie('App.User:dXNlcg~~:99999999999999999999:value'); + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Semaphore/Store/StoreFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Semaphore/Store/StoreFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Semaphore/Store/StoreFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Semaphore/Store/StoreFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -45,6 +45,6 @@ return new RedisStore($connection); } - throw new InvalidArgumentException(\sprintf('Unsupported Connection: "%s".', $connection)); + throw new InvalidArgumentException('No semaphore store supports the given DSN.'); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\Semaphore\Tests\Store; use PHPUnit\Framework\TestCase; +use Symfony\Component\Semaphore\Exception\InvalidArgumentException; use Symfony\Component\Semaphore\Store\RedisStore; use Symfony\Component\Semaphore\Store\StoreFactory; @@ -45,4 +46,15 @@ yield ['redis:?host[localhost]&host[localhost:6379]&redis_cluster=1', RedisStore::class]; } } + + public function testCreateStoreWithUnsupportedDsnDoesNotLeakCredentials() + { + try { + StoreFactory::createStore('unsupported://user:s3cr3t@localhost'); + $this->fail('An exception should have been thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('No semaphore store supports the given DSN.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php 2026-11-04 11:23:00.000000000 +0000 @@ -153,7 +153,7 @@ [$delimiter, $enclosure, $escapeChar, $keySeparator, , , , $asCollection] = $this->getCsvOptions($context); - while (false !== ($cols = fgetcsv($handle, 0, $delimiter, $enclosure, $escapeChar))) { + foreach ($this->readRows($handle, $delimiter, $enclosure, $escapeChar) as $cols) { $nbCols = \count($cols); if (null === $headers) { @@ -301,4 +301,28 @@ return $headers; } + + /** + * @param resource $handle + */ + private function readRows($handle, string $delimiter, string $enclosure, string $escapeChar): \Generator + { + $emptyLines = 0; + while ([null] === $cols = fgetcsv($handle, 0, $delimiter, $enclosure, $escapeChar)) { + ++$emptyLines; + } + + // fgetcsv() returns [null] for an empty line: it is a record with an empty value in a single-column document, and carries no data otherwise + $skipEmptyLines = false !== $cols && 1 < \count($cols); + + if (!$skipEmptyLines) { + yield from array_fill(0, $emptyLines, [null]); + } + + for (; false !== $cols; $cols = fgetcsv($handle, 0, $delimiter, $enclosure, $escapeChar)) { + if (!$skipEmptyLines || [null] !== $cols) { + yield $cols; + } + } + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php 2026-11-04 11:23:00.000000000 +0000 @@ -96,10 +96,21 @@ */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \SplFileInfo { - if (null === $data || !preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) { + if ($data instanceof \SplFileInfo && $data instanceof $type) { + return $data; + } + + if (!isset(self::SUPPORTED_TYPES[$type]) && is_a($type, \SplFileInfo::class, true)) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Data expected to be an instance of "%s" ("%s" given).', $type, get_debug_type($data)), $data, [$type], $context['deserialization_path'] ?? null, true); + } + + if (!\is_string($data) || !preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) { throw NotNormalizableValueException::createForUnexpectedDataType('The provided "data:" URI is not valid.', $data, ['string'], $context['deserialization_path'] ?? null, true); } + // PHP opens a "data:" URI as a local path when its scheme is not lowercase + $data = 'data:'.substr($data, 5); + try { switch ($type) { case File::class: @@ -107,6 +118,12 @@ throw new InvalidArgumentException(\sprintf('Cannot denormalize to a "%s" without the HttpFoundation component installed. Try running "composer require symfony/http-foundation".', File::class)); } + // \SplFileInfo trims trailing slashes, but they are part of the data + if (str_ends_with($data, '/') && false !== $content = @file_get_contents($data)) { + $mediaType = preg_replace('/;base64$/i', '', substr($data, 0, strpos($data, ','))); + $data = $mediaType.','.rawurlencode($content); + } + return new File($data, false); case 'SplFileObject': @@ -125,7 +142,7 @@ */ public function supportsDenormalization(mixed $data, string $type, ?string $format = null /* , array $context = [] */): bool { - return isset(self::SUPPORTED_TYPES[$type]); + return isset(self::SUPPORTED_TYPES[$type]) || is_a($type, \SplFileInfo::class, true); } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php 2026-11-04 11:23:00.000000000 +0000 @@ -171,6 +171,11 @@ } } + if ($this->isPropertyPath($attribute)) { + // a property path is writable only when the class metadata declares it as an attribute + return $this->classMetadataFactory && isset($this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata()[$attribute]); + } + $context = array_intersect_key($context, ['enable_getter_setter_extraction' => true, 'enable_magic_methods_extraction' => true, 'enable_constructor_extraction' => true, 'enable_adder_remover_extraction' => true]); $cacheKey = $class.'::'.$attribute.hash('xxh128', serialize($context)); @@ -178,7 +183,7 @@ return self::$isWritableCache[$cacheKey]; } - if ($this->isPropertyPath($attribute) || $this->propertyInfoExtractor->isWritable($class, $attribute, $context)) { + if ($this->propertyInfoExtractor->isWritable($class, $attribute, $context)) { return self::$isWritableCache[$cacheKey] = true; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Serializer.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Serializer.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Serializer.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Serializer.php 2026-11-04 11:23:00.000000000 +0000 @@ -378,7 +378,7 @@ foreach ($supportedTypes as $supportedType => $isCacheable) { if (\in_array($supportedType, ['*', 'object'], true) - || $class !== $supportedType && ('object' !== $genericType || !is_subclass_of($class, $supportedType)) + || $class !== $supportedType && ('object' !== $genericType || !is_a($class, $supportedType, true)) && !($doesClassRepresentCollection && str_ends_with($supportedType, '[]') && is_subclass_of(strstr($class, '[]', true), strstr($supportedType, '[]', true))) ) { continue; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -589,6 +589,43 @@ )); } + public function testDecodeSkipsEmptyLines() + { + $expected = [ + ['a' => '1', 'b' => '2'], + ['a' => '3', 'b' => '4'], + ]; + + $this->assertSame($expected, $this->encoder->decode("a,b\n1,2\n\n3,4\n", 'csv')); + $this->assertSame($expected, $this->encoder->decode("a,b\n1,2\n\n\n3,4\n", 'csv')); + $this->assertSame([['a' => '1', 'b' => '2']], $this->encoder->decode("a,b\n1,2\n\n", 'csv')); + } + + public function testDecodeSkipsEmptyLineBeforeHeader() + { + $this->assertSame([['a' => '1', 'b' => '2']], $this->encoder->decode("\na,b\n1,2\n", 'csv')); + } + + public function testDecodeWithoutHeaderSkipsEmptyLines() + { + $this->assertSame([['1', '2'], ['3', '4']], $this->encoder->decode("1,2\n\n3,4\n", 'csv', [CsvEncoder::NO_HEADERS_KEY => true])); + $this->assertSame([['1', '2'], ['3', '4']], $this->encoder->decode("\n1,2\n\n3,4\n", 'csv', [CsvEncoder::NO_HEADERS_KEY => true])); + } + + public function testDecodeKeepsEmptyLinesOfSingleColumnDocument() + { + $this->assertSame([['name' => 'alice'], ['name' => null], ['name' => 'bob'], ['name' => null]], $this->encoder->decode("name\nalice\n\nbob\n\n", 'csv')); + $this->assertSame([['name' => null]], $this->encoder->decode("name\n\n", 'csv')); + $this->assertSame([[null], ['x'], [null]], $this->encoder->decode("\nx\n\n", 'csv', [CsvEncoder::NO_HEADERS_KEY => true])); + } + + public function testEncodeDecodeSingleColumnWithEmptyValues() + { + $data = [['name' => null], ['name' => 'alice'], ['name' => null]]; + + $this->assertSame($data, $this->encoder->decode($this->encoder->encode($data, 'csv'), 'csv')); + } + public function testDecodeToManyRelation() { $expected = [ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Fixtures/property-path-mapping-without-groups.yaml symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Fixtures/property-path-mapping-without-groups.yaml --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Fixtures/property-path-mapping-without-groups.yaml 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Fixtures/property-path-mapping-without-groups.yaml 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,8 @@ +Symfony\Component\Serializer\Tests\Normalizer\ObjectOuter: + attributes: + inner.foo: + serialized_name: inner_foo +Symfony\Component\Serializer\Tests\Normalizer\ObjectWithArrayProperty: + attributes: + values[first]: + serialized_name: first_value diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/DataUriNormalizerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/DataUriNormalizerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/DataUriNormalizerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/DataUriNormalizerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,12 +12,21 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Mime\MimeTypeGuesserInterface; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DataUriNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; /** * @author Kévin Dunglas @@ -104,6 +113,23 @@ $this->assertEquals(file_get_contents(self::TEST_TXT_DATA), $this->getContent($file)); } + public function testGiveNotAccessToLocalFilesWithNonLowercaseScheme() + { + $file = tempnam(sys_get_temp_dir(), 'sf_data_uri_'); + file_put_contents($file, 'local file content'); + $path = str_repeat('/..', 32).strtr($file, '\\', '/'); + + try { + foreach (['DATA:', 'Data:', 'dAtA:'] as $scheme) { + $this->assertSame($path, $this->getContent($this->normalizer->denormalize($scheme.','.$path, \SplFileObject::class))); + $this->assertSame($path, $this->getContent($this->normalizer->denormalize($scheme.','.$path, \SplFileInfo::class)->openFile())); + $this->assertSame($path, $this->getContent($this->normalizer->denormalize($scheme.'text/plain,'.$path, File::class)->openFile())); + } + } finally { + unlink($file); + } + } + public function testDenormalizeHttpFoundationFile() { $file = $this->normalizer->denormalize(self::TEST_GIF_DATA, 'Symfony\Component\HttpFoundation\File\File'); @@ -126,6 +152,7 @@ yield [self::TEST_GIF_DATA, \SplFileObject::class, self::TEST_GIF_DATA]; yield [self::TEST_GIF_DATA, File::class, self::TEST_GIF_DATA]; yield [self::TEST_TXT_DATA, File::class, self::TEST_TXT_DATA]; + yield ['data:application/octet-stream;base64,YWJj////', File::class, 'data:application/octet-stream;base64,YWJj////']; yield ['data:,Hello%2C%20World!', \SplFileObject::class, 'data:text/plain,Hello%2C%20World%21']; yield ['data:;base64,SGVsbG8gV29ybGQh', \SplFileObject::class, 'data:text/plain,Hello%20World%21']; yield ['data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQh', \SplFileObject::class, 'data:text/plain;charset=utf-8,Hello%20World%21']; @@ -154,6 +181,27 @@ $this->assertSame('data:text/plain,Hello%20World%21', $this->normalizer->normalize(new \SplFileObject('data://text/plain;base64,SGVsbG8gV29ybGQh'))); } + public function testDenormalizeHttpFoundationFileEndingWithSlashes() + { + $file = $this->normalizer->denormalize('data:application/octet-stream;base64,YWJj////', File::class); + + $this->assertSame("abc\xFF\xFF\xFF", $file->getContent()); + $this->assertSame("abc\xFF\xFF\xFF", $this->getContent($file->openFile())); + + $file = $this->normalizer->denormalize('data:text/plain,a/b//', File::class); + + $this->assertSame('a/b//', $file->getContent()); + $this->assertSame('a/b//', $this->getContent($file->openFile())); + } + + public function testDenormalizeUndecodableHttpFoundationFileEndingWithSlashes() + { + $file = $this->normalizer->denormalize('data:text/plain;BASE64,YWJj////', File::class); + + $this->expectException(FileException::class); + @$file->getContent(); + } + public function testGiveNotAccessToLocalFiles() { $this->expectException(UnexpectedValueException::class); @@ -161,6 +209,119 @@ $this->normalizer->denormalize('/etc/shadow', 'SplFileObject'); } + public function testDenormalizeArray() + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('The provided "data:" URI is not valid.'); + $this->normalizer->denormalize(['path' => '/etc/shadow'], File::class); + } + + public function testSupportsDenormalizationOfSubclasses() + { + $this->assertTrue($this->normalizer->supportsDenormalization([], UploadedFile::class)); + $this->assertTrue($this->normalizer->supportsDenormalization([], FileChild::class)); + $this->assertTrue($this->normalizer->supportsDenormalization([], SplFileInfoChild::class)); + $this->assertTrue($this->normalizer->supportsDenormalization([], \SplTempFileObject::class)); + $this->assertFalse($this->normalizer->supportsDenormalization([], \stdClass::class)); + } + + /** + * @dataProvider provideInstancesOfType + */ + public function testDenormalizeInstanceOfType(\SplFileInfo $file, string $type) + { + $this->assertSame($file, $this->normalizer->denormalize($file, $type)); + } + + public static function provideInstancesOfType(): iterable + { + $uploadedFile = new UploadedFile(__DIR__.'/../Fixtures/test.gif', 'test.gif', null, null, true); + + yield [$uploadedFile, UploadedFile::class]; + yield [$uploadedFile, File::class]; + yield [$uploadedFile, \SplFileInfo::class]; + yield [new FileChild(__DIR__.'/../Fixtures/test.gif'), FileChild::class]; + yield [new SplFileInfoChild(__DIR__.'/../Fixtures/test.gif'), SplFileInfoChild::class]; + yield [new \SplFileObject(__DIR__.'/../Fixtures/test.txt'), \SplFileObject::class]; + } + + /** + * @dataProvider provideDataForSubclasses + */ + public function testDenormalizeSubclassFromData($data, string $type) + { + try { + $this->normalizer->denormalize($data, $type, null, ['deserialization_path' => 'file']); + $this->fail('The data must not be denormalized.'); + } catch (NotNormalizableValueException $e) { + $this->assertSame([$type], $e->getExpectedTypes()); + $this->assertSame('file', $e->getPath()); + $this->assertTrue($e->canUseMessageForUser()); + } + } + + public static function provideDataForSubclasses(): iterable + { + yield [['path' => __FILE__, 'originalName' => 'test.gif', 'test' => true], UploadedFile::class]; + yield [['path' => __FILE__, 'originalName' => 'test.gif'], UploadedFile::class]; + yield [__FILE__, UploadedFile::class]; + yield [self::TEST_GIF_DATA, UploadedFile::class]; + yield [null, UploadedFile::class]; + yield [new File(__FILE__), UploadedFile::class]; + yield [['path' => __FILE__], FileChild::class]; + yield [['filename' => __FILE__], SplFileInfoChild::class]; + yield [self::TEST_GIF_DATA, SplFileInfoChild::class]; + } + + /** + * @dataProvider provideOtherSpellingsOfSupportedTypes + */ + public function testDeserializeFromDataWithOtherSpellingOfType(string $type) + { + $serializer = new Serializer([$this->normalizer, new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage(\sprintf('Data expected to be an instance of "%s" ("array" given).', $type)); + + $serializer->deserialize(json_encode(['filename' => __FILE__, 'path' => __FILE__]), $type, 'json'); + } + + public static function provideOtherSpellingsOfSupportedTypes(): iterable + { + yield ['\SplFileInfo']; + yield ['splfileinfo']; + yield ['\SplFileObject']; + yield ['\\'.File::class]; + } + + public function testDeserializeUploadedFileFromData() + { + $serializer = new Serializer([new ArrayDenormalizer(), $this->normalizer, new ObjectNormalizer(null, null, null, new PhpDocExtractor())], ['json' => new JsonEncoder()]); + $file = ['path' => __FILE__, 'originalName' => 'test.gif', 'test' => true]; + + try { + $serializer->deserialize(json_encode(['file' => $file, 'files' => [$file]]), UploadedFileDummy::class, 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + $this->fail('An UploadedFile must not be built from data.'); + } catch (PartialDenormalizationException $e) { + $this->assertNull($e->getData()->file); + $this->assertSame([], $e->getData()->files); + $this->assertSame(['file', 'files[0]'], array_map(static fn (NotNormalizableValueException $error) => $error->getPath(), $e->getErrors())); + } + } + + public function testDenormalizeUploadedFileInstances() + { + $serializer = new Serializer([new ArrayDenormalizer(), $this->normalizer, new ObjectNormalizer(null, null, null, new PhpDocExtractor())]); + $file = new UploadedFile(__DIR__.'/../Fixtures/test.gif', 'test.gif', null, null, true); + + $dummy = $serializer->denormalize(['file' => $file, 'files' => [$file]], UploadedFileDummy::class, 'json'); + + $this->assertSame($file, $dummy->file); + $this->assertSame([$file], $dummy->files); + } + /** * @dataProvider invalidUriProvider */ @@ -223,3 +384,24 @@ return $buffer; } } + +class UploadedFileDummy +{ + /** + * @var UploadedFile|null + */ + public $file; + + /** + * @var UploadedFile[] + */ + public $files = []; +} + +class FileChild extends File +{ +} + +class SplFileInfoChild extends \SplFileInfo +{ +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -1105,6 +1105,79 @@ $this->assertSame(['first' => 'foo'], $obj->values); } + public function testDenormalizeWithPropertyPathAndWithoutGroups() + { + $classMetadataFactory = new ClassMetadataFactory(new YamlFileLoader(__DIR__.'/../Fixtures/property-path-mapping-without-groups.yaml')); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $dummy = new ObjectOuter(); + $dummy->setInner(new ObjectInner()); + + $obj = $normalizer->denormalize(['inner_foo' => 'foo'], ObjectOuter::class, 'json', [ + 'object_to_populate' => $dummy, + ]); + + $this->assertSame('foo', $obj->getInner()->foo); + } + + public function testDenormalizeWithPropertyPathIndexAndWithoutGroups() + { + $classMetadataFactory = new ClassMetadataFactory(new YamlFileLoader(__DIR__.'/../Fixtures/property-path-mapping-without-groups.yaml')); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $obj = $normalizer->denormalize(['first_value' => 'foo'], ObjectWithArrayProperty::class, 'json'); + + $this->assertSame(['first' => 'foo'], $obj->values); + } + + public function testDenormalizeIgnoresUndeclaredPropertyPath() + { + $normalizer = new ObjectNormalizer(); + + $dummy = new ObjectOuter(); + $dummy->setInner(new ObjectInner()); + + $obj = $normalizer->denormalize(['inner.foo' => 'foo'], ObjectOuter::class, 'json', [ + 'object_to_populate' => $dummy, + ]); + + $this->assertNull($obj->getInner()->foo); + } + + public function testDenormalizeIgnoresUndeclaredPropertyPathWithClassMetadata() + { + $classMetadataFactory = new ClassMetadataFactory(new YamlFileLoader(__DIR__.'/../Fixtures/property-path-mapping.yaml')); + $normalizer = new ObjectNormalizer($classMetadataFactory); + + $dummy = new ObjectOuter(); + $dummy->setInner(new ObjectInner()); + + $obj = $normalizer->denormalize(['inner.bar' => 'bar'], ObjectOuter::class, 'json', [ + 'object_to_populate' => $dummy, + ]); + + $this->assertNull($obj->getInner()->bar); + } + + public function testDenormalizeIgnoresUndeclaredPropertyPathIndex() + { + $normalizer = new ObjectNormalizer(); + + $obj = $normalizer->denormalize(['values[first]' => 'foo'], ObjectWithArrayProperty::class, 'json'); + + $this->assertSame([], $obj->values); + } + + public function testDenormalizeIgnoresUndeclaredPropertyPathIndexWithClassMetadata() + { + $classMetadataFactory = new ClassMetadataFactory(new YamlFileLoader(__DIR__.'/../Fixtures/property-path-mapping.yaml')); + $normalizer = new ObjectNormalizer($classMetadataFactory); + + $obj = $normalizer->denormalize(['values[second]' => 'foo'], ObjectWithArrayProperty::class, 'json'); + + $this->assertSame([], $obj->values); + } + public function testObjectNormalizerWithAttributeLoaderAndObjectHasStaticProperty() { $class = new class { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\MessageCatalogue; @@ -70,6 +71,10 @@ $sourceFileInfo = $this->downloadSourceFile($fileId); $sourceFile = $this->client->request('GET', $sourceFileInfo->toArray()['data']['url']); + if (!str_starts_with($sourceFile->getContent(), 'defaultLocale, $domain)); + } + $providerCatalogue = $this->loader->load($sourceFile->getContent(), $this->defaultLocale, $domain); $allMessages = array_merge($providerCatalogue->all($domain), $defaultLocaleCatalogue->all($domain)); @@ -181,6 +186,10 @@ continue; } + if (!str_starts_with($response->getContent(), 'addCatalogue($this->loader->load($response->getContent(), $locale, $domain)); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -18,6 +18,7 @@ use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProvider; use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; @@ -1263,6 +1264,63 @@ $crowdinProvider->read(['messages'], ['fr']); } + public function testReadDoesNotLoadLocalFiles() + { + $file = tempnam(sys_get_temp_dir(), 'sf_crowdin_'); + file_put_contents($file, (new XliffFileDumper())->formatCatalogue(new MessageCatalogue('fr', ['messages' => ['a' => 'local']]), 'messages')); + + $responses = [ + 'listFiles' => new JsonMockResponse(['data' => [['data' => ['id' => 12, 'name' => 'messages.xlf']]]]), + 'getProject' => new JsonMockResponse(['data' => ['languageMapping' => []]]), + 'exportProjectTranslations' => new JsonMockResponse(['data' => ['url' => 'https://file.url']]), + 'downloadFile' => new MockResponse($file), + ]; + + $crowdinProvider = self::createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2'); + + $this->expectException(InvalidResourceException::class); + $this->expectExceptionMessage('Crowdin did not return XLIFF content for locale "fr" and domain "messages".'); + + try { + $crowdinProvider->read(['messages'], ['fr']); + } finally { + unlink($file); + } + } + + public function testWriteDoesNotLoadLocalFiles() + { + $file = tempnam(sys_get_temp_dir(), 'sf_crowdin_'); + file_put_contents($file, (new XliffFileDumper())->formatCatalogue(new MessageCatalogue('en', ['messages' => ['a' => 'local']]), 'messages')); + + $responses = [ + 'listFiles' => new JsonMockResponse(['data' => [['data' => ['id' => 12, 'name' => 'messages.xlf']]]]), + 'getProject' => new JsonMockResponse(['data' => ['languageMapping' => []]]), + 'downloadSource' => new JsonMockResponse(['data' => ['url' => 'https://file.url']]), + 'downloadFile' => new MockResponse($file), + ]; + + $crowdinProvider = self::createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', ['messages' => ['b' => 'remote']])); + + $this->expectException(InvalidResourceException::class); + $this->expectExceptionMessage('Crowdin did not return XLIFF content for locale "en" and domain "messages".'); + + try { + $crowdinProvider->write($translatorBag); + } finally { + unlink($file); + } + } + public function testDelete() { $responses = [ diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php 2026-11-04 11:23:00.000000000 +0000 @@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Translation\CatalogueMetadataAwareInterface; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\MessageCatalogue; @@ -147,6 +148,10 @@ throw new ProviderException('Unable to read the Loco response: '.$responseContent, $response); } + if (!str_starts_with($responseContent, 'loader->load($responseContent, $locale, $domain); $catalogue = new MessageCatalogue($locale); diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -16,6 +16,8 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Loco\LocoProvider; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; @@ -845,6 +847,23 @@ $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); } + public function testReadDoesNotLoadLocalFiles() + { + $file = tempnam(sys_get_temp_dir(), 'sf_loco_'); + file_put_contents($file, (new XliffFileDumper())->formatCatalogue(new MessageCatalogue('en', ['messages' => ['a' => 'local']]), 'messages')); + + $provider = self::createProvider(new MockHttpClient(new MockResponse($file), 'https://localise.biz/api/'), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(InvalidResourceException::class); + $this->expectExceptionMessage('Loco did not return XLIFF content for locale "en" and domain "messages".'); + + try { + $provider->read(['messages'], ['en']); + } finally { + unlink($file); + } + } + public function testDeleteProcess() { $translatorBag = new TranslatorBag(); @@ -1169,7 +1188,7 @@ $this->assertSame('https://localise.biz/api/export/locale/fr.xlf?filter=&status=translated%2Cblank-translation', $url); $this->assertSame(['filter' => '', 'status' => 'translated,blank-translation'], $options['query']); - return new MockResponse(); + return new MockResponse(''); }, ], 'https://localise.biz/api/'), $this->getLoader(), diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\Translation\Bridge\Lokalise; use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Exception\RuntimeException; use Symfony\Component\Translation\Loader\LoaderInterface; @@ -100,6 +101,10 @@ foreach ($translations as $locale => $files) { foreach ($files as $filename => $content) { + if (!str_starts_with($content['content'], 'addCatalogue($this->loader->load($content['content'], $locale, str_replace('.xliff', '', $filename))); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -17,6 +17,8 @@ use Symfony\Component\HttpClient\Response\JsonMockResponse; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProvider; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; @@ -975,6 +977,24 @@ $provider->read(['foo'], ['baz']); } + public function testReadDoesNotLoadLocalFiles() + { + $file = tempnam(sys_get_temp_dir(), 'sf_lokalise_'); + file_put_contents($file, (new XliffFileDumper())->formatCatalogue(new MessageCatalogue('en', ['messages' => ['a' => 'local']]), 'messages')); + + $response = new MockResponse(json_encode(['files' => ['en' => ['messages.xliff' => ['content' => $file]]]])); + $provider = self::createProvider(new MockHttpClient($response, 'https://api.lokalise.com/api2/projects/PROJECT_ID/'), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $this->expectException(InvalidResourceException::class); + $this->expectExceptionMessage('Lokalise did not return XLIFF content for locale "en" and file "messages.xliff".'); + + try { + $provider->read(['messages'], ['en']); + } finally { + unlink($file); + } + } + public function testDeleteProcess() { $getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php 2026-11-04 11:23:00.000000000 +0000 @@ -16,6 +16,7 @@ use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Provider\ProviderInterface; @@ -121,6 +122,11 @@ } $content = 304 === $statusCode && null !== $cachedResponse ? $cachedResponse['content'] : $response->getContent(); + + if (!str_starts_with($content, 'addCatalogue($this->loader->load($content, $locale, $domain)); // using weak etags, responses for requests with fallback locale enabled can not be reliably cached... diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -22,8 +22,10 @@ use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Phrase\PhraseProvider; use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\ProviderExceptionInterface; use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Loader\XliffFileLoader; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\TranslatorBag; @@ -245,7 +247,7 @@ $this->assertArrayHasKey('query', $options); $this->assertSame($query, $options['query']); - return new MockResponse(); + return new MockResponse(''); }, ]; @@ -260,6 +262,36 @@ $provider->read([$domain], [$locale]); } + public function testReadDoesNotLoadLocalFiles() + { + $file = tempnam(sys_get_temp_dir(), 'sf_phrase_'); + file_put_contents($file, (new XliffFileDumper())->formatCatalogue(new MessageCatalogue('de', ['messages' => ['a' => 'local']]), 'messages')); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', $file), + ]; + + $this->loader = new XliffFileLoader(); + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $this->expectException(InvalidResourceException::class); + $this->expectExceptionMessage('Phrase did not return XLIFF content for locale "de" and domain "messages".'); + + try { + $provider->read(['messages'], ['de']); + } finally { + unlink($file); + } + } + /** * @dataProvider cacheKeyProvider */ @@ -274,7 +306,7 @@ 'download locale' => function (string $method): ResponseInterface { $this->assertSame('GET', $method); - return new MockResponse('', ['http_code' => 200, 'response_headers' => [ + return new MockResponse('', ['http_code' => 200, 'response_headers' => [ 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', ]]); @@ -316,7 +348,7 @@ $this->assertArrayNotHasKey('if-none-match', $options['normalized_headers']); } - return new MockResponse('', ['http_code' => 200, 'response_headers' => [ + return new MockResponse('', ['http_code' => 200, 'response_headers' => [ 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', ]]); @@ -490,7 +522,7 @@ ], ]); }, - 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), ]; $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ @@ -524,7 +556,7 @@ 'fallback_locale' => null, ], ['http_code' => 201]); }, - 'download locale' => $this->getDownloadLocaleResponseMock('messages', 'zWlsCvkeSK0EBgBVmGpZ4cySWbQ0s1Dk4', ''), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', 'zWlsCvkeSK0EBgBVmGpZ4cySWbQ0s1Dk4', ''), ]; $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ @@ -551,7 +583,7 @@ 'fallback_locale' => null, ], ]), - 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), ]; $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ @@ -578,7 +610,7 @@ 'fallback_locale' => null, ], ]), - 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), ]; $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ @@ -605,7 +637,7 @@ 'fallback_locale' => null, ], ]), - 'download locale' => $this->getDownloadLocaleResponseMock('messages', '13604ec993beefcdaba732812cdb828c', ''), + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '13604ec993beefcdaba732812cdb828c', ''), ]; $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ @@ -647,7 +679,7 @@ $this->assertStringStartsWith('https://api.phrase.com/api/v2/projects/1/locales/13604ec993beefcdaba732812cdb828c/download?', $url); $this->assertSame('German', $options['query']['fallback_locale_id']); - return new MockResponse(); + return new MockResponse(''); }, ]; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php 2026-11-04 11:23:00.000000000 +0000 @@ -32,6 +32,6 @@ protected function getPassword(Dsn $dsn): string { - return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn()); + return $dsn->getPassword() ?? throw new IncompleteDsnException('Password is not set.', $dsn->getScheme().'://'.$dsn->getHost()); } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Tests/Provider/AbstractProviderFactoryTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Tests/Provider/AbstractProviderFactoryTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Tests/Provider/AbstractProviderFactoryTest.php 1970-01-01 00:00:00.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Tests/Provider/AbstractProviderFactoryTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\NullProvider; +use Symfony\Component\Translation\Provider\ProviderInterface; + +class AbstractProviderFactoryTest extends TestCase +{ + public function testMissingPasswordDoesNotLeakCredentials() + { + $factory = new class extends AbstractProviderFactory { + public function create(Dsn $dsn): ProviderInterface + { + $this->getPassword($dsn); + + return new NullProvider(); + } + + protected function getSupportedSchemes(): array + { + return ['foo']; + } + }; + + try { + $factory->create(new Dsn('foo://s3cr3t@default?token=s3cr3t')); + $this->fail('An exception should have been thrown.'); + } catch (IncompleteDsnException $e) { + $this->assertSame('Invalid "foo://default" provider DSN: Password is not set.', $e->getMessage()); + $this->assertStringNotContainsString('s3cr3t', $e->getMessage()); + } + } +} diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Tests/TranslatorCacheTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Tests/TranslatorCacheTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Translation/Tests/TranslatorCacheTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Translation/Tests/TranslatorCacheTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -25,7 +25,7 @@ protected function setUp(): void { - $this->tmpDir = sys_get_temp_dir().'/sf_translation'; + $this->tmpDir = sys_get_temp_dir().'/sf_translator_cache_test'; $this->deleteTmpDir(); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Constraints/IpValidator.php symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Constraints/IpValidator.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Constraints/IpValidator.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Constraints/IpValidator.php 2026-11-04 11:23:00.000000000 +0000 @@ -25,6 +25,50 @@ class IpValidator extends ConstraintValidator { /** + * IPv4 subnets that cannot be reached over the public internet because they are special-purpose. + * + * Taken from the IANA IPv4 Special-Purpose Address Registry (RFC 6890), keeping the entries that + * are not globally reachable. filter_var() covers some of them under FILTER_FLAG_NO_PRIV_RANGE + * and others under FILTER_FLAG_NO_RES_RANGE, none under both, and ignores the shared, + * documentation and benchmarking ranges altogether. + */ + private const NON_PUBLIC_V4_SUBNETS = [ + '0.0.0.0/8', // "this" network, including the unspecified address + '10.0.0.0/8', // private use + '100.64.0.0/10', // shared address space + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link local + '172.16.0.0/12', // private use + '192.0.2.0/24', // documentation TEST-NET-1 + '192.168.0.0/16', // private use + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // documentation TEST-NET-2 + '203.0.113.0/24', // documentation TEST-NET-3 + '240.0.0.0/4', // reserved, including the broadcast address + ]; + + /** + * IPv6 subnets that cannot be reached over the public internet, either because they + * are special-purpose or because they carry a non-public IPv4 address. + * + * Taken from the IANA IPv6 Special-Purpose Address Registry (RFC 6890), keeping the entries + * that are not globally reachable. "::/128" is left out since "::/96" already covers it. + */ + private const NON_PUBLIC_V6_SUBNETS = [ + '::1/128', // loopback + 'fc00::/7', // unique local address + 'fe80::/10', // link local address + '::ffff:0:0/96', // IPv4-mapped + '::/96', // IPv4-compatible, including the unspecified address + '2002::/16', // 6to4 + '2001::/32', // Teredo tunneling + '2001:db8::/32', // documentation + '2001:0002::/48', // benchmarking + '64:ff9b::/96', // NAT64 well-known prefix + '64:ff9b:1::/48', // NAT64 local-use prefix + ]; + + /** * @return void */ public function validate(mixed $value, Constraint $constraint) @@ -62,11 +106,60 @@ default => 0, }; - if (!filter_var($value, \FILTER_VALIDATE_IP, $flag)) { + $isValid = (bool) filter_var($value, \FILTER_VALIDATE_IP, $flag); + + if ($isValid && $flag & (\FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE)) { + $isValid = !self::isNonPublicIp($value); + } + + if (!$isValid) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $this->formatValue($value)) ->setCode(Ip::INVALID_IP_ERROR) ->addViolation(); } } + + /** + * Tells whether an address belongs to one of the non-public subnets, whatever its textual spelling. + */ + private static function isNonPublicIp(string $ip): bool + { + if (false === $packed = @inet_pton($ip)) { + // filter_var() parses IPv6 addresses on its own, while inet_pton() rejects all of them + // when PHP is compiled with "--disable-ipv6". The address cannot be classified, so the + // only safe answer for these security-purposed modes is to consider it non-public. + return true; + } + + $subnets = 4 === \strlen($packed) ? self::NON_PUBLIC_V4_SUBNETS : self::NON_PUBLIC_V6_SUBNETS; + + foreach ($subnets as $subnet) { + [$address, $bits] = explode('/', $subnet); + $bytes = (int) $bits >> 3; + $extraBits = (int) $bits & 7; + + if (false === $prefix = inet_pton($address)) { + // Unreachable in practice, parsing $ip above already proved this family is + // supported, but a subnet that cannot be parsed must not silently be skipped. + return true; + } + + if (substr($packed, 0, $bytes) !== substr($prefix, 0, $bytes)) { + continue; + } + + if (!$extraBits) { + return true; + } + + $mask = 0xFF << (8 - $extraBits) & 0xFF; + + if ((\ord($packed[$bytes]) & $mask) === (\ord($prefix[$bytes]) & $mask)) { + return true; + } + } + + return false; + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php 2026-11-04 11:23:00.000000000 +0000 @@ -62,11 +62,6 @@ continue; } - $types = $this->typeExtractor->getTypes($className, $property); - if (null === $types) { - continue; - } - $enabledForProperty = $enabledForClass; $hasTypeConstraint = false; $hasNotNullConstraint = false; @@ -99,6 +94,10 @@ continue; } + if (null === $types = $this->typeExtractor->getTypes($className, $property)) { + continue; + } + $loaded = true; $builtinTypes = []; $nullable = false; diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.az.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.az.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.az.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.az.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Bu dəyər çox qısadır. Heç olmasa bir söz daxil etməlisiniz.|Bu dəyər çox qısadır. Heç olmasa {{ min }} söz daxil etməlisiniz. + Bu dəyər çox qısadır. Heç olmasa {{ min }} söz daxil etməlisiniz. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Bu dəyər çox uzundur. Yalnız bir söz daxil etməlisiniz.|Bu dəyər çox uzundur. {{ max }} və ya daha az söz daxil etməlisiniz. + Bu dəyər çox uzundur. {{ max }} və ya daha az söz daxil etməlisiniz. This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.da.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.da.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.da.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.da.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -572,79 +572,79 @@ The referenced entity does not exist. - Den refererede entitet findes ikke. + Den refererede entitet findes ikke. This value is not a valid ULID. - Denne værdi er ikke et gyldigt ULID. + Denne værdi er ikke et gyldigt ULID. This file is not a valid audio file. - Denne fil er ikke en gyldig lydfil. + Denne fil er ikke en gyldig lydfil. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Lyden er for kort ({{ duration }} sekunder). Mindste forventede varighed er {{ min_duration }} sekunder. + Lyden er for kort ({{ duration }} sekunder). Mindste forventede varighed er {{ min_duration }} sekunder. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Lyden er for lang ({{ duration }} sekunder). Tilladt maksimal varighed er {{ max_duration }} sekunder. + Lyden er for lang ({{ duration }} sekunder). Tilladt maksimal varighed er {{ max_duration }} sekunder. The duration of the audio could not be detected. - Lydens varighed kunne ikke registreres. + Lydens varighed kunne ikke registreres. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Lydens bitrate er for lav ({{ bitrate }} bps). Mindste forventede bitrate er {{ min_bitrate }} bps. + Lydens bitrate er for lav ({{ bitrate }} bps). Mindste forventede bitrate er {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Lydens bitrate er for høj ({{ bitrate }} bps). Tilladt maksimal bitrate er {{ max_bitrate }} bps. + Lydens bitrate er for høj ({{ bitrate }} bps). Tilladt maksimal bitrate er {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Lydens bitrate kunne ikke registreres. + Lydens bitrate kunne ikke registreres. Unsupported audio sample rate ({{ sample_rate }} Hz). - Ikke-understøttet samplingfrekvens for lyd ({{ sample_rate }} Hz). + Ikke-understøttet samplingfrekvens for lyd ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Lydens samplingfrekvens kunne ikke registreres. + Lydens samplingfrekvens kunne ikke registreres. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Lyden har for få kanaler ({{ channels }}). Forventet minimum er {{ min_channels }}. + Lyden har for få kanaler ({{ channels }}). Forventet minimum er {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Lyden har for mange kanaler ({{ channels }}). Forventet maksimummængde er {{ max_channels }}. + Lyden har for mange kanaler ({{ channels }}). Forventet maksimummængde er {{ max_channels }}. The number of audio channels could not be detected. - Antallet af lydkanaler kunne ikke registreres. + Antallet af lydkanaler kunne ikke registreres. The audio file is corrupted. - Lydfilen er beskadiget. + Lydfilen er beskadiget. The audio contains multiple streams. Only one stream is allowed. - Lyden indeholder flere streams. Kun én stream er tilladt. + Lyden indeholder flere streams. Kun én stream er tilladt. Unsupported audio codec "{{ codec }}". - Ikke-understøttet lydkodek "{{ codec }}". + Ikke-understøttet lydkodek "{{ codec }}". Unsupported audio container "{{ container }}". - Ikke-understøttet lydcontainer "{{ container }}". + Ikke-understøttet lydcontainer "{{ container }}". The file does not contain any audio stream. - Filen indeholder ingen lydstream. + Filen indeholder ingen lydstream. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.de.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.de.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.de.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.de.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -580,71 +580,71 @@ This file is not a valid audio file. - Diese Datei ist keine gültige Audiodatei. + Diese Datei ist keine gültige Audiodatei. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Die Audiodatei ist zu kurz ({{ duration }} Sekunden). Die erwartete Mindestdauer ist {{ min_duration }} Sekunden. + Die Audiodatei ist zu kurz ({{ duration }} Sekunden). Die erwartete Mindestdauer ist {{ min_duration }} Sekunden. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Die Audiodatei ist zu lang ({{ duration }} Sekunden). Die zulässige maximale Dauer ist {{ max_duration }} Sekunden. + Die Audiodatei ist zu lang ({{ duration }} Sekunden). Die zulässige maximale Dauer ist {{ max_duration }} Sekunden. The duration of the audio could not be detected. - Die Dauer der Audiodatei konnte nicht ermittelt werden. + Die Dauer der Audiodatei konnte nicht ermittelt werden. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Die Bitrate der Audiodatei ist zu niedrig ({{ bitrate }} bps). Die erwartete Mindestbitrate ist {{ min_bitrate }} bps. + Die Bitrate der Audiodatei ist zu niedrig ({{ bitrate }} bps). Die erwartete Mindestbitrate ist {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Die Bitrate der Audiodatei ist zu hoch ({{ bitrate }} bps). Die zulässige maximale Bitrate ist {{ max_bitrate }} bps. + Die Bitrate der Audiodatei ist zu hoch ({{ bitrate }} bps). Die zulässige maximale Bitrate ist {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Die Bitrate der Audiodatei konnte nicht ermittelt werden. + Die Bitrate der Audiodatei konnte nicht ermittelt werden. Unsupported audio sample rate ({{ sample_rate }} Hz). - Nicht unterstützte Audio-Abtastrate ({{ sample_rate }} Hz). + Nicht unterstützte Audio-Abtastrate ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Die Abtastrate der Audiodatei konnte nicht ermittelt werden. + Die Abtastrate der Audiodatei konnte nicht ermittelt werden. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Die Audiodatei hat zu wenige Kanäle ({{ channels }}). Die erwartete Mindestanzahl ist {{ min_channels }}. + Die Audiodatei hat zu wenige Kanäle ({{ channels }}). Die erwartete Mindestanzahl ist {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Die Audiodatei hat zu viele Kanäle ({{ channels }}). Die erwartete Höchstanzahl ist {{ max_channels }}. + Die Audiodatei hat zu viele Kanäle ({{ channels }}). Die erwartete Höchstanzahl ist {{ max_channels }}. The number of audio channels could not be detected. - Die Anzahl der Audiokanäle konnte nicht ermittelt werden. + Die Anzahl der Audiokanäle konnte nicht ermittelt werden. The audio file is corrupted. - Die Audiodatei ist beschädigt. + Die Audiodatei ist beschädigt. The audio contains multiple streams. Only one stream is allowed. - Die Audiodatei enthält mehrere Streams. Es ist nur ein Stream erlaubt. + Die Audiodatei enthält mehrere Streams. Es ist nur ein Stream erlaubt. Unsupported audio codec "{{ codec }}". - Nicht unterstützter Audiocodec "{{ codec }}". + Nicht unterstützter Audiocodec "{{ codec }}". Unsupported audio container "{{ container }}". - Nicht unterstützter Audiocontainer "{{ container }}". + Nicht unterstützter Audiocontainer "{{ container }}". The file does not contain any audio stream. - Die Datei enthält keinen Audio-Stream. + Die Datei enthält keinen Audio-Stream. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.et.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.et.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.et.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.et.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -532,7 +532,7 @@ Unsupported video codec "{{ codec }}". - Toetamata videokoodek „{{ codec }}“. + Toetamata videokoodek "{{ codec }}". Unsupported video container "{{ container }}". @@ -580,71 +580,71 @@ This file is not a valid audio file. - See fail ei ole kehtiv helifail. + See fail ei ole kehtiv helifail. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Heli on liiga lühike ({{ duration }} sekundit). Oodatav minimaalne kestus on {{ min_duration }} sekundit. + Heli on liiga lühike ({{ duration }} sekundit). Oodatav minimaalne kestus on {{ min_duration }} sekundit. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Heli on liiga pikk ({{ duration }} sekundit). Lubatud maksimaalne kestus on {{ max_duration }} sekundit. + Heli on liiga pikk ({{ duration }} sekundit). Lubatud maksimaalne kestus on {{ max_duration }} sekundit. The duration of the audio could not be detected. - Heli kestust ei õnnestunud tuvastada. + Heli kestust ei õnnestunud tuvastada. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Heli bitikiirus on liiga madal ({{ bitrate }} bps). Oodatav minimaalne bitikiirus on {{ min_bitrate }} bps. + Heli bitikiirus on liiga madal ({{ bitrate }} bps). Oodatav minimaalne bitikiirus on {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Heli bitikiirus on liiga suur ({{ bitrate }} bps). Lubatud maksimaalne bitikiirus on {{ max_bitrate }} bps. + Heli bitikiirus on liiga suur ({{ bitrate }} bps). Lubatud maksimaalne bitikiirus on {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Heli bitikiirust ei õnnestunud tuvastada. + Heli bitikiirust ei õnnestunud tuvastada. Unsupported audio sample rate ({{ sample_rate }} Hz). - Toetamata heli diskreetimissagedus ({{ sample_rate }} Hz). + Toetamata heli sämplimissagedus ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Heli diskreetimissagedust ei õnnestunud tuvastada. + Heli sämplimissagedust ei õnnestunud tuvastada. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Helil on liiga vähe kanaleid ({{ channels }}). Oodatav miinimum on {{ min_channels }}. + Helil on liiga vähe kanaleid ({{ channels }}). Oodatav miinimum on {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Helil on liiga palju kanaleid ({{ channels }}). Eeldatav maksimaalne kogus on {{ max_channels }}. + Helil on liiga palju kanaleid ({{ channels }}). Eeldatav maksimaalne kogus on {{ max_channels }}. The number of audio channels could not be detected. - Helikanalite arvu ei õnnestunud tuvastada. + Helikanalite arvu ei õnnestunud tuvastada. The audio file is corrupted. - Helifail on rikutud. + Helifail on rikutud. The audio contains multiple streams. Only one stream is allowed. - Heli sisaldab mitu voogu. Lubatud on ainult üks voog. + Heli sisaldab mitu voogu. Lubatud on ainult üks voog. Unsupported audio codec "{{ codec }}". - Toetamata helikoodek „{{ codec }}“. + Toetamata helikoodek "{{ codec }}". Unsupported audio container "{{ container }}". - Toetamata helikonteiner "{{ container }}". + Toetamata helikonteiner "{{ container }}". The file does not contain any audio stream. - Fail ei sisalda ühtegi helivoogu. + Fail ei sisalda ühtegi helivoogu. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.id.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.id.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.id.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.id.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Nilai ini terlalu pendek. Seharusnya berisi setidaknya satu kata.|Nilai ini terlalu pendek. Seharusnya berisi setidaknya {{ min }} kata. + Nilai ini terlalu pendek. Seharusnya berisi setidaknya {{ min }} kata. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Nilai ini terlalu panjang. Seharusnya hanya berisi satu kata.|Nilai ini terlalu panjang. Seharusnya berisi {{ max }} kata atau kurang. + Nilai ini terlalu panjang. Seharusnya berisi {{ max }} kata atau kurang. This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.lb.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.lb.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.lb.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.lb.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -160,7 +160,7 @@ The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - D'Breet vum Bild ass ze grouss ({{ width }}px). Déi erlaabte maximal Breet ass {{ max_width }}px. + D'Breet vum Bild ass ze grouss ({{ width }}px). Déi erlaabt maximal Breet ass {{ max_width }}px. The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. @@ -168,7 +168,7 @@ The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - D'Héicht vum Bild ass ze grouss ({{ height }}px). Déi erlaabte maximal Héicht ass {{ max_height }}px. + D'Héicht vum Bild ass ze grouss ({{ height }}px). Déi erlaabt maximal Héicht ass {{ max_height }}px. The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. @@ -404,11 +404,11 @@ The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. - De Dateinimm ass ze laang. Et sollt {{ filename_max_length }} Zeechen oder manner hunn.|De Dateinimm ass ze laang. Et sollt {{ filename_max_length }} Zeechen oder manner hunn. + Den Dateinumm ass ze laang. E sollt {{ filename_max_length }} Zeechen oder manner hunn.|Den Dateinumm ass ze laang. E sollt {{ filename_max_length }} Zeechen oder manner hunn. The password strength is too low. Please use a stronger password. - D'Staarf vum Passwuert ass ze schwaach. Benotzt w. e. g. e stäerker Passwuert. + D'Stäerkt vum Passwuert ass ze schwaach. Benotzt w. e. g. e méi staarkt Passwuert. This value contains characters that are not allowed by the current restriction-level. @@ -416,15 +416,15 @@ Using invisible characters is not allowed. - D'Benotzen vu onsiichtbaren Zeechen ass net erlaabt. + D'Benotze vun onsiichtbaren Zeechen ass net erlaabt. Mixing numbers from different scripts is not allowed. - D'Mësche vu Nummeren aus verschiddenen Skripten ass net erlaabt. + D'Mësche vun Nummeren aus verschiddenen Skripten ass net erlaabt. Using hidden overlay characters is not allowed. - D'Benotzen vu verstoppten Iwwerlagungszeechen ass net erlaabt. + D'Benotze vu verstoppten Iwwerlagungszeechen ass net erlaabt. The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. @@ -472,7 +472,7 @@ This file is not a valid video. - Dës Datei ass kee gëltegen Video. + Dës Datei ass kee gëltege Video. The size of the video could not be detected. @@ -480,23 +480,23 @@ The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - D'Videobreed ass ze grouss ({{ width }}px). Erlaabt maximal Breed ass {{ max_width }}px. + D'Videobreet ass ze grouss ({{ width }}px). Déi erlaabt maximal Breet ass {{ max_width }}px. The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - D'Videobreed ass ze kleng ({{ width }}px). Minimal erwaart Breed ass {{ min_width }}px. + D'Videobreet ass ze kleng ({{ width }}px). Déi minimal erwaart Breet ass {{ min_width }}px. The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - D'Videohéicht ass ze grouss ({{ height }}px). Erlaabt maximal Héicht ass {{ max_height }}px. + D'Videohéicht ass ze grouss ({{ height }}px). Déi erlaabt maximal Héicht ass {{ max_height }}px. The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - D'Videohéicht ass ze kleng ({{ height }}px). Erwaart Mindesthéicht ass {{ min_height }}px. + D'Videohéicht ass ze kleng ({{ height }}px). Déi minimal erwaart Héicht ass {{ min_height }}px. The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - De Video huet ze wéineg Pixel ({{ pixels }}). Erwaart Minimum ass {{ min_pixels }}. + De Video huet ze wéineg Pixel ({{ pixels }}). Déi erwaart minimal Zuel ass {{ min_pixels }}. The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. @@ -504,11 +504,11 @@ The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - D'Videoproportioun ass ze grouss ({{ ratio }}). Erlaabt maximal Proportioun ass {{ max_ratio }}. + D'Videoproportioun ass ze grouss ({{ ratio }}). Déi erlaabt maximal Proportioun ass {{ max_ratio }}. The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - D'Videoratio ass ze kleng ({{ ratio }}). Minimal erwaart Ratio ass {{ min_ratio }}. + D'Videoproportioun ass ze kleng ({{ ratio }}). Déi minimal erwaart Proportioun ass {{ min_ratio }}. The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. @@ -516,11 +516,11 @@ The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. - De Video ass am Landschaftsformat ({{ width }}x{{ height }} px). Landschafts-Videoe sinn net erlaabt. + De Video ass am Queeschformat ({{ width }}x{{ height }}px). Videoe am Queeschformat sinn net erlaabt. The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. - De Video ass am Portraitformat ({{ width }}x{{ height }}px). Portrait-Videoe sinn net erlaabt. + De Video ass am Héichformat ({{ width }}x{{ height }}px). Videoe am Héichformat sinn net erlaabt. The video file is corrupted. @@ -528,7 +528,7 @@ The video contains multiple streams. Only one stream is allowed. - De Video enthält verschidde Stréimen. Nëmmen ee Stroum ass erlaabt. + De Video enthält verschidde Streams. Nëmmen ee Stream ass erlaabt. Unsupported video codec "{{ codec }}". @@ -556,95 +556,95 @@ This value is not valid XML. - Dëse Wert ass keen valabelt XML. + Dëse Wäert ass kee gëltegt XML. This value does not conform to the expected XSD schema. - Dëse Wert entsprécht net dem erwaarte XSD-Schema. + Dëse Wäert entsprécht net dem erwaarten XSD-Schema. This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes. - Dës XML-Nutzlaascht ass ze grouss ({{ size }} Bytes): si iwwerschreift d'Limitt vu {{ limit }} Bytes. + Dës XML-Notzlaascht ass ze grouss ({{ size }} Bytes): si iwwerschreit d'Limitt vun {{ limit }} Bytes. This value is not a valid cron expression. - Dëse Wäert ass keen gültegen Cron-Ausdrock. + Dëse Wäert ass kee gëltege Cron-Ausdrock. The referenced entity does not exist. - Déi referenzéiert Entitéit existéiert net. + Déi referenzéiert Entitéit existéiert net. This value is not a valid ULID. - Dëse Wäert ass keng gülteg ULID. + Dëse Wäert ass keng gëlteg ULID. This file is not a valid audio file. - Dës Datei ass kee gëltegen Audiofichier. + Dës Datei ass kee gëltegen Audiofichier. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - D'Audio ass ze kuerz ({{ duration }} Sekonnen). Minimal erwaart Dauer ass {{ min_duration }} Sekonnen. + D'Audiodatei ass ze kuerz ({{ duration }} Sekonnen). Déi minimal erwaart Dauer ass {{ min_duration }} Sekonnen. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - D'Audio ass ze laang ({{ duration }} Sekonnen). Erlaabt maximal Dauer ass {{ max_duration }} Sekonnen. + D'Audiodatei ass ze laang ({{ duration }} Sekonnen). Déi erlaabt maximal Dauer ass {{ max_duration }} Sekonnen. The duration of the audio could not be detected. - D'Dauer vum Audio konnt net erkannt ginn. + D'Dauer vun der Audiodatei konnt net erkannt ginn. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - D'Bitrate vum Audio ass ze niddreg ({{ bitrate }} bps). Minimal erwaart Bitrate ass {{ min_bitrate }} bps. + D'Bitrate vun der Audiodatei ass ze niddreg ({{ bitrate }} bps). Déi minimal erwaart Bitrate ass {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - D'Bitrate vum Audio ass ze héich ({{ bitrate }} bps). Erlaabt maximal Bitrate ass {{ max_bitrate }} bps. + D'Bitrate vun der Audiodatei ass ze héich ({{ bitrate }} bps). Déi erlaabt maximal Bitrate ass {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - D'Bitrate vum Audio konnt net erkannt ginn. + D'Bitrate vun der Audiodatei konnt net erkannt ginn. Unsupported audio sample rate ({{ sample_rate }} Hz). - Net ënnerstëtzte Audio-Samplingrate ({{ sample_rate }} Hz). + Net ënnerstëtzt Audio-Samplingrate ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - D'Samplingrate vum Audio konnt net erkannt ginn. + D'Samplingrate vun der Audiodatei konnt net erkannt ginn. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - D'Audio huet ze wéineg Kanäl ({{ channels }}). Erwaart Minimum ass {{ min_channels }}. + D'Audiodatei huet ze wéineg Kanäl ({{ channels }}). Déi erwaart minimal Zuel ass {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - D'Audio huet ze vill Kanäl ({{ channels }}). Déi erwaart maximal Zuel ass {{ max_channels }}. + D'Audiodatei huet ze vill Kanäl ({{ channels }}). Déi erwaart maximal Zuel ass {{ max_channels }}. The number of audio channels could not be detected. - D'Zuel vun den Audiokanäl konnt net erkannt ginn. + D'Zuel vun den Audiokanäl konnt net erkannt ginn. The audio file is corrupted. - D'Audiodatei ass beschiedegt. + D'Audiodatei ass beschiedegt. The audio contains multiple streams. Only one stream is allowed. - D'Audio enthält verschidde Stréimen. Nëmmen ee Stroum ass erlaabt. + D'Audiodatei enthält verschidde Streams. Nëmmen ee Stream ass erlaabt. Unsupported audio codec "{{ codec }}". - Net ënnerstëtzte Audiocodec „{{ codec }}“. + Net ënnerstëtzten Audiocodec „{{ codec }}“. Unsupported audio container "{{ container }}". - Net ënnerstëtzte Audio-Container "{{ container }}". + Net ënnerstëtzten Audio-Container "{{ container }}". The file does not contain any audio stream. - D'Datei enthält keen Audiostroum. + D'Datei enthält keen Audio-Stream. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.my.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.my.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.my.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.my.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - ဤတန်ဖိုးသည် အလွန်တိုတောင်းသည်။ အနည်းဆုံး စကားလုံးတစ်လုံး ပါဝင်သင့်သည်။|ဤတန်ဖိုးသည် အလွန်တိုတောင်းသည်။ အနည်းဆုံး စကားလုံး {{ min }} လုံး ပါဝင်သင့်သည်။ + ဤတန်ဖိုးသည် အလွန်တိုတောင်းသည်။ အနည်းဆုံး စကားလုံး {{ min }} လုံး ပါဝင်သင့်သည်။ This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - ဤတန်ဖိုးသည် အလွန်ရှည်လျားသည်။ စကားလုံးတစ်လုံးသာ ပါဝင်သင့်သည်။|ဤတန်ဖိုးသည် အလွန်ရှည်လျားသည်။ စကားလုံး {{ max }} လုံး သို့မဟုတ် ထိုထက်နည်းသည် ပါဝင်သင့်သည်။ + ဤတန်ဖိုးသည် အလွန်ရှည်လျားသည်။ စကားလုံး {{ max }} လုံး သို့မဟုတ် ထိုထက်နည်းသည် ပါဝင်သင့်သည်။ This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sk.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sk.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sk.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sk.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -572,79 +572,79 @@ The referenced entity does not exist. - Odkazovaná entita neexistuje. + Odkazovaná entita neexistuje. This value is not a valid ULID. - Táto hodnota nie je platné ULID. + Táto hodnota nie je platné ULID. This file is not a valid audio file. - Tento súbor nie je platný audiosúbor. + Tento súbor nie je platný audiosúbor. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Audio je príliš krátke ({{ duration }} sekúnd). Očakávaná minimálna dĺžka je {{ min_duration }} sekúnd. + Audio je príliš krátke ({{ duration }} s). Očakávaná minimálna dĺžka je {{ min_duration }} s. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Audio je príliš dlhé ({{ duration }} sekúnd). Povolená maximálna dĺžka je {{ max_duration }} sekúnd. + Audio je príliš dlhé ({{ duration }} s). Povolená maximálna dĺžka je {{ max_duration }} s. The duration of the audio could not be detected. - Dĺžku audia sa nepodarilo zistiť. + Dĺžku audia sa nepodarilo zistiť. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Bitový tok audia je príliš nízky ({{ bitrate }} bps). Očakávaný minimálny bitový tok je {{ min_bitrate }} bps. + Bitový tok audia je príliš nízky ({{ bitrate }} bps). Očakávaný minimálny bitový tok je {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Bitový tok audia je príliš vysoký ({{ bitrate }} bps). Povolený maximálny bitový tok je {{ max_bitrate }} bps. + Bitový tok audia je príliš vysoký ({{ bitrate }} bps). Povolený maximálny bitový tok je {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Bitový tok audia sa nepodarilo zistiť. + Bitový tok audia sa nepodarilo zistiť. Unsupported audio sample rate ({{ sample_rate }} Hz). - Nepodporovaná vzorkovacia frekvencia audia ({{ sample_rate }} Hz). + Nepodporovaná vzorkovacia frekvencia audia ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Vzorkovaciu frekvenciu audia sa nepodarilo zistiť. + Vzorkovaciu frekvenciu audia sa nepodarilo zistiť. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Audio má príliš málo kanálov ({{ channels }}). Očakávané minimálne množstvo je {{ min_channels }}. + Audio má príliš málo kanálov ({{ channels }}). Očakávané minimálne množstvo je {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Audio má príliš veľa kanálov ({{ channels }}). Očakávané maximálne množstvo je {{ max_channels }}. + Audio má príliš veľa kanálov ({{ channels }}). Očakávané maximálne množstvo je {{ max_channels }}. The number of audio channels could not be detected. - Počet audiokanálov sa nepodarilo zistiť. + Počet audiokanálov sa nepodarilo zistiť. The audio file is corrupted. - Audiosúbor je poškodený. + Audiosúbor je poškodený. The audio contains multiple streams. Only one stream is allowed. - Audio obsahuje viacero tokov. Povolený je len jeden tok. + Audio obsahuje viacero streamov. Povolený je len jeden stream. Unsupported audio codec "{{ codec }}". - Nepodporovaný audiokodek "{{ codec }}". + Nepodporovaný audiokodek "{{ codec }}". Unsupported audio container "{{ container }}". - Nepodporovaný kontajner audia "{{ container }}". + Nepodporovaný kontajner audia "{{ container }}". The file does not contain any audio stream. - Súbor neobsahuje žiadny audio tok. + Súbor neobsahuje žiadny audio stream. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.sl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -192,7 +192,7 @@ No temporary folder was configured in php.ini, or the configured folder does not exist. - V php.ini ni bila nastavljena začasna mapa, ali pa nastavljena mapa ne obstaja. + V php.ini ni bila nastavljena začasna mapa ali pa nastavljena mapa ne obstaja. Cannot write temporary file to disk. @@ -360,7 +360,7 @@ This password has been leaked in a data breach, it must not be used. Please use another password. - To geslo je ušlo pri kršitvi varnosti podatkov in ga ne smete uporabljati. Prosimo, uporabite drugo geslo. + To geslo je bilo razkrito v varnostnem incidentu in se ne sme uporabiti. Prosimo, uporabite drugo geslo. This value should be between {{ min }} and {{ max }}. @@ -556,95 +556,95 @@ This value is not valid XML. - Ta vrednost ni veljaven XML. + Ta vrednost ni veljaven XML. This value does not conform to the expected XSD schema. - Ta vrednost ni skladna s pričakovano shemo XSD. + Ta vrednost ni skladna s pričakovano shemo XSD. This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes. - Ta vsebina XML je prevelika ({{ size }} bajtov): presega omejitev {{ limit }} bajtov. + Ta vsebina XML je prevelika ({{ size }} bajtov): presega omejitev {{ limit }} bajtov. This value is not a valid cron expression. - Ta vrednost ni veljaven izraz cron. + Ta vrednost ni veljaven cron izraz. The referenced entity does not exist. - Referencirana entiteta ne obstaja. + Referencirana entiteta ne obstaja. This value is not a valid ULID. - Ta vrednost ni veljaven ULID. + Ta vrednost ni veljaven ULID. This file is not a valid audio file. - Ta datoteka ni veljavna zvočna datoteka. + Ta datoteka ni veljavna zvočna datoteka. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Zvočni posnetek je prekratek ({{ duration }} sekund). Najmanjše pričakovano trajanje je {{ min_duration }} sekund. + Zvočni posnetek je prekratek ({{ duration }} sekund). Najmanjše pričakovano trajanje je {{ min_duration }} sekund. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Zvočni posnetek je predolg ({{ duration }} sekund). Največje dovoljeno trajanje je {{ max_duration }} sekund. + Zvočni posnetek je predolg ({{ duration }} sekund). Največje dovoljeno trajanje je {{ max_duration }} sekund. The duration of the audio could not be detected. - Trajanja zvočnega posnetka ni bilo mogoče določiti. + Trajanja zvočnega posnetka ni bilo mogoče določiti. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Bitna hitrost zvočnega posnetka je prenizka ({{ bitrate }} bps). Najmanjša pričakovana bitna hitrost je {{ min_bitrate }} bps. + Bitna hitrost zvočnega posnetka je prenizka ({{ bitrate }} bps). Najmanjša pričakovana bitna hitrost je {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Bitna hitrost zvočnega posnetka je previsoka ({{ bitrate }} bps). Največja dovoljena bitna hitrost je {{ max_bitrate }} bps. + Bitna hitrost zvočnega posnetka je previsoka ({{ bitrate }} bps). Največja dovoljena bitna hitrost je {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Bitne hitrosti zvočnega posnetka ni bilo mogoče določiti. + Bitne hitrosti zvočnega posnetka ni bilo mogoče zaznati. Unsupported audio sample rate ({{ sample_rate }} Hz). - Nepodprta frekvenca vzorčenja zvoka ({{ sample_rate }} Hz). + Nepodprta frekvenca vzorčenja zvoka ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Frekvence vzorčenja zvočnega posnetka ni bilo mogoče določiti. + Hitrosti vzorčenja zvočnega posnetka ni bilo mogoče zaznati. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Zvočni posnetek ima premalo kanalov ({{ channels }}). Najmanjše pričakovano število je {{ min_channels }}. + Zvočni posnetek ima premalo kanalov ({{ channels }}). Najmanjše pričakovano število je {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Zvočni posnetek ima preveč kanalov ({{ channels }}). Največje pričakovano število je {{ max_channels }}. + Zvočni posnetek ima preveč kanalov ({{ channels }}). Največje pričakovano število je {{ max_channels }}. The number of audio channels could not be detected. - Števila zvočnih kanalov ni bilo mogoče določiti. + Števila zvočnih kanalov ni bilo mogoče zaznati. The audio file is corrupted. - Zvočna datoteka je poškodovana. + Zvočna datoteka je poškodovana. The audio contains multiple streams. Only one stream is allowed. - Zvočni posnetek vsebuje več tokov. Dovoljen je le en tok. + Zvočni posnetek vsebuje več tokov. Dovoljen je le en tok. Unsupported audio codec "{{ codec }}". - Nepodprt zvočni kodek "{{ codec }}". + Nepodprt zvočni kodek "{{ codec }}". Unsupported audio container "{{ container }}". - Nepodprt zvočni vsebnik "{{ container }}". + Nepodprt zvočni vsebnik "{{ container }}". The file does not contain any audio stream. - Datoteka ne vsebuje nobenega zvočnega toka. + Datoteka ne vsebuje nobenega zvočnega toka. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.th.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.th.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.th.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.th.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - ค่านี้สั้นเกินไป ควรมีอย่างน้อยหนึ่งคำ|ค่านี้สั้นเกินไป ควรมีอย่างน้อย {{ min }} คำ + ค่านี้สั้นเกินไป ควรมีอย่างน้อย {{ min }} คำ This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - ค่านี้ยาวเกินไป ควรมีเพียงคำเดียว|ค่านี้ยาวเกินไป ควรมีไม่เกิน {{ max }} คำ + ค่านี้ยาวเกินไป ควรมีไม่เกิน {{ max }} คำ This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tl.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tl.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tl.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tl.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Masyadong maikli ang halagang ito. Dapat itong maglaman ng hindi bababa sa isang salita.|Masyadong maikli ang halagang ito. Dapat itong maglaman ng hindi bababa sa {{ min }} salita. + Masyadong maikli ang halagang ito. Dapat itong maglaman ng hindi bababa sa {{ min }} salita. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Masyadong mahaba ang halagang ito. Dapat itong maglaman lamang ng isang salita.|Masyadong mahaba ang halagang ito. Dapat itong maglaman ng {{ max }} salita o mas kaunti. + Masyadong mahaba ang halagang ito. Dapat itong maglaman ng {{ max }} salita o mas kaunti. This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tr.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tr.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tr.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.tr.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Bu değer çok kısa. En az bir kelime içermelidir.|Bu değer çok kısa. En az {{ min }} kelime içermelidir. + Bu değer çok kısa. En az {{ min }} kelime içermelidir. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Bu değer çok uzun. Tek bir kelime içermelidir.|Bu değer çok uzun. {{ max }} veya daha az kelime içermelidir. + Bu değer çok uzun. {{ max }} veya daha az kelime içermelidir. This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uk.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uk.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uk.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uk.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -572,79 +572,79 @@ The referenced entity does not exist. - Сутність, на яку посилаються, не існує. + Сутність, на яку посилаються, не існує. This value is not a valid ULID. - Це значення не є дійсним ULID. + Це значення не є дійсним ULID. This file is not a valid audio file. - Цей файл не є допустимим аудіофайлом. + Цей файл не є допустимим аудіофайлом. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Аудіофайл занадто короткий ({{ duration }} секунд). Мінімально допустима тривалість {{ min_duration }} секунд. + Аудіофайл занадто короткий ({{ duration }} секунд). Мінімально допустима тривалість {{ min_duration }} секунд. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Аудіофайл занадто довгий ({{ duration }} секунд). Максимально допустима тривалість {{ max_duration }} секунд. + Аудіофайл занадто довгий ({{ duration }} секунд). Максимально допустима тривалість {{ max_duration }} секунд. The duration of the audio could not be detected. - Не вдалося визначити тривалість аудіофайлу. + Не вдалося визначити тривалість аудіофайлу. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Бітрейт аудіофайлу занадто низький ({{ bitrate }} bps). Мінімально допустимий бітрейт {{ min_bitrate }} bps. + Бітрейт аудіофайлу занадто низький ({{ bitrate }} bps). Мінімально допустимий бітрейт {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Бітрейт аудіофайлу занадто високий ({{ bitrate }} bps). Максимально допустимий бітрейт {{ max_bitrate }} bps. + Бітрейт аудіофайлу занадто високий ({{ bitrate }} bps). Максимально допустимий бітрейт {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Не вдалося визначити бітрейт аудіофайлу. + Не вдалося визначити бітрейт аудіофайлу. Unsupported audio sample rate ({{ sample_rate }} Hz). - Непідтримувана частота дискретизації аудіо ({{ sample_rate }} Hz). + Непідтримувана частота дискретизації аудіо ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Не вдалося визначити частоту дискретизації аудіофайлу. + Не вдалося визначити частоту дискретизації аудіофайлу. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Кількість каналів в аудіофайлі занадто мала ({{ channels }}). Мінімально допустима кількість {{ min_channels }}. + Кількість каналів в аудіофайлі занадто мала ({{ channels }}). Мінімально допустима кількість {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Кількість каналів в аудіофайлі занадто велика ({{ channels }}). Максимально допустима кількість {{ max_channels }}. + Кількість каналів в аудіофайлі занадто велика ({{ channels }}). Максимально допустима кількість {{ max_channels }}. The number of audio channels could not be detected. - Не вдалося визначити кількість аудіоканалів. + Не вдалося визначити кількість аудіоканалів. The audio file is corrupted. - Аудіофайл пошкоджено. + Аудіофайл пошкоджено. The audio contains multiple streams. Only one stream is allowed. - Аудіофайл містить кілька потоків. Дозволено лише один потік. + Аудіофайл містить кілька потоків. Дозволено лише один потік. Unsupported audio codec "{{ codec }}". - Непідтримуваний аудіокодек «{{ codec }}». + Непідтримуваний аудіокодек "{{ codec }}". Unsupported audio container "{{ container }}". - Непідтримуваний аудіоконтейнер "{{ container }}". + Непідтримуваний аудіоконтейнер "{{ container }}". The file does not contain any audio stream. - Файл не містить аудіопотоку. + Файл не містить аудіопотоку. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uz.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uz.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uz.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.uz.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Bu qiymat juda qisqa. U kamida bitta so'z bo'lishi kerak.|Bu qiymat juda qisqa. U kamida {{ min }} so'z bo'lishi kerak. + Bu qiymat juda qisqa. U kamida {{ min }} so'z bo'lishi kerak. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Bu qiymat juda uzun. U faqat bitta so'z bo'lishi kerak.|Bu qiymat juda uzun. U {{ max }} so'z yoki undan kam bo'lishi kerak. + Bu qiymat juda uzun. U {{ max }} so'z yoki undan kam bo'lishi kerak. This value does not represent a valid week in the ISO 8601 format. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.vi.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.vi.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.vi.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.vi.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -12,7 +12,7 @@ This value should be of type {{ type }}. - Giá trị này phải là kiểu {{ type }}. + Giá trị này phải là kiểu {{ type }}. This value should be blank. @@ -28,7 +28,7 @@ You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. - Bạn phải chọn nhiều nhất {{ limit }} lựa chọn.|Bạn phải chọn nhiều nhất {{ limit }} lựa chọn. + Bạn phải chọn nhiều nhất {{ limit }} lựa chọn.|Bạn phải chọn nhiều nhất {{ limit }} lựa chọn. One or more of the given values is invalid. @@ -36,11 +36,11 @@ This field was not expected. - Lĩnh vực này không được dự kiến. + Trường này không được chấp nhận. This field is missing. - Lĩnh vực này bị thiếu. + Trường này bị thiếu. This value is not a valid date. @@ -128,7 +128,7 @@ This value should be a valid number. - Giá trị phải là con số. + Giá trị này phải là số hợp lệ. This file is not a valid image. @@ -136,7 +136,7 @@ This value is not a valid IP address. - Giá trị này không phải là địa chỉ IP hợp lệ. + Giá trị này không phải là địa chỉ IP hợp lệ. This value is not a valid language. @@ -144,7 +144,7 @@ This value is not a valid locale. - Giá trị không phải là một bản địa địa phương hợp lệ. + Giá trị này không phải là mã ngôn ngữ và khu vực hợp lệ. This value is not a valid country. @@ -192,7 +192,7 @@ No temporary folder was configured in php.ini, or the configured folder does not exist. - Không có thư mục tạm được cấu hình trong php.ini, hoặc thư mục đã cấu hình không tồn tại. + Chưa cấu hình thư mục tạm trong php.ini, hoặc thư mục đã cấu hình không tồn tại. Cannot write temporary file to disk. @@ -224,7 +224,7 @@ This value is not a valid International Bank Account Number (IBAN). - Giá trị này không phải là Số Tài Khoản Ngân Hàng Quốc Tế (IBAN) hợp lệ. + Giá trị này không phải là số tài khoản ngân hàng quốc tế (IBAN) hợp lệ. This value is not a valid ISBN-10. @@ -260,7 +260,7 @@ This value should be identical to {{ compared_value_type }} {{ compared_value }}. - Giá trị phải giống {{ compared_value_type }} {{ compared_value }}. + Giá trị phải giống {{ compared_value_type }} {{ compared_value }}. This value should be less than {{ compared_value }}. @@ -312,7 +312,7 @@ This value is not a valid Business Identifier Code (BIC). - Giá trị này không phải là Mã Định Danh Doanh Nghiệp (BIC) hợp lệ. + Giá trị này không phải là mã định danh doanh nghiệp (BIC) hợp lệ. Error @@ -320,7 +320,7 @@ This value is not a valid UUID. - Giá trị này không phải là UUID hợp lệ. + Giá trị này không phải là UUID hợp lệ. This value should be a multiple of {{ compared_value }}. @@ -340,19 +340,19 @@ This value should be positive. - Giá trị này có thể thực hiện được. + Giá trị này phải là số dương. This value should be either positive or zero. - Giá trị này có thể thực hiện được hoặc bằng không. + Giá trị này phải là số dương hoặc bằng không. This value should be negative. - Giá trị này nên bị từ chối. + Giá trị này phải là số âm. This value should be either negative or zero. - Giá trị này nên bị từ chối hoặc bằng không. + Giá trị này phải là số âm hoặc bằng không. This value is not a valid timezone. @@ -420,7 +420,7 @@ Mixing numbers from different scripts is not allowed. - Không được phép trộn các số từ các tập lệnh khác nhau. + Không được phép trộn chữ số từ các hệ chữ viết khác nhau. Using hidden overlay characters is not allowed. @@ -432,219 +432,219 @@ The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. - Mã hóa ký tự được phát hiện là không hợp lệ ({{ detected }}). Các mã hóa được phép là {{ encodings }}. + Bảng mã ký tự phát hiện được không hợp lệ ({{ detected }}). Các bảng mã cho phép là {{ encodings }}. This value is not a valid MAC address. - Giá trị này không phải là địa chỉ MAC hợp lệ. + Giá trị này không phải là địa chỉ MAC hợp lệ. This URL is missing a top-level domain. - URL này thiếu miền cấp cao. + URL này thiếu tên miền cấp cao nhất. This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - Giá trị này quá ngắn. Nó phải chứa ít nhất một từ.|Giá trị này quá ngắn. Nó phải chứa ít nhất {{ min }} từ. + Giá trị này quá ngắn. Giá trị phải chứa ít nhất {{ min }} từ. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - Giá trị này quá dài. Nó chỉ nên chứa một từ.|Giá trị này quá dài. Nó chỉ nên chứa {{ max }} từ hoặc ít hơn. + Giá trị này quá dài. Giá trị chỉ được chứa tối đa {{ max }} từ. This value does not represent a valid week in the ISO 8601 format. - Giá trị này không đại diện cho một tuần hợp lệ theo định dạng ISO 8601. + Giá trị này không phải là tuần hợp lệ theo định dạng ISO 8601. This value is not a valid week. - Giá trị này không phải là một tuần hợp lệ. + Giá trị này không phải là một tuần hợp lệ. This value should not be before week "{{ min }}". - Giá trị này không nên trước tuần "{{ min }}". + Giá trị này không được trước tuần "{{ min }}". This value should not be after week "{{ max }}". - Giá trị này không nên sau tuần "{{ max }}". + Giá trị này không được sau tuần "{{ max }}". This value is not a valid Twig template. - Giá trị này không phải là một mẫu Twig hợp lệ. + Giá trị này không phải là một mẫu Twig hợp lệ. This file is not a valid video. - Tệp này không phải là video hợp lệ. + Tệp này không phải là video hợp lệ. The size of the video could not be detected. - Không thể phát hiện kích thước của video. + Không thể xác định kích thước của video. The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - Chiều rộng video quá lớn ({{ width }}px). Chiều rộng tối đa cho phép là {{ max_width }}px. + Chiều rộng video quá lớn ({{ width }}px). Chiều rộng tối đa cho phép là {{ max_width }}px. The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - Chiều rộng video quá nhỏ ({{ width }}px). Chiều rộng tối thiểu mong đợi là {{ min_width }}px. + Chiều rộng video quá nhỏ ({{ width }}px). Chiều rộng tối thiểu yêu cầu là {{ min_width }}px. The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - Chiều cao video quá lớn ({{ height }}px). Chiều cao tối đa cho phép là {{ max_height }}px. + Chiều cao video quá lớn ({{ height }}px). Chiều cao tối đa cho phép là {{ max_height }}px. The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - Chiều cao video quá nhỏ ({{ height }}px). Chiều cao tối thiểu dự kiến là {{ min_height }}px. + Chiều cao video quá nhỏ ({{ height }}px). Chiều cao tối thiểu yêu cầu là {{ min_height }}px. The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - Video có quá ít điểm ảnh ({{ pixels }}). Lượng tối thiểu mong đợi là {{ min_pixels }}. + Video có quá ít điểm ảnh ({{ pixels }} điểm ảnh). Số điểm ảnh tối thiểu yêu cầu là {{ min_pixels }}. The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - Video có quá nhiều điểm ảnh ({{ pixels }}). Số lượng tối đa dự kiến là {{ max_pixels }}. + Video có quá nhiều điểm ảnh ({{ pixels }} điểm ảnh). Số điểm ảnh tối đa cho phép là {{ max_pixels }}. The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - Tỉ lệ video quá lớn ({{ ratio }}). Tỉ lệ tối đa được phép là {{ max_ratio }}. + Tỷ lệ khung hình video quá lớn ({{ ratio }}). Tỷ lệ tối đa cho phép là {{ max_ratio }}. The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - Tỉ lệ video quá nhỏ ({{ ratio }}). Tỉ lệ tối thiểu dự kiến là {{ min_ratio }}. + Tỷ lệ khung hình video quá nhỏ ({{ ratio }}). Tỷ lệ tối thiểu yêu cầu là {{ min_ratio }}. The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. - Video có dạng hình vuông ({{ width }}x{{ height }}px). Không cho phép video hình vuông. + Video có dạng hình vuông ({{ width }}x{{ height }}px). Không cho phép video hình vuông. The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. - Video có hướng ngang ({{ width }}x{{ height }} px). Không cho phép video ngang. + Video có hướng ngang ({{ width }}x{{ height }}px). Không cho phép video hướng ngang. The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. - Video có hướng dọc ({{ width }}x{{ height }}px). Không cho phép video hướng dọc. + Video có hướng dọc ({{ width }}x{{ height }}px). Không cho phép video hướng dọc. The video file is corrupted. - Tệp video bị hỏng. + Tệp video bị hỏng. The video contains multiple streams. Only one stream is allowed. - Video chứa nhiều luồng. Chỉ cho phép một luồng. + Video chứa nhiều luồng. Chỉ cho phép một luồng. Unsupported video codec "{{ codec }}". - Bộ mã hóa video không được hỗ trợ "{{ codec }}". + Không hỗ trợ codec video "{{ codec }}". Unsupported video container "{{ container }}". - Bộ chứa video không được hỗ trợ "{{ container }}". + Không hỗ trợ định dạng chứa video "{{ container }}". The image file is corrupted. - Tệp hình ảnh bị hỏng. + Tệp hình ảnh bị hỏng. The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - Hình ảnh có quá ít điểm ảnh ({{ pixels }}). Số lượng tối thiểu dự kiến là {{ min_pixels }}. + Hình ảnh có quá ít điểm ảnh ({{ pixels }} điểm ảnh). Số điểm ảnh tối thiểu yêu cầu là {{ min_pixels }}. The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - Hình ảnh có quá nhiều điểm ảnh ({{ pixels }}). Lượng tối đa dự kiến là {{ max_pixels }}. + Hình ảnh có quá nhiều điểm ảnh ({{ pixels }} điểm ảnh). Số điểm ảnh tối đa cho phép là {{ max_pixels }}. This filename does not match the expected charset. - Tên tệp này không khớp với bộ ký tự mong đợi. + Tên tệp này không khớp với bảng mã ký tự yêu cầu. This value is not valid XML. - Giá trị này không phải là XML hợp lệ. + Giá trị này không phải là XML hợp lệ. This value does not conform to the expected XSD schema. - Giá trị này không tuân theo lược đồ XSD mong đợi. + Giá trị này không tuân theo lược đồ XSD yêu cầu. This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes. - Tải trọng XML này quá lớn ({{ size }} byte): vượt quá giới hạn {{ limit }} byte. + Dữ liệu XML này quá lớn ({{ size }} byte): vượt quá giới hạn {{ limit }} byte. This value is not a valid cron expression. - Giá trị này không phải là một biểu thức cron hợp lệ. + Giá trị này không phải là một biểu thức cron hợp lệ. The referenced entity does not exist. - Thực thể được tham chiếu không tồn tại. + Thực thể được tham chiếu không tồn tại. This value is not a valid ULID. - Giá trị này không phải là ULID hợp lệ. + Giá trị này không phải là ULID hợp lệ. This file is not a valid audio file. - Tệp này không phải là tệp âm thanh hợp lệ. + Tệp này không phải là tệp âm thanh hợp lệ. The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - Âm thanh quá ngắn ({{ duration }} giây). Thời lượng tối thiểu mong đợi là {{ min_duration }} giây. + Âm thanh quá ngắn ({{ duration }} giây). Thời lượng tối thiểu yêu cầu là {{ min_duration }} giây. The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - Âm thanh quá dài ({{ duration }} giây). Thời lượng tối đa cho phép là {{ max_duration }} giây. + Âm thanh quá dài ({{ duration }} giây). Thời lượng tối đa cho phép là {{ max_duration }} giây. The duration of the audio could not be detected. - Không thể phát hiện thời lượng của âm thanh. + Không thể xác định thời lượng của âm thanh. The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - Bitrate của âm thanh quá thấp ({{ bitrate }} bps). Bitrate tối thiểu mong đợi là {{ min_bitrate }} bps. + Bitrate của âm thanh quá thấp ({{ bitrate }} bps). Bitrate tối thiểu yêu cầu là {{ min_bitrate }} bps. The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - Bitrate của âm thanh quá cao ({{ bitrate }} bps). Bitrate tối đa cho phép là {{ max_bitrate }} bps. + Bitrate của âm thanh quá cao ({{ bitrate }} bps). Bitrate tối đa cho phép là {{ max_bitrate }} bps. The bitrate of the audio could not be detected. - Không thể phát hiện bitrate của âm thanh. + Không thể xác định bitrate của âm thanh. Unsupported audio sample rate ({{ sample_rate }} Hz). - Tần số lấy mẫu âm thanh không được hỗ trợ ({{ sample_rate }} Hz). + Không hỗ trợ tần số lấy mẫu âm thanh ({{ sample_rate }} Hz). The sample rate of the audio could not be detected. - Không thể phát hiện tần số lấy mẫu của âm thanh. + Không thể xác định tần số lấy mẫu của âm thanh. The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - Âm thanh có quá ít kênh ({{ channels }}). Lượng tối thiểu mong đợi là {{ min_channels }}. + Âm thanh có quá ít kênh ({{ channels }}). Số kênh tối thiểu yêu cầu là {{ min_channels }}. The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - Âm thanh có quá nhiều kênh ({{ channels }}). Số lượng tối đa dự kiến là {{ max_channels }}. + Âm thanh có quá nhiều kênh ({{ channels }}). Số kênh tối đa cho phép là {{ max_channels }}. The number of audio channels could not be detected. - Không thể phát hiện số kênh âm thanh. + Không thể xác định số kênh âm thanh. The audio file is corrupted. - Tệp âm thanh bị hỏng. + Tệp âm thanh bị hỏng. The audio contains multiple streams. Only one stream is allowed. - Âm thanh chứa nhiều luồng. Chỉ cho phép một luồng. + Âm thanh chứa nhiều luồng. Chỉ cho phép một luồng. Unsupported audio codec "{{ codec }}". - Bộ mã hóa âm thanh không được hỗ trợ "{{ codec }}". + Không hỗ trợ codec âm thanh "{{ codec }}". Unsupported audio container "{{ container }}". - Bộ chứa âm thanh không được hỗ trợ "{{ container }}". + Không hỗ trợ định dạng chứa âm thanh "{{ container }}". The file does not contain any audio stream. - Tệp không chứa luồng âm thanh nào. + Tệp không chứa luồng âm thanh nào. diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_CN.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_CN.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_CN.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_CN.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -4,15 +4,15 @@ This value should be false. - 该值应为 false 。 + 该值应为 false。 This value should be true. - 该值应为 true 。 + 该值应为 true。 This value should be of type {{ type }}. - 该值的类型应为 {{ type }} 。 + 该值的类型应为 {{ type }}。 This value should be blank. @@ -28,7 +28,7 @@ You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. - 您最多能选择 {{ limit }} 个选项。 + 您最多只能选择 {{ limit }} 个选项。 One or more of the given values is invalid. @@ -48,7 +48,7 @@ This value is not a valid datetime. - 该值不是一个有效的日期时间(datetime)。 + 该值不是一个有效的日期和时间(datetime)。 This value is not a valid email address. @@ -64,15 +64,15 @@ The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. - 文件太大 ({{ size }} {{ suffix }}),文件大小不应超过 {{ limit }} {{ suffix }} 。 + 文件太大({{ size }} {{ suffix }}),大小不应超过 {{ limit }} {{ suffix }}。 The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. - 无效的文件类型 ({{ type }}) ,允许的文件类型有 {{ types }} 。 + 无效的文件类型({{ type }}),允许的文件类型有 {{ types }}。 This value should be {{ limit }} or less. - 该值应小于或等于 {{ limit }} 。 + 该值应小于或等于 {{ limit }}。 This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. @@ -80,7 +80,7 @@ This value should be {{ limit }} or more. - 该值应大于或等于 {{ limit }} 。 + 该值应大于或等于 {{ limit }}。 This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. @@ -92,11 +92,11 @@ This value should not be null. - 该值不应为 null 。 + 该值不应为 null。 This value should be null. - 该值应为 null 。 + 该值应为 null。 This value is not valid. @@ -108,15 +108,15 @@ This value is not a valid URL. - 该值不是一个有效的 URL 。 + 该值不是一个有效的 URL。 The two values should be equal. - 该两个变量值应相等。 + 两个值应相等。 The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. - 文件太大,文件大小不可以超过 {{ limit }} {{ suffix }} 。 + 文件太大,大小不应超过 {{ limit }} {{ suffix }}。 The file is too large. @@ -124,7 +124,7 @@ The file could not be uploaded. - 无法上传此文件。 + 无法上传该文件。 This value should be a valid number. @@ -160,19 +160,19 @@ The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - 图片太宽 ({{ width }}px),最大宽度为 {{ max_width }}px 。 + 图片太宽({{ width }} 像素),最大宽度为 {{ max_width }} 像素。 The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - 图片宽度不够 ({{ width }}px),最小宽度为 {{ min_width }}px 。 + 图片宽度不够({{ width }} 像素),最小宽度为 {{ min_width }} 像素。 The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - 图片太高 ({{ height }}px),最大高度为 {{ max_height }}px 。 + 图片太高({{ height }} 像素),最大高度为 {{ max_height }} 像素。 The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - 图片高度不够 ({{ height }}px),最小高度为 {{ min_height }}px 。 + 图片高度不够({{ height }} 像素),最小高度为 {{ min_height }} 像素。 This value should be the user's current password. @@ -184,7 +184,7 @@ The file was only partially uploaded. - 文件的上传不完整。 + 文件上传不完整。 No file was uploaded. @@ -248,15 +248,15 @@ This value should be equal to {{ compared_value }}. - 该值应等于 {{ compared_value }} 。 + 该值应等于 {{ compared_value }}。 This value should be greater than {{ compared_value }}. - 该值应大于 {{ compared_value }} 。 + 该值应大于 {{ compared_value }}。 This value should be greater than or equal to {{ compared_value }}. - 该值应大于或等于 {{ compared_value }} 。 + 该值应大于或等于 {{ compared_value }}。 This value should be identical to {{ compared_value_type }} {{ compared_value }}. @@ -264,15 +264,15 @@ This value should be less than {{ compared_value }}. - 该值应小于 {{ compared_value }} 。 + 该值应小于 {{ compared_value }}。 This value should be less than or equal to {{ compared_value }}. - 该值应小于或等于 {{ compared_value }} 。 + 该值应小于或等于 {{ compared_value }}。 This value should not be equal to {{ compared_value }}. - 该值不应等于 {{ compared_value }} 。 + 该值不应等于 {{ compared_value }}。 This value should not be identical to {{ compared_value_type }} {{ compared_value }}. @@ -280,23 +280,23 @@ The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - 图片宽高比太大 ({{ ratio }})。允许的最大宽高比为 {{ max_ratio }} 。 + 图片比例太大({{ ratio }}),允许的最大比例为 {{ max_ratio }}。 The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - 图片宽高比太小 ({{ ratio }})。允许的最大宽高比为 {{ min_ratio }} 。 + 图片比例太小({{ ratio }}),允许的最小比例为 {{ min_ratio }}。 The image is square ({{ width }}x{{ height }}px). Square images are not allowed. - 图片是方形的 ({{ width }}x{{ height }}px)。不允许使用方形的图片。 + 图片是正方形的({{ width }}x{{ height }} 像素),不允许使用正方形的图片。 The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. - 图片是横向的 ({{ width }}x{{ height }}px)。不允许使用横向的图片。 + 图片是横向的({{ width }}x{{ height }} 像素)。不允许使用横向的图片。 The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. - 图片是纵向的 ({{ width }}x{{ height }}px)。不允许使用纵向的图片。 + 图片是纵向的({{ width }}x{{ height }} 像素)。不允许使用纵向的图片。 An empty file is not allowed. @@ -304,7 +304,7 @@ The host could not be resolved. - 主机名无法解析。 + 主机名称无法解析。 This value does not match the expected {{ charset }} charset. @@ -320,7 +320,7 @@ This value is not a valid UUID. - 该值不是有效的 UUID 。 + 该值不是有效的 UUID。 This value should be a multiple of {{ compared_value }}. @@ -332,7 +332,7 @@ This value should be valid JSON. - 该值应为有效的 JSON 。 + 该值应为有效的 JSON。 This collection should contain only unique elements. @@ -360,7 +360,7 @@ This password has been leaked in a data breach, it must not be used. Please use another password. - 此密码已被泄露,切勿使用。请更换密码。 + 该密码已被泄露,切勿使用。请更换密码。 This value should be between {{ min }} and {{ max }}. @@ -388,7 +388,7 @@ This value should be a valid expression. - 该值应为一个有效的表达式。 + 该值应为有效的表达式。 This value is not a valid CSS color. @@ -404,7 +404,7 @@ The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. - 文件名过长,最长不应超过{{ filename_max_length }} 个字符。 + 文件名过长,最长不应超过 {{ filename_max_length }} 个字符。 The password strength is too low. Please use a stronger password. @@ -428,11 +428,11 @@ The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. - 文件的扩展名无效 ({{ extension }})。允许的扩展名为 {{ extensions }} 。 + 文件的扩展名无效({{ extension }}),允许的扩展名为 {{ extensions }}。 The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. - 检测到的字符编码无效 ({{ detected }})。允许的编码为 {{ encodings }} 。 + 检测到的字符编码无效({{ detected }}),允许的编码为 {{ encodings }}。 This value is not a valid MAC address. @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - 该值太短,应至少包含一个词。|该值太短,应至少包含 {{ min }} 个词。 + 该值太短,应至少包含 {{ min }} 个词。 This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - 该值太长,应只包含一个词。|该值太长,应只包含 {{ max }} 个或更少个词。 + 该值太长,应只包含 {{ max }} 个或更少个词。 This value does not represent a valid week in the ISO 8601 format. @@ -460,11 +460,11 @@ This value should not be before week "{{ min }}". - 该值不应位于 "{{ min }}" 周之前。 + 该值不应位于 {{ min }} 周之前。 This value should not be after week "{{ max }}". - 该值不应位于 "{{ max }}"周之后。 + 该值不应位于 {{ max }} 周之后。 This value is not a valid Twig template. @@ -472,7 +472,7 @@ This file is not a valid video. - 此文件不是有效的视频。 + 该文件不是有效的视频。 The size of the video could not be detected. @@ -480,47 +480,47 @@ The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - 视频宽度过大({{ width }}px)。允许的最大宽度为 {{ max_width }}px。 + 视频宽度过大({{ width }} 像素),允许的最大宽度为 {{ max_width }} 像素。 The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - 视频宽度太小({{ width }}px)。预期的最小宽度为 {{ min_width }} 像素。 + 视频宽度太小({{ width }} 像素),预期的最小宽度为 {{ min_width }} 像素。 The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - 视频高度过大({{ height }}px)。允许的最大高度为 {{ max_height }}px。 + 视频高度过大({{ height }} 像素),允许的最大高度为 {{ max_height }} 像素。 The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - 视频高度太小({{ height }}px)。期望的最小高度为 {{ min_height }}px。 + 视频高度太小({{ height }} 像素),期望的最小高度为 {{ min_height }} 像素。 The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - 该视频像素过少 ({{ pixels }}). 期望的最小值为 {{ min_pixels }}。 + 该视频像素太少({{ pixels }} 像素),期望的最小值为 {{ min_pixels }} 像素。 The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - 该视频的像素过多({{ pixels }})。预期的最大数量为 {{ max_pixels }}。 + 该视频的像素太多({{ pixels }} 像素),预期的最大数量为 {{ max_pixels }} 像素。 The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - 视频纵横比过大({{ ratio }})。允许的最大纵横比为 {{ max_ratio }}。 + 视频比例过大({{ ratio }}),允许的最大比例为 {{ max_ratio }}。 The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - 视频纵横比过小({{ ratio }})。预期的最低比例为 {{ min_ratio }}。 + 视频比例过小({{ ratio }}),预期的最低比例为 {{ min_ratio }}。 The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. - 视频为正方形 ({{ width }}x{{ height }}px)。不允许正方形视频。 + 视频为正方形({{ width }}x{{ height }} 像素),不允许正方形视频。 The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. - 视频为横向({{ width }}x{{ height }} 像素)。不允许横向视频。 + 视频为横向({{ width }}x{{ height }} 像素),不允许横向视频。 The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. - 该视频为竖屏方向({{ width }}x{{ height }}px)。不允许竖屏视频。 + 该视频为竖屏方向({{ width }}x{{ height }} 像素),不允许竖屏视频。 The video file is corrupted. @@ -528,15 +528,15 @@ The video contains multiple streams. Only one stream is allowed. - 该视频包含多个流。只允许一个流。 + 该视频包含多个流,只允许一个流。 Unsupported video codec "{{ codec }}". - 不支持的视频编解码器“{{ codec }}”。 + 不支持的视频编解码器 “{{ codec }}”。 Unsupported video container "{{ container }}". - 不支持的视频容器 "{{ container }}". + 不支持的视频容器 “{{ container }}”。 The image file is corrupted. @@ -544,11 +544,11 @@ The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - 图像的像素太少({{ pixels }})。预期的最小数量为 {{ min_pixels }}。 + 图像的像素太少({{ pixels }}),预期的最小数量为 {{ min_pixels }}。 The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - 图像的像素过多 ({{ pixels }}). 预期的最大数量为 {{ max_pixels }}. + 图像的像素太多({{ pixels }}),预期的最大数量为 {{ max_pixels }}。 This filename does not match the expected charset. @@ -556,95 +556,95 @@ This value is not valid XML. - 该值不是有效的 XML。 + 该值不是有效的 XML。 This value does not conform to the expected XSD schema. - 该值不符合预期的 XSD 架构。 + 该值不符合预期的 XSD 架构。 This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes. - 此 XML 负载过大({{ size }} 字节):超过了 {{ limit }} 字节的限制。 + 该 XML 负载过大({{ size }} 字节),超过了 {{ limit }} 字节的限制。 This value is not a valid cron expression. - 该值不是有效的 cron 表达式。 + 该值不是有效的 cron 表达式。 The referenced entity does not exist. - 引用的实体不存在。 + 引用的实体不存在。 This value is not a valid ULID. - 该值不是有效的 ULID 。 + 该值不是有效的 ULID。 This file is not a valid audio file. - 此文件不是有效的音频文件。 + 该文件不是有效的音频文件。 The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - 音频时长过短({{ duration }} 秒)。期望的最小时长为 {{ min_duration }} 秒。 + 音频时长过短({{ duration }} 秒),最小时长为 {{ min_duration }} 秒。 The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - 音频时长过长({{ duration }} 秒)。允许的最大时长为 {{ max_duration }} 秒。 + 音频时长过长({{ duration }} 秒),最大时长为 {{ max_duration }} 秒。 The duration of the audio could not be detected. - 无法检测到音频的时长。 + 无法检测到音频的时长。 The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - 音频比特率过低({{ bitrate }} bps)。期望的最小比特率为 {{ min_bitrate }} bps。 + 音频比特率过低({{ bitrate }} bps),最小比特率为 {{ min_bitrate }} bps。 The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - 音频比特率过高({{ bitrate }} bps)。允许的最大比特率为 {{ max_bitrate }} bps。 + 音频比特率过高({{ bitrate }} bps),最大比特率为 {{ max_bitrate }} bps。 The bitrate of the audio could not be detected. - 无法检测到音频的比特率。 + 无法检测到音频的比特率。 Unsupported audio sample rate ({{ sample_rate }} Hz). - 不支持的音频采样率({{ sample_rate }} Hz)。 + 不支持的音频采样率({{ sample_rate }} Hz)。 The sample rate of the audio could not be detected. - 无法检测到音频的采样率。 + 无法检测到音频的采样率。 The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - 该音频的声道过少({{ channels }})。期望的最小数量为 {{ min_channels }}。 + 音频的声道太少({{ channels }}),最小数量为 {{ min_channels }}。 The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - 该音频的声道过多({{ channels }})。期望的最大数量为 {{ max_channels }}。 + 音频的声道太多({{ channels }}),最大数量为 {{ max_channels }}。 The number of audio channels could not be detected. - 无法检测到音频声道的数量。 + 无法检测到音频声道的数量。 The audio file is corrupted. - 音频文件已损坏。 + 音频文件已损坏。 The audio contains multiple streams. Only one stream is allowed. - 该音频包含多个流。只允许一个流。 + 音频包含多个流,只允许一个流。 Unsupported audio codec "{{ codec }}". - 不支持的音频编解码器“{{ codec }}”。 + 不支持的音频编解码器 “{{ codec }}”。 Unsupported audio container "{{ container }}". - 不支持的音频容器“{{ container }}”。 + 不支持的音频容器 “{{ container }}”。 The file does not contain any audio stream. - 该文件不包含音频流。 + 文件不包含音频流。 diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_TW.xlf symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_TW.xlf --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_TW.xlf 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Resources/translations/validators.zh_TW.xlf 2026-11-04 11:23:00.000000000 +0000 @@ -160,19 +160,19 @@ The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - 圖片寬度過大 ({{ width }}px)。允許的最大寬度為 {{ max_width }}px。 + 圖片寬度過大({{ width }}px)。允許的最大寬度為 {{ max_width }}px。 The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - 圖片寬度過小 ({{ width }}px)。允許的最小寬度為 {{ min_width }}px。 + 圖片寬度過小({{ width }}px)。允許的最小寬度為 {{ min_width }}px。 The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - 圖片高度過大 ({{ height }}px)。允許的最大長度為 {{ max_height }}px。 + 圖片高度過大({{ height }}px)。允許的最大長度為 {{ max_height }}px。 The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - 圖片高度過小 ({{ height }}px)。允許的最小高度為 {{ min_height }}px。 + 圖片高度過小({{ height }}px)。允許的最小高度為 {{ min_height }}px。 This value should be the user's current password. @@ -280,23 +280,23 @@ The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - 圖片的比例過大 ({{ ratio }})。允許的最大比例為 {{ max_ratio }}。 + 圖片的比例過大({{ ratio }})。允許的最大比例為 {{ max_ratio }}。 The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - 圖片的比例過小 ({{ ratio }})。允許的最小比例為 {{ min_ratio }}。 + 圖片的比例過小({{ ratio }})。允許的最小比例為 {{ min_ratio }}。 The image is square ({{ width }}x{{ height }}px). Square images are not allowed. - 圖片為正方形 ({{ width }}x{{ height }}px)。不允許使用正方形圖片。 + 圖片為正方形({{ width }}x{{ height }}px)。不允許使用正方形圖片。 The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. - 圖片為橫向 ({{ width }}x{{ height }}px)。不允許使用橫向圖片。 + 圖片為橫向({{ width }}x{{ height }}px)。不允許使用橫向圖片。 The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. - 圖片為縱向 ({{ width }}x{{ height }}px)。不允許使用縱向圖片。 + 圖片為縱向({{ width }}x{{ height }}px)。不允許使用縱向圖片。 An empty file is not allowed. @@ -444,11 +444,11 @@ This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. - 這個值過短。應至少包含 1 個單字。|這個值過短。應至少包含 {{ min }} 個單字。 + 這個值過短。應至少包含 {{ min }} 個單字。 This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. - 這個值過長。應包含 1 個單字。|這個值過長。應最多包含 {{ max }} 個單字。 + 這個值過長。應最多包含 {{ max }} 個單字。 This value does not represent a valid week in the ISO 8601 format. @@ -480,47 +480,47 @@ The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. - 影片寬度過大 ({{ width }}px)。允許的最大寬度為 {{ max_width }}px。 + 影片寬度過大({{ width }}px)。允許的最大寬度為 {{ max_width }}px。 The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. - 影片寬度過小 ({{ width }}p)。允許的最小寬度為 {{ min_width }}px。 + 影片寬度過小({{ width }}px)。允許的最小寬度為 {{ min_width }}px。 The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. - 影片高度過大 ({{ height }}px)。允許的最大高度為 {{ max_height }}px。 + 影片高度過大({{ height }}px)。允許的最大高度為 {{ max_height }}px。 The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. - 影片高度過小 ({{ height }}px)。允許的最小高度為 {{ min_height }}px。 + 影片高度過小({{ height }}px)。允許的最小高度為 {{ min_height }}px。 The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - 影片的像素過少 ({{ pixels }} 像素). 允許的最小值為 {{ min_pixels }} 像素。 + 影片的像素過少({{ pixels }} 像素)。允許的最小值為 {{ min_pixels }} 像素。 The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - 影片的像素過多 ({{ pixels }} 像素)。允許的最大值為 {{ max_pixels }} 像素。 + 影片的像素過多({{ pixels }} 像素)。允許的最大值為 {{ max_pixels }} 像素。 The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. - 影片的比例過大 ({{ ratio }})。允許的最大比例為 {{ max_ratio }}。 + 影片的比例過大({{ ratio }})。允許的最大比例為 {{ max_ratio }}。 The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. - 影片的比例過小 ({{ ratio }})。允許的最小比例為 {{ min_ratio }}。 + 影片的比例過小({{ ratio }})。允許的最小比例為 {{ min_ratio }}。 The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. - 影片為正方形 ({{ width }}x{{ height }}px)。不允許使用正方形影片。 + 影片為正方形({{ width }}x{{ height }}px)。不允許使用正方形影片。 The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. - 影片為橫向 ({{ width }}x{{ height }}px)。不允許使用橫向影片。 + 影片為橫向({{ width }}x{{ height }}px)。不允許使用橫向影片。 The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. - 影片為縱向 ({{ width }}x{{ height }}px)。不允許使用縱向影片。 + 影片為縱向({{ width }}x{{ height }}px)。不允許使用縱向影片。 The video file is corrupted. @@ -532,11 +532,11 @@ Unsupported video codec "{{ codec }}". - 不支援的影片編解碼器: {{ codec }}。 + 不支援的影片編解碼器:{{ codec }}。 Unsupported video container "{{ container }}". - 不支援的影片容器格式: {{ container }}。 + 不支援的影片容器格式:{{ container }}。 The image file is corrupted. @@ -544,11 +544,11 @@ The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. - 圖片的像素過少 ({{ pixels }} 像素)。允許的最小值為 {{ min_pixels }} 像素。 + 圖片的像素過少({{ pixels }} 像素)。允許的最小值為 {{ min_pixels }} 像素。 The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. - 圖片的像素過多 ({{ pixels }} 像素)。允許的最大值為 {{ max_pixels }} 像素。 + 圖片的像素過多({{ pixels }} 像素)。允許的最大值為 {{ max_pixels }} 像素。 This filename does not match the expected charset. @@ -580,71 +580,71 @@ This file is not a valid audio file. - 這個檔案不是有效的音訊檔案。 + 這個檔案不是有效的音訊檔案。 The audio is too short ({{ duration }} seconds). Minimum duration expected is {{ min_duration }} seconds. - 音訊長度過短({{ duration }} 秒)。允許的最小長度為 {{ min_duration }} 秒。 + 音訊長度過短({{ duration }} 秒)。允許的最小長度為 {{ min_duration }} 秒。 The audio is too long ({{ duration }} seconds). Allowed maximum duration is {{ max_duration }} seconds. - 音訊長度過長({{ duration }} 秒)。允許的最大長度為 {{ max_duration }} 秒。 + 音訊長度過長({{ duration }} 秒)。允許的最大長度為 {{ max_duration }} 秒。 The duration of the audio could not be detected. - 無法偵測到音訊的長度。 + 無法偵測到音訊的長度。 The audio bitrate is too low ({{ bitrate }} bps). Minimum bitrate expected is {{ min_bitrate }} bps. - 音訊位元率過低({{ bitrate }} bps)。允許的最小位元率為 {{ min_bitrate }} bps。 + 音訊位元率過低({{ bitrate }} bps)。允許的最小位元率為 {{ min_bitrate }} bps。 The audio bitrate is too high ({{ bitrate }} bps). Allowed maximum bitrate is {{ max_bitrate }} bps. - 音訊位元率過高({{ bitrate }} bps)。允許的最大位元率為 {{ max_bitrate }} bps。 + 音訊位元率過高({{ bitrate }} bps)。允許的最大位元率為 {{ max_bitrate }} bps。 The bitrate of the audio could not be detected. - 無法偵測到音訊的位元率。 + 無法偵測到音訊的位元率。 Unsupported audio sample rate ({{ sample_rate }} Hz). - 不支援的音訊取樣率({{ sample_rate }} Hz)。 + 不支援的音訊取樣率({{ sample_rate }} Hz)。 The sample rate of the audio could not be detected. - 無法偵測到音訊的取樣率。 + 無法偵測到音訊的取樣率。 The audio has too few channels ({{ channels }}). Minimum amount expected is {{ min_channels }}. - 音訊的聲道過少({{ channels }})。允許的最小值為 {{ min_channels }}。 + 音訊的聲道過少({{ channels }})。允許的最小值為 {{ min_channels }}。 The audio has too many channels ({{ channels }}). Maximum amount expected is {{ max_channels }}. - 音訊的聲道過多({{ channels }})。允許的最大值為 {{ max_channels }}。 + 音訊的聲道過多({{ channels }})。允許的最大值為 {{ max_channels }}。 The number of audio channels could not be detected. - 無法偵測到音訊聲道的數量。 + 無法偵測到音訊聲道的數量。 The audio file is corrupted. - 音訊檔案已損壞。 + 音訊檔案已損壞。 The audio contains multiple streams. Only one stream is allowed. - 音訊包含多個串流。只允許單個串流。 + 音訊包含多個串流。只允許單個串流。 Unsupported audio codec "{{ codec }}". - 不支援的音訊編解碼器: {{ codec }}。 + 不支援的音訊編解碼器:{{ codec }}。 Unsupported audio container "{{ container }}". - 不支援的音訊容器格式: {{ container }}。 + 不支援的音訊容器格式:{{ container }}。 The file does not contain any audio stream. - 檔案不包含音訊串流。 + 檔案不包含音訊串流。 diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Constraints/IpValidatorTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Constraints/IpValidatorTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Constraints/IpValidatorTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Constraints/IpValidatorTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -168,6 +168,136 @@ } /** + * @dataProvider getValidPublicIpsV4 + */ + public function testValidPublicIpsV4($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::V4_ONLY_PUBLIC, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidPublicIpsV4 + */ + public function testValidNonPrivateIpsV4($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::V4_NO_PRIV, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidPublicIpsV4 + */ + public function testValidNonReservedIpsV4($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::V4_NO_RES, + ])); + + $this->assertNoViolation(); + } + + public static function getValidPublicIpsV4() + { + return [ + ['8.8.8.8'], + ['1.1.1.1'], + ['123.45.67.178'], + ['224.0.0.1'], + // just outside the non-public subnets + ['100.63.255.255'], + ['100.128.0.1'], + ['172.32.0.1'], + ['198.17.255.255'], + ['198.20.0.1'], + ]; + } + + /** + * @dataProvider getValidPublicIpsV6 + */ + public function testValidPublicIpsV6($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::V6_ONLY_PUBLIC, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidPublicIpsAll + */ + public function testValidPublicIpsAll($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::ALL_ONLY_PUBLIC, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidPublicIpsAll + */ + public function testValidNonPrivateIpsAll($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::ALL_NO_PRIV, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidPublicIpsAll + */ + public function testValidNonReservedIpsAll($ip) + { + $this->validator->validate($ip, new Ip([ + 'version' => Ip::ALL_NO_RES, + ])); + + $this->assertNoViolation(); + } + + public static function getValidPublicIpsV6() + { + return [ + ['2a01:198::1'], + ['2606:4700:4700::1111'], + ['2001:4860:4860::8888'], + // just outside the non-public subnets, including both edges of the + // ones whose prefix length is not a multiple of 8 + ['fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ['fe00::1'], + ['fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ['fec0::1'], + ['::1:0:0:0'], + ['2001:1::1'], + ['2001:1:ffff:ffff:ffff:ffff:ffff:ffff'], + ['2001:2:1::1'], + ['2001:db7:ffff:ffff:ffff:ffff:ffff:ffff'], + ['2001:db9::1'], + ['2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ['2003::1'], + ['64:ff9b::1:0:0'], + ['64:ff9b:2::1'], + ]; + } + + public static function getValidPublicIpsAll() + { + return array_merge(self::getValidPublicIpsV4(), self::getValidPublicIpsV6()); + } + + /** * @dataProvider getInvalidIpsV4 */ public function testInvalidIpsV4($ip) @@ -220,10 +350,49 @@ public static function getInvalidPrivateIpsV4() { + return self::getNonPublicIpsV4(); + } + + public static function getNonPublicIpsV4() + { return [ + // 0.0.0.0/8 "this" network, including the unspecified address + ['0.0.0.0'], + ['0.1.2.3'], + ['0.255.255.255'], + // 10.0.0.0/8 private use ['10.0.0.0'], + ['10.255.255.254'], + // 100.64.0.0/10 shared address space + ['100.64.0.1'], + ['100.127.255.254'], + // 127.0.0.0/8 loopback + ['127.0.0.1'], + ['127.255.255.254'], + // 169.254.0.0/16 link local + ['169.254.0.1'], + ['169.254.255.254'], + // 172.16.0.0/12 private use ['172.16.0.0'], + ['172.31.255.254'], + // 192.0.2.0/24 documentation TEST-NET-1 + ['192.0.2.1'], + ['192.0.2.254'], + // 192.168.0.0/16 private use ['192.168.1.0'], + ['192.168.255.254'], + // 198.18.0.0/15 benchmarking + ['198.18.0.1'], + ['198.19.255.254'], + // 198.51.100.0/24 documentation TEST-NET-2 + ['198.51.100.1'], + ['198.51.100.254'], + // 203.0.113.0/24 documentation TEST-NET-3 + ['203.0.113.1'], + ['203.0.113.254'], + // 240.0.0.0/4 reserved, including the broadcast address + ['240.0.0.1'], + ['255.255.255.255'], ]; } @@ -247,11 +416,7 @@ public static function getInvalidReservedIpsV4() { - return [ - ['0.0.0.0'], - ['240.0.0.1'], - ['255.255.255.255'], - ]; + return self::getNonPublicIpsV4(); } /** @@ -274,7 +439,7 @@ public static function getInvalidPublicIpsV4() { - return array_merge(self::getInvalidPrivateIpsV4(), self::getInvalidReservedIpsV4()); + return self::getNonPublicIpsV4(); } /** @@ -334,10 +499,78 @@ public static function getInvalidPrivateIpsV6() { - return [ + return array_merge([ ['fdfe:dcba:9876:ffff:fdc6:c46b:bb8f:7d4c'], ['fdc6:c46b:bb8f:7d4c:fdc6:c46b:bb8f:7d4c'], ['fdc6:c46b:bb8f:7d4c:0000:8a2e:0370:7334'], + ], self::getNonPublicIpsV6()); + } + + public static function getNonPublicIpsV6() + { + return [ + // ::1/128 loopback + ['::1'], + ['0:0:0:0:0:0:0:1'], + // fc00::/7 unique local address, both edges included + ['fc00::'], + ['fc00::1'], + ['fd00::1'], + ['fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + // fe80::/10 link local address, both edges included + ['fe80::'], + ['fe80::1'], + ['febf::1'], + ['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ['fe80:0000:0000:0000:0202:b3ff:fe1e:8329'], + // ::ffff:0:0/96 IPv4-mapped + ['::ffff:127.0.0.1'], + ['::ffff:7f00:1'], + ['::ffff:10.0.0.1'], + ['::ffff:0a00:1'], + ['::ffff:172.16.0.1'], + ['::ffff:192.168.1.1'], + ['::ffff:8.8.8.8'], + ['::ffff:0808:0808'], + ['0:0:0:0:0:ffff:192.168.1.1'], + ['0000:0000:0000:0000:0000:ffff:c0a8:0101'], + ['::ffff:ffff:ffff'], + // ::/96 IPv4-compatible, including the unspecified address + ['::'], + ['::127.0.0.1'], + ['::7f00:1'], + ['::10.0.0.1'], + ['::0a00:1'], + ['::8.8.8.8'], + ['::0808:0808'], + ['::ffff:ffff'], + // 2002::/16 6to4 + ['2002::'], + ['2002:7f00:1::'], + ['2002:808:808::1'], + ['2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + // 2001::/32 Teredo + ['2001::'], + ['2001:0:1::'], + ['2001:0000:4136:e378:8000:63bf:3fff:fdd2'], + ['2001:0:ffff:ffff:ffff:ffff:ffff:ffff'], + // 2001:db8::/32 documentation + ['2001:db8::1'], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334'], + ['2001:db8:ffff:ffff:ffff:ffff:ffff:ffff'], + // 2001:2::/48 benchmarking + ['2001:2::1'], + ['2001:0002:0000:1234::1'], + ['2001:2:0:ffff:ffff:ffff:ffff:ffff'], + // 64:ff9b::/96 NAT64 well-known prefix + ['64:ff9b::7f00:1'], + ['64:ff9b::127.0.0.1'], + ['64:ff9b::8.8.8.8'], + ['64:ff9b::ffff:ffff'], + // 64:ff9b:1::/48 NAT64 local-use prefix + ['64:ff9b:1::1'], + ['64:ff9b:1:1234::1'], + ['64:ff9b:1:ffff:ffff:ffff:ffff:ffff'], ]; } @@ -364,7 +597,7 @@ // Quoting after official filter documentation: // "FILTER_FLAG_NO_RES_RANGE = This flag does not apply to IPv6 addresses." // Full description: https://php.net/filter.filters.flags - return self::getInvalidIpsV6(); + return array_merge(self::getInvalidIpsV6(), self::getNonPublicIpsV6()); } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,7 +12,10 @@ namespace Symfony\Component\Validator\Tests\Mapping\Loader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\Iban; @@ -280,4 +283,60 @@ $this->assertCount(2, $classMetadata->getPropertyMetadata('autoMappingExplicitlyEnabled')[0]->constraints); $this->assertSame(AutoMappingStrategy::ENABLED, $classMetadata->getPropertyMetadata('autoMappingExplicitlyEnabled')[0]->getAutoMappingStrategy()); } + + public function testTypesAreOnlyExtractedForAutoMappedProperties() + { + $propertyListExtractor = $this->createStub(PropertyListExtractorInterface::class); + $propertyListExtractor + ->method('getProperties') + ->willReturn(['string', 'autoMappingExplicitlyEnabled']) + ; + + $propertyTypeExtractor = new class implements PropertyTypeExtractorInterface { + /** @var list */ + public array $extracted = []; + + public function getTypes(string $class, string $property, array $context = []): ?array + { + $this->extracted[] = $property; + + return [new Type(Type::BUILTIN_TYPE_STRING)]; + } + }; + + $propertyAccessExtractor = $this->createStub(PropertyAccessExtractorInterface::class); + + $propertyInfoLoader = new PropertyInfoLoader($propertyListExtractor, $propertyTypeExtractor, $propertyAccessExtractor); + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->addLoader($propertyInfoLoader) + ->getValidator() + ; + + $validator->getMetadataFor(new PropertyInfoLoaderNoAutoMappingEntity()); + + $this->assertSame(['autoMappingExplicitlyEnabled'], $propertyTypeExtractor->extracted); + } + + public function testTypeExtractorFailureDoesNotBreakLoadingWithoutAutoMapping() + { + $propertyListExtractor = $this->createStub(PropertyListExtractorInterface::class); + $propertyListExtractor + ->method('getProperties') + ->willReturn(['nullableString', 'string']) + ; + + $propertyTypeExtractor = new class implements PropertyTypeExtractorInterface { + public function getTypes(string $class, string $property, array $context = []): ?array + { + throw new \LogicException('The type cannot be extracted.'); + } + }; + + $propertyAccessExtractor = $this->createStub(PropertyAccessExtractorInterface::class); + + $propertyInfoLoader = new PropertyInfoLoader($propertyListExtractor, $propertyTypeExtractor, $propertyAccessExtractor); + + $this->assertFalse($propertyInfoLoader->loadClassMetadata(new ClassMetadata(PropertyInfoLoaderEntity::class))); + } } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Resources/TranslationFilesTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Resources/TranslationFilesTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Validator/Tests/Resources/TranslationFilesTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Validator/Tests/Resources/TranslationFilesTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Resources; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\IdentityTranslator; use Symfony\Component\Translation\Util\XliffUtils; class TranslationFilesTest extends TestCase @@ -76,6 +77,44 @@ } } } + + $this->assertSame([], $invalidUnits); + } + + public function testSingleFormLocalesDisplayTheCountOfPluralMessages() + { + $invalidUnits = []; + + foreach (glob(\dirname(__DIR__, 2).'/Resources/translations/*.xlf') as $filePath) { + $translator = new IdentityTranslator(); + $translator->setLocale(substr(basename($filePath), \strlen('validators.'), -\strlen('.xlf'))); + + // locales with a single plural form only display the first form of a message + if ('a' !== $translator->trans('a|b|c|d|e|f', ['%count%' => 2])) { + continue; + } + + $document = new \DOMDocument(); + $document->load($filePath); + + foreach ($document->getElementsByTagName('trans-unit') as $unit) { + $sourceForms = explode('|', $unit->getElementsByTagName('source')->item(0)->textContent); + + if (1 === \count($sourceForms)) { + continue; + } + + $firstTargetForm = explode('|', $unit->getElementsByTagName('target')->item(0)->textContent)[0]; + preg_match_all('/\{\{ \w++ \}\}/', end($sourceForms), $placeholders); + + foreach ($placeholders[0] as $placeholder) { + if (!str_contains($firstTargetForm, $placeholder)) { + $invalidUnits[] = basename($filePath).' #'.$unit->getAttribute('id'); + break; + } + } + } + } $this->assertSame([], $invalidUnits); } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Escaper.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Escaper.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Escaper.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Escaper.php 2026-11-04 11:23:00.000000000 +0000 @@ -80,7 +80,7 @@ // Determines if the PHP value contains any single characters that would // cause it to require single quoting in YAML. - return 0 < preg_match('/[ \s \' " \: \{ \} \[ \] , & \* \# \?] | \A[ \- ? | < > = ! % @ ` \p{Zs}]/xu', $value); + return 0 < preg_match('/[ \s \' " \: \{ \} \[ \] , & \* \# \?] | \A[ \- ? | < > = ! % @ ` \p{Zs} \x{FEFF}]/xu', $value); } /** diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Inline.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Inline.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Inline.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Inline.php 2026-11-04 11:23:00.000000000 +0000 @@ -290,11 +290,11 @@ $output = self::parseQuotedScalar($scalar, $i); if (null !== $delimiters) { - $tmp = ltrim(substr($scalar, $i), " \n"); - if ('' === $tmp) { + $j = $i + strspn($scalar, " \n", $i); + if (!isset($scalar[$j])) { throw new ParseException(\sprintf('Unexpected end of line, expected one of "%s".', implode('', $delimiters)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); } - if (!\in_array($tmp[0], $delimiters)) { + if (!\in_array($scalar[$j], $delimiters)) { throw new ParseException(\sprintf('Unexpected characters (%s).', substr($scalar, $i)), self::$parsedLineNumber + 1, $scalar, self::$parsedFilename); } } @@ -307,10 +307,20 @@ $i += \strlen($output); // remove comments - if (Parser::preg_match('/[ \t]+#/', $output, $match, \PREG_OFFSET_CAPTURE)) { + if (Parser::preg_match('/(?= $len) { - break; - } - - $mappingValue .= $sequence[$j++]; - if ($j >= $len) { - break; - } - - $mappingValue .= self::parseScalar($sequence, $flags, [',', ']'], $j, null === $tag, $references); - } - } while ($j < $len); + // embedded mapping, whose value can be a collection that spans several scalars + $value = '{'.$value; - if ($mappingException) { - throw $mappingException; + if ($i < $end = self::findEmbeddedMappingEnd($sequence, $offset)) { + $value .= substr($sequence, $i, $end - $i); + $i = $end; } + + $value .= '}'; + $pos = 0; + $value = self::parseMapping($state, $value, $flags, $pos, $references); } --$i; @@ -481,6 +474,86 @@ } /** + * Returns the offset of the "," or "]" that ends the embedded mapping starting at $i. + * + * Tokens are split the same way as in Parser::lexInlineStructure(). + */ + private static function findEmbeddedMappingEnd(string $sequence, int $i): int + { + $closingTags = []; + $len = \strlen($sequence); + $start = $i; + + while ($i < $len) { + switch ($sequence[$i]) { + case '[': + $closingTags[] = ']'; + $start = ++$i; + break; + case '{': + $closingTags[] = '}'; + $start = ++$i; + break; + case ',': + if (!$closingTags) { + return $i; + } + $start = ++$i; + break; + case ']': + case '}': + if ($sequence[$i] !== array_pop($closingTags)) { + return $i; + } + $start = ++$i; + break; + case '"': + case "'": + case '&': + case '*': + // a plain scalar can contain quotes, anchors and aliases, so the tokens are split again from $start, 100 per call to stay below pcre.backtrack_limit + for ($end = $i, $i = $start; $i <= $end && $i < $len; $start = $i) { + if (preg_match('/(?:[ \t:]++|"[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+"|\'[^\']*+\'|[&*][^ \t,\[\]{}]*+|[^ \t:"\'&*,\[\]{}][^:,\[\]{}]*+){1,100}+/As', $sequence, $match, 0, $i)) { + $i += \strlen($match[0]); + continue; + } + + // an unterminated quoted string, or a PCRE error + switch ($sequence[$i]) { + case ' ': + case "\t": + case ':': + ++$i; + break; + case '"': + case "'": + $quotation = $sequence[$i]; + while (++$i < $len && $quotation !== $sequence[$i]) { + if ('\\' === $sequence[$i] && '"' === $quotation) { + ++$i; + } + } + ++$i; + break; + case '&': + case '*': + $i += 1 + strcspn($sequence, " \t,[]{}", $i + 1); + break; + default: + $i += strcspn($sequence, ',:[]{}', $i); + } + } + break; + default: + // spaces, colons and plain scalars + $i += strcspn($sequence, ',[]{}"\'&*', $i); + } + } + + return $len; + } + + /** * Parses a YAML mapping. * * @throws ParseException When malformed inline YAML string is parsed @@ -698,7 +771,7 @@ $value = substr($scalar, 1); // remove comments - if (Parser::preg_match('/[ \t]+#/', $value, $match, \PREG_OFFSET_CAPTURE)) { + if (Parser::preg_match('/(?[^ \t,\[\]\{\}\n]++)\s*+/A', substr($value, $i), $matches)) { + if (!Parser::preg_match('/&(?P[^ \t,\[\]\{\}\n]++)\s*+/A', $value, $matches, 0, $i)) { return null; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Parser.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Parser.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Parser.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Parser.php 2026-11-04 11:23:00.000000000 +0000 @@ -29,6 +29,9 @@ public const DEFAULT_MAX_NESTING_LEVEL = 128; public const DEFAULT_MAX_ALIASES_FOR_COLLECTIONS = 128; + // nested blocks that have a longer run of empty or comment lines are read in place instead of being copied at each nesting level + private const MAX_COPIED_EMPTY_LINES = 8; + private ?string $filename = null; private int $offset = 0; private int $numberOfParsedLines = 0; @@ -41,6 +44,12 @@ private array $locallySkippedLineNumbers = []; private array $refsBeingParsed = []; private ?ParserState $state = null; + private ?array $nextLineNumbers = null; + private int $linesOffset = 0; + private int $linesIndentation = 0; + private ?array $linesIndentations = null; + private array $replacedLines = []; + private array $commentIndentations = []; public function __construct(int $maxNestingLevel = self::DEFAULT_MAX_NESTING_LEVEL, int $maxAliasesForCollections = self::DEFAULT_MAX_ALIASES_FOR_COLLECTIONS) { @@ -114,6 +123,7 @@ $this->skippedLineNumbers = []; $this->locallySkippedLineNumbers = []; $this->totalNumberOfLines = null; + $this->nextLineNumbers = null; $state->reset(); } @@ -125,13 +135,26 @@ return $this->state ??= new ParserState(); } - private function doParse(string $value, int $flags): mixed + private function doParse(string|array $value, int $flags): mixed { $this->currentLineNb = -1; $this->currentLine = ''; - $value = $this->cleanup($value, $hasContentOnStartMarkerLine); - $this->lines = explode("\n", $value); - $this->numberOfParsedLines = \count($this->lines); + + if (\is_array($value)) { + $this->readBlock($value); + $hasContentOnStartMarkerLine = false; + $value = fn () => implode("\n", $this->getLines()); + } else { + $value = $this->cleanup($value, $hasContentOnStartMarkerLine); + $this->lines = explode("\n", $value); + $this->numberOfParsedLines = \count($this->lines); + + // copying the empty lines at each nesting level is cheap when there are few lines or few levels + if (null === $this->totalNumberOfLines && $this->numberOfParsedLines * $this->getState()->maxNestingLevel > self::MAX_COPIED_EMPTY_LINES * self::DEFAULT_MAX_NESTING_LEVEL ** 2) { + $this->indexLines(); + } + } + $this->locallySkippedLineNumbers = []; $this->totalNumberOfLines ??= $this->numberOfParsedLines; @@ -206,7 +229,7 @@ // keep trailing empty lines, they can be part of a block scalar if ($currentLineNumber !== $this->getRealCurrentLineNb()) { - $sequenceYaml .= "\n".$embedBlock; + $sequenceYaml = $this->prependLine($sequenceYaml, $embedBlock); } $data[] = $this->parseBlock($currentLineNumber, $sequenceYaml, $flags); @@ -224,14 +247,14 @@ isset($values['leadspaces']) && ( '!' === $values['value'][0] - || self::preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $this->trimTag($values['value']), $matches) + || self::preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[](?:[^ :]++| ++(?!:(?:\s|$))|:(?!\s|$))*+) *+\:(\s++(?P(?:\s*+\S)++))?\s*+$#u', $this->trimTag($values['value']), $matches) ) ) { $currentLineNumber = $this->getRealCurrentLineNb(); $block = $values['value']; if ($this->isNextLineIndented() || $this->isNextLineInBlockScalar($block)) { - $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + \strlen($values['leadspaces']) + 1); + $block = $this->prependLine($block, $this->getNextEmbedBlock($this->getCurrentLineIndentation() + \strlen($values['leadspaces']) + 1)); } $data[] = $this->parseBlock($currentLineNumber, $block, $flags); @@ -239,13 +262,13 @@ $data[] = $this->parseValue($values['value'], $flags, $context); } } - if ($isRef) { + if (false !== $isRef) { $this->refs[$isRef] = end($data); array_pop($this->refsBeingParsed); } } elseif ( // @todo in 7.0 remove legacy "(?:!?!php/const:)?" - self::preg_match('#^(?P(?:![^\s]++\s++)?(?:'.Inline::REGEX_QUOTED_STRING.'|(?:!?!php/const:)?[^ \'"\[\{!].*?)|![^\s]++(?= +\:)) *\:(( |\t)++(?P.+))?$#u', rtrim($this->currentLine), $values) + self::preg_match('#^(?P(?:![^\s]++\s++)?(?:'.Inline::REGEX_QUOTED_STRING.'|(?:!?!php/const:)?[^ \'"\[\{!](?:[^ :]++| ++(?!:(?:[ \t]|$))|:(?![ \t]|$))*+)|![^\s]++(?= +\:)) *+\:(( |\t)++(?P.+))?$#u', rtrim($this->currentLine), $values) && (!str_contains($values['key'], ' #') || \in_array($values['key'][0], ['"', "'"])) ) { if (str_starts_with($values['key'], '!php/const:')) { @@ -297,7 +320,7 @@ $refName = substr(rtrim($values['value']), 1); // remove comments - if (self::preg_match('/[ \t]+#/', $refName, $match, \PREG_OFFSET_CAPTURE)) { + if (self::preg_match('/(?parseBlock($currentLineNumber + 1, $value, $flags); + if (\is_array($value)) { + $block = $value; + $value = fn () => $this->getBlockText($block); + } + if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $parsed instanceof \stdClass) { $parsed = (array) $parsed; } @@ -340,7 +368,7 @@ throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename); } - if (self::preg_match('/^(?:[ \t]*+(?:#.*+)?\n)*+[ \t]*+(?:\[|-(?:\s|$))/', $value)) { + if (self::preg_match('/^(?:[ \t]*+(?:#.*+)?\n)*+[ \t]*+(?:\[|-(?:\s|$))/', $value instanceof \Closure ? $this->getBlockText($block, true) : $value)) { // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier // in the sequence override keys specified in later mapping nodes. @@ -419,7 +447,7 @@ throw new ParseException(\sprintf('Duplicate key "%s" detected.', $key), $this->getRealCurrentLineNb() + 1, $this->currentLine); } } - if ($isRef) { + if (false !== $isRef) { $this->refs[$isRef] = $data[$key]; array_pop($this->refsBeingParsed); } @@ -483,10 +511,14 @@ throw new ParseException('Complex mappings are not supported.', $this->getRealCurrentLineNb() + 1, $this->currentLine); } + if ($value instanceof \Closure) { + $value = $value(); + } + // 1-liner optionally followed by newline(s) - if (\is_string($value) && $this->lines[0] === trim($value)) { + if (\is_string($value) && $this->getLine(0) === trim($value)) { try { - $value = Inline::parse($this->lines[0], $flags, $this->refs, $this->state); + $value = Inline::parse($this->getLine(0), $flags, $this->refs, $this->state); } catch (ParseException $e) { $e->setParsedLine($this->getRealCurrentLineNb() + 1); $e->setSnippet($this->currentLine); @@ -503,7 +535,7 @@ $previousLineWasTerminatedWithBackslash = false; $value = ''; - foreach ($this->lines as $line) { + foreach ($this->getLines() as $line) { $trimmedLine = trim($line); if ('#' === ($trimmedLine[0] ?? '')) { continue; @@ -569,7 +601,7 @@ return empty($data) ? null : $data; } - private function parseBlock(int $offset, string $yaml, int $flags): mixed + private function parseBlock(int $offset, string|array $yaml, int $flags): mixed { $skippedLineNumbers = $this->skippedLineNumbers; @@ -589,6 +621,11 @@ $parser->refsBeingParsed = $this->refsBeingParsed; $parser->state = $this->state; + if (\is_array($yaml)) { + $parser->lines = $this->lines; + $parser->nextLineNumbers = $this->nextLineNumbers; + } + $this->getState()->enterNestingLevel($offset + 1, $this->currentLine, $this->filename); try { @@ -630,12 +667,15 @@ /** * Returns the next embed block of YAML. * + * A block that contains many empty or comment lines is returned as an array + * that describes the lines to read in place. + * * @param int|null $indentation The indent level at which the block is to be read, or null for default * @param bool $inSequence True if the enclosing data structure is a sequence * * @throws ParseException When indentation problem are detected */ - private function getNextEmbedBlock(?int $indentation = null, bool $inSequence = false): string + private function getNextEmbedBlock(?int $indentation = null, bool $inSequence = false): string|array { $oldLineIndentation = $this->getCurrentLineIndentation(); @@ -643,6 +683,8 @@ return ''; } + $nextContentLine = null; + if (null === $indentation) { $newIndent = null; $movements = 0; @@ -659,6 +701,7 @@ } } else { $newIndent = $this->getCurrentLineIndentation(); + $nextContentLine = substr($this->currentLine, $newIndent); } } while (!$EOF && null === $newIndent); @@ -687,7 +730,9 @@ return ''; } - if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) { + $nextContentLine = $nextContentLine ?? $data[0]; + + if ($inSequence && $oldLineIndentation === $newIndent && isset($nextContentLine[0]) && '-' === $nextContentLine[0]) { // the previous line contained a dash but no item content, this line is a sequence item with the same indentation // and therefore no nested list or mapping $this->moveToPreviousLine(); @@ -697,6 +742,8 @@ $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem(); $isItComment = $this->isCurrentLineComment(); + $firstLineNumber = $this->currentLineNb; + $inPlace = false; while ($this->moveToNextLine()) { if ($isItComment && !$isItUnindentedCollection) { @@ -704,6 +751,24 @@ $isItComment = $this->isCurrentLineComment(); } + if (null !== $this->nextLineNumbers) { + $lineNumber = $this->nextLineNumbers[$this->linesOffset + $this->currentLineNb]; + + // when the block starts with comments, skip only them, so that the next line is checked above + if (!$isItComment) { + $lineNumber = $this->nextLineNumbers[$lineNumber]; + } + + if (self::MAX_COPIED_EMPTY_LINES < ($lineNumber = min($lineNumber - $this->linesOffset, $this->numberOfParsedLines)) - $this->currentLineNb) { + // skip a long run of empty and comment lines, the block will be read in place instead of copying them + $inPlace = true; + $this->currentLineNb = $lineNumber - 1; + $this->currentLine = $this->getLine($this->currentLineNb); + + continue; + } + } + $indent = $this->getCurrentLineIndentation(); if ($isItUnindentedCollection && !$this->isCurrentLineEmpty() && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) { @@ -729,12 +794,253 @@ } } + if ($inPlace) { + return $this->createBlock($firstLineNumber, $this->currentLineNb, $newIndent ?? 0, $data[0]); + } + return implode("\n", $data); } + /** + * Returns a line of a nested block as getNextEmbedBlock() reads it. + */ + private function getBlockLine(string $line, ?int $indentation): string + { + if ('' === trim($line, ' ') || \strlen($line) - \strlen(ltrim($line, ' ')) >= $indentation) { + return (string) substr($line, $indentation ?? 0); + } + + // a comment line that is less indented than the block is kept as is + return $line; + } + + /** + * Describes the lines of a nested block, so that they are read in place. + */ + private function createBlock(int $firstLineNumber, int $lastLineNumber, int $indentation, string $firstLine): array + { + $lines = [$firstLine]; + + if ($firstLineNumber < $lastLineNumber && isset($this->replacedLines[$lastLineNumber])) { + $lines[$lastLineNumber - $firstLineNumber] = $this->getBlockLine($this->replacedLines[$lastLineNumber], $indentation); + } + + return [ + 'offset' => $this->linesOffset + $firstLineNumber, + 'count' => $lastLineNumber - $firstLineNumber + 1, + 'indentation' => $this->linesIndentation + $indentation, + 'indentations' => array_merge($this->linesIndentations ?? [], [$indentation]), + 'lines' => $lines, + ]; + } + + private function prependLine(string $line, string|array $block): string|array + { + if (\is_string($block)) { + return $line."\n".$block; + } + + $lines = [$line]; + + foreach ($block['lines'] as $lineNumber => $replacedLine) { + $lines[$lineNumber + 1] = $replacedLine; + } + + --$block['offset']; + ++$block['count']; + $block['lines'] = $lines; + + return $block; + } + + private function useBlock(array $block): void + { + $this->linesOffset = $block['offset']; + $this->numberOfParsedLines = $block['count']; + $this->linesIndentation = $block['indentation']; + $this->linesIndentations = $block['indentations']; + $this->replacedLines = $block['lines']; + } + + /** + * Reads a nested block in place, as cleanup() would have left it. + */ + private function readBlock(array $block): void + { + $this->useBlock($block); + + // remove the YAML header, and the comments and empty lines before the content + $hasYamlHeader = false; + + for ($lineNumber = 0; $lineNumber < $this->numberOfParsedLines - 1; ++$lineNumber) { + if ($lineNumber < $nextLineNumber = min($this->getNextContentLineNumber($lineNumber), $this->numberOfParsedLines - 1)) { + $lineNumber = $nextLineNumber - 1; + + continue; + } + + $line = $this->getLine($lineNumber); + + if (!self::preg_match('/^[ \t]*+(?:#.*+)?+$/', $line) && ($hasYamlHeader || !$hasYamlHeader = (bool) self::preg_match('#^%YAML[: ][\d.]++#', $line))) { + break; + } + } + + $this->skipLines($lineNumber); + } + + private function skipLines(int $count): void + { + if (0 === $count) { + return; + } + + $this->offset += $count; + $this->linesOffset += $count; + $this->numberOfParsedLines -= $count; + $replacedLines = []; + + foreach ($this->replacedLines as $lineNumber => $line) { + if ($lineNumber >= $count) { + $replacedLines[$lineNumber - $count] = $line; + } + } + + $this->replacedLines = $replacedLines; + } + + /** + * Returns the text of a nested block, or only its first line that is neither empty nor a comment. + */ + private function getBlockText(array $block, bool $firstContentLineOnly = false): string + { + $parser = new self(); + $parser->lines = $this->lines; + $parser->nextLineNumbers = $this->nextLineNumbers; + $parser->useBlock($block); + + if (!$firstContentLineOnly) { + return implode("\n", $parser->getLines()); + } + + $lineNumber = 0; + + while ($lineNumber < $parser->numberOfParsedLines - 1) { + $lineNumber = min($parser->getNextContentLineNumber($lineNumber), $parser->numberOfParsedLines - 1); + + if ($lineNumber === $parser->numberOfParsedLines - 1 || !self::preg_match('/^[ \t]*+(?:#.*+)?$/', $parser->getLine($lineNumber))) { + break; + } + + ++$lineNumber; + } + + return $parser->getLine($lineNumber); + } + + /** + * Indexes the blank and comment lines, so that nested blocks can skip them. + * + * A comment line points to the next line that is not a comment, any other line to the next content line. + */ + private function indexLines(): void + { + $this->nextLineNumbers = array_fill(0, $this->numberOfParsedLines + 1, $this->numberOfParsedLines); + $nextContentLineNumber = $nextNonCommentLineNumber = $this->numberOfParsedLines; + + for ($lineNumber = $this->numberOfParsedLines - 1; 0 <= $lineNumber; --$lineNumber) { + $line = ltrim($this->lines[$lineNumber], ' '); + + if ('' === $line) { + $this->nextLineNumbers[$lineNumber] = $nextContentLineNumber; + $nextNonCommentLineNumber = $lineNumber; + } elseif ('#' === $line[0]) { + $this->nextLineNumbers[$lineNumber] = $nextNonCommentLineNumber; + } else { + $this->nextLineNumbers[$lineNumber] = $nextContentLineNumber = $nextNonCommentLineNumber = $lineNumber; + } + } + } + + /** + * Returns the number of the first line from the given one that is neither empty nor a comment. + */ + private function getNextContentLineNumber(int $lineNumber): int + { + while ($lineNumber < $this->numberOfParsedLines) { + if (null !== $this->nextLineNumbers && !isset($this->replacedLines[$lineNumber])) { + $lineNumber = $this->nextLineNumbers[$this->nextLineNumbers[$this->linesOffset + $lineNumber]] - $this->linesOffset; + + if ($lineNumber >= $this->numberOfParsedLines) { + break; + } + } + + $line = ltrim($this->getLine($lineNumber), ' '); + + if ('' !== $line && '#' !== $line[0]) { + return $lineNumber; + } + + ++$lineNumber; + } + + return $this->numberOfParsedLines; + } + + private function getLine(int $lineNumber): string + { + if (null === $this->linesIndentations) { + return $this->lines[$lineNumber]; + } + + if (isset($this->replacedLines[$lineNumber])) { + return $this->replacedLines[$lineNumber]; + } + + $line = $this->lines[$this->linesOffset + $lineNumber]; + $indentation = strspn($line, ' '); + + if ($indentation >= $this->linesIndentation) { + return substr($line, $this->linesIndentation); + } + + if (!isset($line[$indentation])) { + return ''; + } + + // a comment line that is less indented than a block is kept as is by that block + if (!isset($this->commentIndentations[$indentation])) { + $this->commentIndentations[$indentation] = 0; + + foreach ($this->linesIndentations as $blockIndentation) { + if ($indentation - $this->commentIndentations[$indentation] >= $blockIndentation) { + $this->commentIndentations[$indentation] += $blockIndentation; + } + } + } + + return substr($line, $this->commentIndentations[$indentation]); + } + + private function getLines(): array + { + if (null === $this->linesIndentations) { + return $this->lines; + } + + $lines = []; + + for ($lineNumber = 0; $lineNumber < $this->numberOfParsedLines; ++$lineNumber) { + $lines[] = $this->getLine($lineNumber); + } + + return $lines; + } + private function hasMoreLines(): bool { - return (\count($this->lines) - 1) > $this->currentLineNb; + return $this->numberOfParsedLines - 1 > $this->currentLineNb; } /** @@ -746,7 +1052,7 @@ return false; } - $this->currentLine = $this->lines[++$this->currentLineNb]; + $this->currentLine = null === $this->linesIndentations ? $this->lines[++$this->currentLineNb] : $this->getLine(++$this->currentLineNb); return true; } @@ -760,7 +1066,7 @@ return false; } - $this->currentLine = $this->lines[--$this->currentLineNb]; + $this->currentLine = null === $this->linesIndentations ? $this->lines[--$this->currentLineNb] : $this->getLine(--$this->currentLineNb); return true; } @@ -780,7 +1086,7 @@ $value = substr($value, 1); // remove comments - if (self::preg_match('/[ \t]+#/', $value, $match, \PREG_OFFSET_CAPTURE)) { + if (self::preg_match('/(?totalNumberOfLines && str_starts_with($value, "\u{FEFF}")) { + $value = substr($value, 3); + } + // remove the YAML header, and the comments and empty lines before the content $value = $this->removeLeadingLines('#^(?:[ \t]*+(?:\#[^\n]*+)?+\n)*+(?:%YAML[: ][\d.]++[^\n]*+\n(?:[ \t]*+(?:\#[^\n]*+)?+\n)*+)?+#', $value); @@ -1104,11 +1415,11 @@ */ private function isNextLineInBlockScalar(string $value): bool { - if (!isset($this->lines[$this->currentLineNb + 1]) || !self::preg_match('/:[ \t]+(?:'.self::TAG_PATTERN.'[ \t]+)?'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value)) { + if ($this->currentLineNb + 1 >= $this->numberOfParsedLines || !self::preg_match('/:[ \t]+(?:'.self::TAG_PATTERN.'[ \t]+)?'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value)) { return false; } - $nextLine = $this->lines[$this->currentLineNb + 1]; + $nextLine = $this->getLine($this->currentLineNb + 1); return '' === trim($nextLine, ' ') || strspn($nextLine, ' ') > $this->getCurrentLineIndentation(); } @@ -1201,7 +1512,7 @@ private function getLineTag(string $value, int $flags, bool $nextLineCheck = true): ?string { - if ('' === $value || '!' !== $value[0] || 1 !== self::preg_match('/^'.self::TAG_PATTERN.' *( +#.*)?$/', $value, $matches)) { + if ('' === $value || '!' !== $value[0] || 1 !== self::preg_match('/^'.self::TAG_PATTERN.'(?: ++(?:#.*+)?+)?+$/', $value, $matches)) { return null; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/DumperTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/DumperTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/DumperTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/DumperTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -247,6 +247,7 @@ 'line-separator' => ["\xE2\x80\xA8", '"\\L"'], 'paragraph-separator' => ["\xE2\x80\xA9", '"\\P"'], 'colon' => [':', "':'"], + 'byte-order-mark' => ["\u{FEFF}foo", "'\u{FEFF}foo'"], ]; } diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/InlineTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/InlineTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/InlineTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/InlineTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -80,6 +80,7 @@ ['{ !php/const PHP_INT_MAX: foo }', [\PHP_INT_MAX => 'foo']], ['!php/const NULL', null], ['{ !php/const Symfony\Component\Yaml\Yaml::PARSE_CONSTANT: foo }', [Yaml::PARSE_CONSTANT => 'foo']], + ['[ foo: [ bar: baz, !php/const PHP_INT_MAX: qux ] ]', [['foo' => [['bar' => 'baz'], [\PHP_INT_MAX => 'qux']]]]], ]; } @@ -213,6 +214,26 @@ Inline::parse('[foo] bar'); } + public function testParseLargeCollections() + { + $this->assertSame(array_fill(0, 50000, 'a'), Inline::parse('['.str_repeat('"a", ', 49999).'"a"]')); + $this->assertSame(array_fill(0, 50000, 'a'), Inline::parse('['.str_repeat('a, ', 49999).'a]')); + + $mapping = []; + for ($i = 0; $i < 50000; ++$i) { + $mapping['k'.$i] = 'v'; + } + $this->assertSame($mapping, Inline::parse(json_encode($mapping))); + } + + public function testQuotedScalarFollowedByUnexpectedCharactersInSequence() + { + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unexpected characters ( b]) at line 1 (near "[\'a\' b]").'); + + Inline::parse("['a' b]"); + } + public function testParseInvalidSequenceShouldThrowException() { $this->expectException(ParseException::class); @@ -227,6 +248,23 @@ Inline::parse('[ foo: { bar: 1, bar: 2 } ]'); } + public function testParseDeeplyNestedEmbeddedMappings() + { + $expected = [['a' => 'x'], 'x']; + for ($i = 1; $i < 30; ++$i) { + $expected = [['a' => $expected]]; + } + + $this->assertSame($expected, Inline::parse(str_repeat('[a: ', 30).'x, x'.str_repeat(']', 30))); + } + + public function testParseInvalidDeeplyNestedEmbeddedMappingsShouldThrowException() + { + $this->expectException(ParseException::class); + + Inline::parse('![a'.str_repeat(':[a: {]', 30).str_repeat(']', 60)); + } + public function testParseInvalidTaggedSequenceShouldThrowException() { $this->expectException(ParseException::class); @@ -258,6 +296,7 @@ 'list-in-list' => ['[[ *var ]]', [['var-value']]], 'map-in-list' => ['[ { key: *var } ]', [['key' => 'var-value']]], 'embedded-mapping-in-list' => ['[ key: *var ]', [['key' => 'var-value']]], + 'nested-embedded-mapping-in-list' => ['[ key: [ key: *var ] ]', [['key' => [['key' => 'var-value']]]]], 'map' => ['{ key: *var }', ['key' => 'var-value']], 'list-in-map' => ['{ key: [*var] }', ['key' => ['var-value']]], 'map-in-map' => ['{ foo: { bar: *var } }', ['foo' => ['bar' => 'var-value']]], @@ -429,6 +468,13 @@ ['[ foo: { bar: [ \'foobar\', 12 ] } ]', [['foo' => ['bar' => ['foobar', 12]]]]], ['[ foo: { bar: \'foobar\', baz: false } ]', [['foo' => ['bar' => 'foobar', 'baz' => false]]]], ['[ foo: { bar: [ \'foobar\', 12 ], baz: true } ]', [['foo' => ['bar' => ['foobar', 12], 'baz' => true]]]], + ['[ foo: [ bar, \'baz, qux\' ], baz: ~ ]', [['foo' => ['bar', 'baz, qux']], ['baz' => null]]], + ['[ foo: [ bar, ~ ] ]', [['foo' => ['bar', null]]]], + ['[ foo: { bar: "baz, ]" }, qux ]', [['foo' => ['bar' => 'baz, ]']], 'qux']], + ['[ foo: [ "bar, ]", \'baz, ]\', qux"quux\'s ] ]', [['foo' => ['bar, ]', 'baz, ]', 'qux"quux\'s']]]], + ['[ foo: [ \'bar\'\'s, baz\', "qux\\", quux" ] ]', [['foo' => ["bar's, baz", 'qux", quux']]]], + ['[ foo: [ bar: &anchor baz ], qux: *anchor ]', [['foo' => [['bar' => 'baz']]], ['qux' => 'baz']]], + ['[ foo: [ '.str_repeat('bar:', 150).'baz"qux ] ]', [['foo' => [str_repeat('bar:', 150).'baz"qux']]]], // Binary string not utf8-compliant but starting with and utf8-equivalent "&" character ['{ uid: !!binary Ju0Yh+uqSXOagJZFTlUt8g== }', ['uid' => hex2bin('26ed1887ebaa49739a8096454e552df2')]], diff -Nru symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/ParserTest.php symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/ParserTest.php --- symfony-6.4.49+dfsg/src/Symfony/Component/Yaml/Tests/ParserTest.php 2026-10-28 18:15:18.000000000 +0000 +++ symfony-6.4.50+dfsg/src/Symfony/Component/Yaml/Tests/ParserTest.php 2026-11-04 11:23:00.000000000 +0000 @@ -233,6 +233,23 @@ ]; } + public function testParseStripsByteOrderMarkAtStartOfStream() + { + $this->assertSame(['foo' => 'bar'], $this->parser->parse("\u{FEFF}foo: bar")); + $this->assertSame(['foo' => 'bar'], $this->parser->parse("\u{FEFF}---\nfoo: bar")); + $this->assertSame([1, 2], $this->parser->parse("\u{FEFF}[1, 2]")); + $this->assertSame('foo', $this->parser->parse("\u{FEFF}foo")); + $this->assertSame(['foo' => 'bar'], $this->parser->parse("\u{FEFF}# a comment\nfoo: bar")); + $this->assertNull($this->parser->parse("\u{FEFF}")); + } + + public function testParseKeepsByteOrderMarkInsideTheDocument() + { + $this->assertSame(['foo' => ["\u{FEFF}bar" => 'baz']], $this->parser->parse("foo:\n \u{FEFF}bar: baz")); + $this->assertSame([["\u{FEFF}bar" => 'baz']], $this->parser->parse("- \u{FEFF}bar: baz")); + $this->assertSame(['foo' => 1, "\u{FEFF}bar" => 2], $this->parser->parse("foo: 1\n\u{FEFF}bar: 2")); + } + public function testParserIsStateless() { $yamlString = '# translations/messages.en.yaml @@ -3829,6 +3846,141 @@ $this->assertSame(['foo' => 'bar'], $this->parser->parse($yaml)); } + /** + * @dataProvider provideLinesWithLongRunsOfSpaces + */ + public function testParseLinesWithLongRunsOfSpaces($expected, string $yaml) + { + // patterns that scan a run of spaces once per character of the run exceed a low limit + $backtrackLimit = ini_set('pcre.backtrack_limit', '1000'); + + try { + $this->assertSame($expected, $this->parser->parse($yaml)); + } finally { + ini_set('pcre.backtrack_limit', $backtrackLimit); + } + } + + /** + * @dataProvider provideBlockScalarsWithManyBlankLines + */ + public function testParseBlockScalarsWithManyBlankLines(string $expected, string $yaml) + { + // a pattern that scans the run of newlines once per newline exceeds a low limit + $backtrackLimit = ini_set('pcre.backtrack_limit', '1000'); + + try { + $this->assertSame(['k' => $expected], $this->parser->parse($yaml)); + } finally { + ini_set('pcre.backtrack_limit', $backtrackLimit); + } + } + + public static function provideBlockScalarsWithManyBlankLines(): iterable + { + $newlines = str_repeat("\n", 20000); + + yield 'literal' => ["a\n".$newlines."b\n", "k: |\n a\n".$newlines." b\n"]; + yield 'literal at the end of the document' => ["a\n".$newlines.'b', "k: |\n a\n".$newlines.' b']; + yield 'literal with strip chomping' => ["a\n".$newlines.'b', "k: |-\n a\n".$newlines." b\n"]; + yield 'folded' => ['a'.$newlines."b\n", "k: >\n a\n".$newlines." b\n"]; + yield 'folded with strip chomping' => ['a'.$newlines.'b', "k: >-\n a\n".$newlines." b\n"]; + } + + /** + * @dataProvider provideEmptySequenceItemsFollowedByBlankOrCommentLines + */ + public function testParseEmptySequenceItemsFollowedByBlankOrCommentLines(array $expected, string $yaml) + { + $this->assertSame($expected, $this->parser->parse($yaml)); + } + + public static function provideEmptySequenceItemsFollowedByBlankOrCommentLines(): iterable + { + yield 'blank line' => [[null, 'x'], "-\n\n- x"]; + yield 'comment line' => [[null, 'x'], "-\n# comment\n- x"]; + yield 'line of spaces' => [[null, 'x'], "- \n \n- x"]; + yield 'anchor' => [[null, 'x'], "- &a\n\n- x"]; + yield 'nested sequence' => [[[null, 'x']], "-\n -\n\n - x"]; + yield 'indented sequence is nested' => [[['x']], "-\n\n - x"]; + yield 'many items' => [array_merge(array_fill(0, 300, null), ['x']), str_repeat("-\n\n", 300).'- x']; + } + + public static function provideLinesWithLongRunsOfSpaces(): iterable + { + $spaces = str_repeat(' ', 20000); + + yield 'mapping key' => [['a'.$spaces.'b' => 'c'], 'a'.$spaces.'b: c']; + yield 'mapping key followed by a tab' => [['a' => 'c'], 'a'.$spaces."\t: c"]; + yield 'mapping key in a sequence' => [[['a'.$spaces.'b' => 'c']], '- a'.$spaces.'b: c']; + yield 'mapping value in a sequence' => [[['a' => 'b'.$spaces.'c']], '- a: b'.$spaces.'c']; + yield 'tagged scalar' => [substr($spaces, 1).'x', '!!str'.$spaces.'x']; + yield 'tagged mapping value' => [['a' => substr($spaces, 1).'x'], 'a: !!str'.$spaces.'x']; + } + + public function testParseAnchorsNamedZero() + { + $this->assertSame(['a', 'a'], $this->parser->parse("- &0 a\n- *0")); + $this->assertSame(['k' => 'a', 'l' => 'a'], $this->parser->parse("k: &0 a\nl: *0")); + } + + public function testParseNestedBlocksWithManyEmptyLines() + { + $yaml = ''; + for ($i = 0; $i < 100; ++$i) { + $yaml .= str_repeat(' ', $i)."k:\n"; + } + $yaml .= str_repeat(' ', 100)."a: 1\n".str_repeat("\n#", 10000)."\n"; + $yaml .= str_repeat(' ', 100).'b: !php/object '.serialize(new MemoryUsageRecorder()); + + $memoryUsage = memory_get_usage(); + $data = $this->parser->parse($yaml, Yaml::PARSE_OBJECT); + + for ($i = 0; $i < 100; ++$i) { + $data = $data['k']; + } + + $this->assertSame(1, $data['a']); + $this->assertInstanceOf(MemoryUsageRecorder::class, $data['b']); + + // the empty and comment lines used to be copied by each nesting level + $this->assertLessThan(10 * 1024 * 1024, MemoryUsageRecorder::$memoryUsage - $memoryUsage); + } + + public function testParseNestedSequencesOnOneLineWithManyCommentLines() + { + $yaml = str_repeat('- ', 100).'!php/object '.serialize(new MemoryUsageRecorder()).str_repeat("\n#", 10000); + + $memoryUsage = memory_get_usage(); + $data = $this->parser->parse($yaml, Yaml::PARSE_OBJECT); + + for ($i = 0; $i < 100; ++$i) { + $data = $data[0]; + } + + $this->assertInstanceOf(MemoryUsageRecorder::class, $data); + + // the comment lines that start a nested block used to be copied by each nesting level + $this->assertLessThan(10 * 1024 * 1024, MemoryUsageRecorder::$memoryUsage - $memoryUsage); + } + + public function testParseNestedBlocksWithHigherMaxNestingLevel() + { + $yaml = str_repeat('- ', 255).'!php/object '.serialize(new MemoryUsageRecorder()).str_repeat("\n#".str_repeat('-', 200), 1000); + + $memoryUsage = memory_get_usage(); + $data = (new Parser(256))->parse($yaml, Yaml::PARSE_OBJECT); + + for ($i = 0; $i < 255; ++$i) { + $data = $data[0]; + } + + $this->assertInstanceOf(MemoryUsageRecorder::class, $data); + + // with a higher limit, the lines of smaller documents used to be copied by each nesting level + $this->assertLessThan(20 * 1024 * 1024, MemoryUsageRecorder::$memoryUsage - $memoryUsage); + } + public function testParseHandlesTrailingNewlinesAfterDocumentEndMarker() { $yaml = "---\nfoo: bar\n...\n\n"; @@ -3932,6 +4084,16 @@ } } +class MemoryUsageRecorder +{ + public static int $memoryUsage; + + public function __wakeup(): void + { + self::$memoryUsage = memory_get_usage(); + } +} + class B { public $b = 'foo';