preg_match() has three outcomes -- 1, 0 and false -- but regex() folded false into the "no match" branch. An uncompilable pattern or an exhausted PCRE limit therefore leaked a raw PHP warning out of the library and then reported the misleading "The value ... does not match the expected pattern." Check for false separately and report the pattern together with preg_last_error_msg(), following the existing false === $x = nativeCall() handling in Assert::strlen(). The caller's custom message is intentionally not reused for that branch, since it describes a mismatch that never happened. Matching values and genuine mismatches are unaffected.
7.3 KiB
Make Assert::regex() report PCRE failures instead of a bogus mismatch
Fixes #119 (one instance of it -- see "Scope" below).
Problem
Assert::regex() treats the return value of preg_match() as a boolean. preg_match()
has three outcomes, not two: 1 (match), 0 (no match) and false (the engine could not
run the match at all). Folding false into the 0 branch produces two bad behaviours.
1. A raw PHP warning escapes from inside the library, followed by a misleading exception.
Assert::regex('abc', '/(/');
on PHP 8.3.6 before this change:
PHP Warning: preg_match(): Compilation failed: missing closing parenthesis at offset 1 in .../src/Assert.php on line 1510
Webmozart\Assert\InvalidArgumentException: The value "abc" does not match the expected pattern.
The warning is emitted from library code the caller does not control, and the exception then blames the value for what is actually a broken pattern.
2. A PCRE engine failure is silently reclassified as a failed assertion.
With pcre.backtrack_limit=100:
Assert::regex(str_repeat('a', 30).'c', '/^(a+)+$/');
// Webmozart\Assert\InvalidArgumentException: The value "aaaa...c" does not match the expected pattern.
// preg_last_error_msg() === 'Backtrack limit exhausted'
Here preg_match() returned false because it hit the backtrack limit -- it never
determined whether the value matches. The caller is told the value failed validation.
That is the more dangerous of the two: on a large input, a resource limit becomes an
indistinguishable "invalid input" error, and the real cause is invisible.
The same masking happens for PREG_BAD_UTF8_ERROR, PREG_JIT_STACKLIMIT_ERROR, and so on.
Root cause
src/Assert.php:1510
if (!\preg_match($pattern, $value)) {
!false and !0 are both true, so the "engine failed" and "value did not match" cases
are indistinguishable, and nothing suppresses or reports the warning preg_match() raises
on its way out.
Solution
Separate the three outcomes, mirroring how this repository already handles a native
function that can return false (Assert::strlen(), src/Assert.php:2590:
if (false === $encoding = \mb_detect_encoding($value)) {).
if (false === $result = @\preg_match($pattern, $value)) {
static::reportInvalidArgument(\sprintf(
'The pattern %s could not be evaluated: %s.',
static::valueToString($pattern),
\preg_last_error_msg()
));
}
if (0 === $result) {
// unchanged: the existing mismatch message, including the caller's custom message
}
Now:
Assert::regex('abc', '/(/');
-> InvalidArgumentException: The pattern "/(/" could not be evaluated: Internal error.
(no PHP warning)
Assert::regex(str_repeat('a', 30).'c', '/^(a+)+$/'); // pcre.backtrack_limit=100
-> InvalidArgumentException: The pattern "/^(a+)+$/" could not be evaluated: Backtrack limit exhausted.
Notes on the two deliberate choices here:
- The caller's custom
$messageis not applied to the engine-failure branch. A custom message such as'The value %s is not a valid slug.'describes a mismatch. Reusing it for a pattern that never compiled would preserve exactly the confusion this change removes. - The diagnostic comes from
preg_last_error_msg()rather than from the raised warning text. Capturing the warning text ("Compilation failed: missing closing parenthesis at offset 1") would requireset_error_handler()/restore_error_handler(), and both are listed in Psalm'sdictionaries/ImpureFunctionsList.php, so calling them inside a method annotated@psalm-pureis the wrong trade.preg_match()andpreg_last_error_msg()are not in that list.
Behaviour on the normal paths is unchanged: a matching value still returns the value, and a genuine mismatch still throws with the existing default or custom message. No public signature changes, so no BC break.
Scope
Issue #119 is broadly worded ("Some assertions call functions that may generate a warning
... e.g. preg_match, strlen"). This PR fixes only regex(), which is one instance of
that class of problem. I did not extend it to length(), contains() or the count*
assertions, because I have no reproduction showing a defect there, and guessing at those
would make the change harder to review.
One sibling is worth flagging explicitly, though I left it out to keep this PR to a single
behaviour change. notRegex() (src/Assert.php:1533 before this change) has the same root
shape with the opposite and arguably worse symptom -- because it checks the truthiness of
preg_match() directly, a pattern that fails to compile makes the assertion silently
pass:
Assert::notRegex('abc', '/(/'); // emits a PHP warning, then returns 'abc' successfully
Happy to fold notRegex() into this PR, or file it separately, whichever you prefer.
How this was tested
Environment: PHP 8.3.6, Composer 2.10.2, on commit 2ccb7c2 ("Prepare for 2.4.1 release").
Four tests were added to tests/AssertTest.php, following the existing standalone-test
convention (testResourceOfTypeCustomMessage, testEnumAssertionErrorMessage):
testRegexRejectsAnUncompilablePatterntestRegexSuppressesTheWarningForAnUncompilablePatterntestRegexDoesNotUseTheCustomMessageForAnUncompilablePatterntestRegexRejectsAPatternExceedingTheBacktrackLimit
Each new test fails without the source change and passes with it.
With src/Assert.php reverted to its original state and only the new tests applied:
$ ./tools/phpunit/vendor/bin/phpunit --filter 'testRegex'
1) Webmozart\Assert\Tests\AssertTest::testRegexRejectsAnUncompilablePattern
2) Webmozart\Assert\Tests\AssertTest::testRegexSuppressesTheWarningForAnUncompilablePattern
3) Webmozart\Assert\Tests\AssertTest::testRegexDoesNotUseTheCustomMessageForAnUncompilablePattern
4) Webmozart\Assert\Tests\AssertTest::testRegexRejectsAPatternExceedingTheBacktrackLimit
FAILURES!
Tests: 4, Assertions: 6, Failures: 4, Warnings: 1.
With the fix applied:
$ ./tools/phpunit/vendor/bin/phpunit --filter 'testRegex'
OK (4 tests, 6 assertions)
Full suite before the change:
$ composer install && composer --working-dir=tools/phpunit install && ./tools/phpunit/vendor/bin/phpunit
Tests: 4906, Assertions: 5823, Notices: 2, Skipped: 56.
Full suite after the change:
$ ./tools/phpunit/vendor/bin/phpunit
Tests: 4910, Assertions: 5829, Notices: 2, Skipped: 56.
Same 2 notices and 56 skips in both runs -- they are pre-existing on a clean checkout
(the notices originate from tests/AssertTest.php:618) and are unrelated to this change.
The 4 added tests account for the entire delta.
One caveat on local verification: composer run cs-check and composer run static-analysis
could not be run on this machine. friendsofphp/php-cs-fixer v3.92.3 requires PHP >= 8.4
via symfony/console v8.0.3, and vimeo/psalm 6.14.3 refuses to start with
Psalm requires a PHP version ">= 8.3.16". You are running 8.3.6. The added code was instead
matched by hand to the conventions already in the tree: Yoda comparisons (false === $x, as
in src/Assert.php:829/897/2590), global classes imported rather than written with a leading
backslash (as with LogicException, Error, Exception in tests/AssertTest.php), and
concatenation without surrounding spaces. CI will be the real check on both of those gates.