fix: Task 8 review round 1 - quote-aware tokenizer, context check, skipped global nodes (S45)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 16:32:00 +02:00
co-authored by Claude Opus 5.5
parent bb85868192
commit 93d2bde286
6 changed files with 893 additions and 114 deletions
@@ -195,6 +195,134 @@ final class TextReplacerTest extends TestCase
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
}
/**
* Ruling S45: a ">" inside a quoted attribute value does not end the tag, so nothing after it
* in the tag is text. With the old "<[^>]*>" split these tests fail (the rest of the tag
* was searched), which is how a reverted tokenizer shows.
*/
public function testQuotedGreaterThanDoesNotEndTheTag(): void
{
$double = '<a title="A > Kontakt" href="/kontakt">Kontakt</a> und Kontakt';
$single = "<img alt='x > Kontakt' src='/kontakt.jpg'> Kontakt";
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($double), self::same());
$image = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($single), self::same());
self::assertSame('<a title="A > Kontakt" href="/kontakt">Anfrage</a> und Anfrage', self::valueOf($result['attrs']));
self::assertSame(2, $result['count']);
self::assertSame("<img alt='x > Kontakt' src='/kontakt.jpg'> Anfrage", self::valueOf($image['attrs']));
}
public function testCommentsAreNoText(): void
{
$value = "<!-- Kontakt > Kontakt\n<b> --><p>Kontakt</p><!---->Kontakt";
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
self::assertSame("<!-- Kontakt > Kontakt\n<b> --><p>Anfrage</p><!---->Anfrage", self::valueOf($result['attrs']));
}
/** More raw-text elements; a closing tag must really close ("</script-x>" does not). */
public function testRawTextBodiesOfAllKinds(): void
{
$value = '<iframe>Kontakt</iframe><noscript>Kontakt</noscript><script>a</script-x> Kontakt</script>'
. '<textarea>Kontakt</textarea>';
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
self::assertSame(1, $result['count']);
self::assertStringEndsWith('<textarea>Anfrage</textarea>', (string) self::valueOf($result['attrs']));
}
/** Search == replacement (or a cleaned replacement equal to the match) is no change and no hit. */
public function testNoEffectiveChangeIsNoHit(): void
{
$same = (new TextReplacer('Müller', 'Müller'))->apply(self::ATTRS, self::same());
$cleanedAway = (new TextReplacer('Müller', '<b>Müller</b>'))->apply(
self::ATTRS,
static fn (string $p, ?string $k, string $v): string => strip_tags($v)
);
$partly = (new TextReplacer('müller', 'Müller', false))->apply(self::ATTRS, self::same());
self::assertSame(0, $same['count']);
self::assertSame(self::ATTRS, $same['attrs']);
self::assertSame(0, $cleanedAway['count']);
self::assertSame(self::ATTRS, $cleanedAway['attrs']);
self::assertSame(1, $partly['count'], 'only MÜLLER changes');
}
/**
* Ruling S45 malicious rows: either refused (exception) or no change at all.
*
* @return array<string, array{string, string, string}>
*/
public static function s45(): array
{
return [
'double-quoted ">" then handler' => ['<a title="A > B" href="/kontakt">Link</a>', ' B', '" onmouseover="alert(1)'],
'single-quoted ">" then handler' => ["<img alt='A > B' src='/b.jpg'>", ' B', "' onerror='alert(1)"],
'href rewrite after quoted ">"' => ['<a title="x > y" href="/kontakt">Link</a>', 'kontakt', 'javascript:alert(1)'],
'comment with ">"' => ['<!-- a > B --><p>x</p>', ' B', '--><img src=x onerror=alert(1)>'],
'comment closed by --!>' => ['<!-- B --!><p>x</p>', ' B', 'x'],
'unterminated comment' => ['<p>x B</p><!-- B', ' B', 'y'],
'unterminated tag' => ['<p>ok B</p><a title="x B', ' B', 'y'],
'unbalanced quote' => ['<a title="x>B B</a>', ' B', 'y'],
'unquoted "<" in a tag' => ['<a <b>x B</b>', ' B', 'y'],
'lone "<" before text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'],
'"<" + letter in text' => ['a<B c', ' c', 'y'],
'raw body' => ['<iframe>x B</iframe>', ' B', '<img src=x onerror=alert(1)>'],
'fake script end' => ['<script>a</script-x> B</script>', ' B', 'y'],
'double-escaped script' => ['<script><!--<script></script> B--></script>', ' B', 'y'],
'unterminated reference' => ['$variable({"type":"content"} B', ' B', ')$'],
];
}
/** @dataProvider s45 */
public function testS45RowsAreRefusedOrUnchanged(string $value, string $search, string $replace): void
{
try {
$result = (new TextReplacer($search, $replace))->apply(self::text($value), self::same());
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('content.innerContent', $e->getMessage());
return;
}
self::assertSame(0, $result['count']);
self::assertSame($value, self::valueOf($result['attrs']));
}
/** @return array<string, array{string}> */
public static function longInputs(): array
{
$n = 200000;
return [
'many "<"' => [str_repeat('<', $n) . ' Kontakt'],
'many "<a"' => [str_repeat('<a ', intdiv($n, 3)) . ' Kontakt'],
'many open quotes' => [str_repeat('<a title="', intdiv($n, 10)) . ' Kontakt'],
'many comment openers' => [str_repeat('<!--', intdiv($n, 4)) . ' Kontakt'],
'comments then a closer' => [str_repeat('<!--', intdiv($n, 4)) . ' --> Kontakt'],
'many markers' => [str_repeat('$variable(', intdiv($n, 10)) . ' Kontakt'],
'many markers then a closer' => [str_repeat('$variable(', intdiv($n, 10)) . ')$ Kontakt'],
'many entities' => [str_repeat('&amp', intdiv($n, 4)) . ' Kontakt'],
'long text' => [str_repeat('Kontakt ', intdiv($n, 8))],
];
}
/** @dataProvider longInputs */
public function testLongInputsStayLinear(string $value): void
{
$start = microtime(true);
try {
(new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
} catch (InvalidArgumentException) {
// refused is fine
}
self::assertLessThan(2.0, microtime(true) - $start);
}
public function testRefusesAMarkerFromTheCallback(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -218,6 +346,8 @@ final class TextReplacerTest extends TestCase
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
'NUL in search' => ["Mül\0ler", 'x', 'search'],
'VT in search' => ["Mül\x0Bler", 'x', 'search'],
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
];
}