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:
@@ -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('&', 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'],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user