diff --git a/ext/standard/VmPreg.php b/ext/standard/VmPreg.php index ae03b129d50..bde61ded292 100644 --- a/ext/standard/VmPreg.php +++ b/ext/standard/VmPreg.php @@ -325,8 +325,19 @@ private static function pregReplaceArrayPatterns( } $elemCount = 0; $replaced = self::pregReplaceArrayPatterns($pattern, $replacements, $item, $limit, $elemCount); - if (false === $replaced || null === $replaced) { - return $replaced; + if (false === $replaced) { + return false; + } + if (null === $replaced) { + if (StdlibConstants::PREG_BAD_UTF8_ERROR === self::lastError()) { + if (null !== $count) { + $count = $totalCount; + } + + return $out; + } + + return null; } $out[$key] = $replaced; $totalCount += $elemCount; @@ -348,9 +359,20 @@ private static function pregReplaceArrayPatterns( $stepCount = 0; $step = VmPregNative::pregReplace($pat, $repl, $result, $limit, $stepCount); self::syncLastErrorFromNative(); - if (false === $step || null === $step) { + if (false === $step) { return false; } + if (null === $step) { + if (StdlibConstants::PREG_BAD_UTF8_ERROR === self::lastError()) { + if (null !== $count) { + $count = $totalCount; + } + + return $result; + } + + return null; + } $result = $step; $totalCount += $stepCount; } diff --git a/ext/standard/VmPregPattern.php b/ext/standard/VmPregPattern.php index 3b302fef0fe..339431da4a5 100644 --- a/ext/standard/VmPregPattern.php +++ b/ext/standard/VmPregPattern.php @@ -11,6 +11,9 @@ */ final class VmPregPattern { + /** PCRE2_UTF — PHP /u pattern modifier (ext/pcre/php_pcre.c). */ + public const PCRE2_UTF = 0x00080000; + /** * Zend ext/pcre/php_pcre.c delimiter/compile failure text (issue #12083). */ @@ -124,7 +127,7 @@ public static function parsePhpPattern(string $pattern): ?array 'A' => 0x80000000, 'D' => 0x00000010, 'U' => 0x00040000, - 'u' => 0x00080000, + 'u' => self::PCRE2_UTF, default => 0, }; } diff --git a/ext/standard/VmPregPure.php b/ext/standard/VmPregPure.php index 3529500e450..b6d16b951a7 100644 --- a/ext/standard/VmPregPure.php +++ b/ext/standard/VmPregPure.php @@ -38,17 +38,19 @@ public static function pregMatch( return false; } [$regex, $opts] = $parsed; - $compiled = self::compile($pattern); - if (null === $compiled) { - return false; - } - $normalizedOffset = self::normalizeMatchSubjectOffset($offset, \strlen($subject)); if (false === $normalizedOffset) { self::$lastError = StdlibConstants::PREG_INTERNAL_ERROR; return false; } + if (!self::ensureUtf8SubjectForOpts($subject, $opts, $normalizedOffset)) { + return false; + } + $compiled = self::compile($pattern); + if (null === $compiled) { + return false; + } $ovector = self::engineMatch( $compiled['ast'], @@ -100,11 +102,6 @@ public static function pregMatchAll( return false; } [$regex, $opts] = $parsed; - $compiled = self::compile($pattern); - if (null === $compiled) { - return false; - } - $subjectLen = \strlen($subject); $normalizedOffset = self::normalizeMatchSubjectOffset($offset, $subjectLen); if (false === $normalizedOffset) { @@ -112,6 +109,13 @@ public static function pregMatchAll( return false; } + if (!self::ensureUtf8SubjectForOpts($subject, $opts, $normalizedOffset)) { + return false; + } + $compiled = self::compile($pattern); + if (null === $compiled) { + return false; + } $start = $normalizedOffset; $count = 0; @@ -185,8 +189,19 @@ public static function pregReplace( } $elemCount = 0; $replaced = self::pregReplaceString($pattern, $replacement, $item, $limit, $elemCount); - if (false === $replaced || null === $replaced) { - return $replaced; + if (false === $replaced) { + return false; + } + if (null === $replaced) { + if (StdlibConstants::PREG_BAD_UTF8_ERROR === self::$lastError) { + if (null !== $count) { + $count = $totalCount; + } + + return $out; + } + + return null; } $out[$key] = $replaced; $totalCount += $elemCount; @@ -214,14 +229,21 @@ public static function pregSplit(string $pattern, string $subject, int $limit = } [$regex, $opts] = $parsed; if ('' === $regex) { + if (!self::ensureUtf8SubjectForOpts($subject, $opts, 0)) { + return false; + } + return self::pregSplitEmptyPattern( $subject, $limit, $flags, - 0 !== ($opts & 0x00080000) + 0 !== ($opts & VmPregPattern::PCRE2_UTF) ); } + if (!self::ensureUtf8SubjectForOpts($subject, $opts, 0)) { + return false; + } $compiled = self::compile($pattern); if (null === $compiled) { return false; @@ -325,7 +347,7 @@ private static function splitAdvanceUnit(string $subject, int $byteOffset, int $ if ($byteOffset >= \strlen($subject)) { return 0; } - if (0 !== ($opts & 0x00080000)) { + if (0 !== ($opts & VmPregPattern::PCRE2_UTF)) { $charIndex = 0; $pos = 0; $charLen = VmString::utf8CharLength($subject); @@ -407,6 +429,9 @@ private static function pregReplaceString( $emptyParsed = PregEmptyPatternReplace::parseEmptyPattern($pattern); if (null !== $emptyParsed) { [, $opts] = $emptyParsed; + if (!self::ensureUtf8SubjectForOpts($subject, $opts, 0)) { + return null; + } self::$lastError = 0; $replacements = 0; $result = PregEmptyPatternReplace::replace( @@ -430,6 +455,9 @@ private static function pregReplaceString( return null; } [$regex, $opts] = $parsed; + if (!self::ensureUtf8SubjectForOpts($subject, $opts, 0)) { + return null; + } $compiled = self::compile($pattern); if (null === $compiled) { self::$lastError = 1; @@ -550,6 +578,32 @@ private static function normalizeMatchSubjectOffset(int $offset, int $subjectLen return $offset; } + /** + * php-src ext/pcre/php_pcre.c — reject malformed UTF-8 subjects when /u is set. + */ + private static function ensureUtf8SubjectForOpts(string $subject, int $opts, int $offset): bool + { + if (0 === ($opts & VmPregPattern::PCRE2_UTF)) { + return true; + } + if (!VmString::isValidUtf8($subject)) { + self::$lastError = StdlibConstants::PREG_BAD_UTF8_ERROR; + + return false; + } + $subjectLen = \strlen($subject); + if ($offset > 0 && $offset < $subjectLen) { + $byte = \ord($subject[$offset]); + if (($byte & 0xC0) === 0x80) { + self::$lastError = StdlibConstants::PREG_BAD_UTF8_OFFSET_ERROR; + + return false; + } + } + + return true; + } + /** * @param list $ovector * @param array $groupNameToIndex diff --git a/ext/standard/preg_grep.php b/ext/standard/preg_grep.php index c2a7fdb70bf..a0ed353facc 100644 --- a/ext/standard/preg_grep.php +++ b/ext/standard/preg_grep.php @@ -56,6 +56,9 @@ public function execute(Frame $frame): void self::rejectNonStringHaystackValue($value); $match = VmPreg::pregMatch($pattern, $value->toString()); if (false === $match) { + if (StdlibConstants::PREG_BAD_UTF8_ERROR === VmPreg::lastError()) { + continue; + } if (null !== $frame->returnVar) { $frame->returnVar->bool(false); } diff --git a/test/compliance/cases/pcre/preg_utf8_invalid_subject.phpt b/test/compliance/cases/pcre/preg_utf8_invalid_subject.phpt new file mode 100644 index 00000000000..e8072d89708 --- /dev/null +++ b/test/compliance/cases/pcre/preg_utf8_invalid_subject.phpt @@ -0,0 +1,32 @@ +--TEST-- +pcre preg_* /u modifier rejects invalid UTF-8 subject (#17503, ext/pcre/php_pcre.c) +--FILE-- + 'a', +) +4 diff --git a/test/repro/maintainer_gap_preg_match_invalid_utf8.php b/test/repro/maintainer_gap_preg_match_invalid_utf8.php new file mode 100644 index 00000000000..4c9807789be --- /dev/null +++ b/test/repro/maintainer_gap_preg_match_invalid_utf8.php @@ -0,0 +1,11 @@ +assertFalse(VmPregPure::pregMatch('//u', self::BAD)); + $this->assertSame(StdlibConstants::PREG_BAD_UTF8_ERROR, VmPregPure::lastError()); + } + + public function testPregSplitInvalidUtf8SetsBadUtf8Error(): void + { + VmPregPure::setLastError(0); + $this->assertFalse(VmPregPure::pregSplit('//u', self::BAD)); + $this->assertSame(StdlibConstants::PREG_BAD_UTF8_ERROR, VmPregPure::lastError()); + } + + public function testPregReplaceInvalidUtf8ReturnsNull(): void + { + VmPregPure::setLastError(0); + $this->assertNull(VmPregPure::pregReplace('//u', 'x', self::BAD)); + $this->assertSame(StdlibConstants::PREG_BAD_UTF8_ERROR, VmPregPure::lastError()); + } + + public function testJitHelperPropagatesBadUtf8Error(): void + { + VmPregNative::setLastError(0); + $this->assertSame(-1, PregJitHelper::matchArgv('//u', self::BAD)); + $this->assertSame(StdlibConstants::PREG_BAD_UTF8_ERROR, PregJitHelper::lastError()); + } +}