From ce2a73faf1b2a1095d16de22f4a5697af7f914b8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 3 Sep 2026 14:28:48 +0200 Subject: [PATCH] fix: distinguish PCRE failure from a mismatch in Assert::regex() 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. --- src/Assert.php | 10 +++++++- tests/AssertTest.php | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/Assert.php b/src/Assert.php index 31c861b9..8544ea91 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -1507,7 +1507,15 @@ public static function regex(mixed $value, mixed $pattern, string|callable $mess static::string($value); static::string($pattern); - if (!\preg_match($pattern, $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) { $message = self::resolveMessage($message); static::reportInvalidArgument(\sprintf( $message ?: 'The value %s does not match the expected pattern.', diff --git a/tests/AssertTest.php b/tests/AssertTest.php index f0ec064d..1fc73a68 100644 --- a/tests/AssertTest.php +++ b/tests/AssertTest.php @@ -19,6 +19,7 @@ use DateTimeImmutable; use Error; use Exception; +use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -991,6 +992,66 @@ public function testEnumAssertionErrorMessage(): void Assert::null(DummyEnum::CaseName, 'Expected null. Got: %s'); } + public function testRegexRejectsAnUncompilablePattern(): void + { + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage('The pattern "/(/" could not be evaluated: Internal error.'); + + Assert::regex('abc', '/(/'); + } + + public function testRegexSuppressesTheWarningForAnUncompilablePattern(): void + { + $reported = []; + // PHPUnit masks E_WARNING out of error_reporting(); restore it here. + $errorReporting = error_reporting(E_ALL); + set_error_handler(static function (int $errno, string $message) use (&$reported): bool { + // This is what a well behaved error handler does with a suppressed diagnostic. + if (0 !== (error_reporting() & $errno)) { + $reported[] = $message; + } + + return true; + }); + + try { + Assert::regex('abc', '/(/'); + } catch (InvalidArgumentException) { + // The assertion is expected to fail; this test only inspects the reported errors. + } finally { + restore_error_handler(); + error_reporting($errorReporting); + } + + $this->assertSame([], $reported); + } + + public function testRegexDoesNotUseTheCustomMessageForAnUncompilablePattern(): void + { + $this->expectException('\InvalidArgumentException'); + $this->expectExceptionMessage('The pattern "/(/" could not be evaluated: Internal error.'); + + Assert::regex('abc', '/(/', 'The value %s is not a valid slug.'); + } + + public function testRegexRejectsAPatternExceedingTheBacktrackLimit(): void + { + $backtrackLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '100'); + + try { + Assert::regex(str_repeat('a', 30).'c', '/^(a+)+$/'); + $this->fail('Expected an InvalidArgumentException to be thrown.'); + } catch (InvalidArgumentException $e) { + $this->assertSame( + 'The pattern "/^(a+)+$/" could not be evaluated: Backtrack limit exhausted.', + $e->getMessage() + ); + } finally { + ini_set('pcre.backtrack_limit', $backtrackLimit); + } + } + #[DataProvider('getMethodsThatUseOtherMethods')] public function testMessageIsPassedToInternalCalls(string $method, array $args, string $exceptionMessage): void {