From 6453c6680ede44dc19b73e0a9b4a9b9a00870f95 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 10 Oct 2026 10:47:01 -0400 Subject: [PATCH] Avoid multibyte substring scans for valid nickname closers --- src/Mapper/NicknameMapper.php | 13 ++++++- tests/Mapper/NicknameMapperTest.php | 58 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/Mapper/NicknameMapper.php b/src/Mapper/NicknameMapper.php index f7bc6d8..d9b25ea 100644 --- a/src/Mapper/NicknameMapper.php +++ b/src/Mapper/NicknameMapper.php @@ -279,7 +279,7 @@ private function symmetricCloserAppears(array $parts, int $openKey, string $stri { $closerLength = mb_strlen($closer, 'UTF-8'); - if ($stripped !== '' && mb_substr($stripped, -$closerLength, null, 'UTF-8') === $closer) { + if ($stripped !== '' && self::endsWithCloser($stripped, $closer, $closerLength)) { return true; } @@ -287,7 +287,7 @@ private function symmetricCloserAppears(array $parts, int $openKey, string $stri $last = null; foreach ($parts as $k => $part) { if (! is_string($part) - || mb_substr($part, -$closerLength, null, 'UTF-8') !== $closer) { + || ! self::endsWithCloser($part, $closer, $closerLength)) { continue; } @@ -308,6 +308,15 @@ private function symmetricCloserAppears(array $parts, int $openKey, string $stri return $last !== null && $last > $openKey; } + private static function endsWithCloser(string $part, string $closer, int $closerLength): bool + { + // Valid UTF-8 needs only a byte suffix check; retain mbstring substitution + // semantics for malformed strings passed directly to the mapper. + return mb_check_encoding($part, 'UTF-8') + ? str_ends_with($part, $closer) + : mb_substr($part, -$closerLength, null, 'UTF-8') === $closer; + } + protected function buildRegexp(): string { if (empty($this->delimiters)) { diff --git a/tests/Mapper/NicknameMapperTest.php b/tests/Mapper/NicknameMapperTest.php index 886a3b3..6519e38 100644 --- a/tests/Mapper/NicknameMapperTest.php +++ b/tests/Mapper/NicknameMapperTest.php @@ -300,6 +300,64 @@ public function testReplacementDelimitersPreserveLiteralQuoteContent(string $inp ); } + /** + * @return iterable + */ + public static function symmetricDelimiterProvider(): iterable + { + yield 'ASCII quote' => ['"']; + yield 'multi-character ASCII' => ['||']; + yield 'multibyte' => ['§']; + yield 'multi-character Unicode' => ['§界']; + yield 'combining mark' => ["e\u{0301}"]; + } + + #[DataProvider('symmetricDelimiterProvider')] + public function testSymmetricClosersPreserveTokenBoundaries(string $delimiter): void + { + $mapper = new NicknameMapper([$delimiter => $delimiter]); + + $this->assertSame( + self::canonicalParts(['John', new Nickname('Bób'), new Nickname('李'), 'Smith']), + self::canonicalParts($mapper->map(['John', $delimiter . 'Bób', '李' . $delimiter, 'Smith'])), + ); + $this->assertSame( + self::canonicalParts(['John', new Nickname('Bób'), 'Smith']), + self::canonicalParts($mapper->map(['John', $delimiter . 'Bób' . $delimiter, 'Smith'])), + ); + $this->assertSame( + ['John', $delimiter . 'Bób', '李' . $delimiter . ';', 'Smith'], + $mapper->map(['John', $delimiter . 'Bób', '李' . $delimiter . ';', 'Smith']), + ); + $this->assertSame( + self::canonicalParts(['John', $delimiter . 'orphan', new Nickname('Bób'), 'Smith']), + self::canonicalParts($mapper->map(['John', $delimiter . 'orphan', $delimiter . 'Bób' . $delimiter, 'Smith'])), + ); + } + + public function testMalformedUtf8PreservesMbstringSubstitutionForClosers(): void + { + $substitute = mb_substitute_character(); + mb_substitute_character(63); + + try { + $mapper = new NicknameMapper(['?' => '?']); + $this->assertSame( + ['John', 'Bob', "Bad\xFF", 'Smith'], + $mapper->map(['John', '?Bob', "Bad\xFF", 'Smith']), + ); + + mb_substitute_character(39); + $mapper = new NicknameMapper(); + $this->assertSame( + ['John', 'Bob', "Bad\xFF", 'Smith'], + $mapper->map(['John', "'Bob", "Bad\xFF", 'Smith']), + ); + } finally { + mb_substitute_character($substitute); + } + } + protected function getMapper(): NicknameMapper { return new NicknameMapper([