diff --git a/Michelf/Markdown.php b/Michelf/Markdown.php index 746a2d04..793451ab 100644 --- a/Michelf/Markdown.php +++ b/Michelf/Markdown.php @@ -374,7 +374,7 @@ protected function hashHTMLBlocks($text) { # closing nested tag ) | - <(?!/\2\s*> # other tags with a different name + <(?!/?\2[\s/>])(?!/\2\s*> # other tags (not an open/close of \2) ) )*', $nested_tags_level); diff --git a/test/unit/MarkdownHtmlBlockTest.php b/test/unit/MarkdownHtmlBlockTest.php new file mode 100644 index 00000000..2f27d853 --- /dev/null +++ b/test/unit/MarkdownHtmlBlockTest.php @@ -0,0 +1,118 @@ + array(str_repeat("
\n\n", 2000)), + 'div, single lines' => array(str_repeat("
\n", 2000)), + 'ins (group a)' => array(str_repeat("\n\n", 2000)), + 'del (group a)' => array(str_repeat("\n", 2000)), + 'section' => array(str_repeat("
\n\n", 2000)), + 'attr with > in quotes' => array(str_repeat("
b\">\n\n", 2000)), + ); + } + + /** + * @dataProvider unclosedBlocks + */ + public function testUnclosedBlocksDoNotWipeOutput(string $prefix) + { + $html = (new \Michelf\Markdown())->transform($prefix . "the trailing paragraph\n"); + // Before the fix this came back as "" under the default backtrack + // limit, or took seconds to minutes with the limit raised. + $this->assertStringContainsString('the trailing paragraph', $html); + } + + /** + * A realistic document whose real content surrounds a run of stray + * block-level tags must not be lost. Before the fix the unclosed tags + * blew the backtrack limit and the whole document came back empty, + * taking the heading and both paragraphs with it. + */ + public function testSurroundingContentSurvivesStrayTagRun() + { + $markdown = + "# My Page\n\n" . + "Intro paragraph.\n\n" . + str_repeat("
\n", 60) . + "\nConclusion paragraph.\n"; + + $html = (new \Michelf\Markdown())->transform($markdown); + + $this->assertStringContainsString('My Page', $html); + $this->assertStringContainsString('Intro paragraph.', $html); + $this->assertStringContainsString('Conclusion paragraph.', $html); + } + + /** + * The CPU-denial-of-service variant: with the backtrack limit raised (as + * the Readme suggests when output comes back empty) the old regex instead + * ran in time doubling roughly every dozen-and-a-half tags. Here a large + * unclosed run must parse quickly and keep its trailing content. + */ + public function testLargeUnclosedRunIsNotCatastrophic() + { + $markdown = str_repeat("
\n\n", 5000) . "the trailing paragraph\n"; + + $start = microtime(true); + $html = (new \Michelf\Markdown())->transform($markdown); + $elapsed = microtime(true) - $start; + + $this->assertStringContainsString('the trailing paragraph', $html); + // Linear now: ~0.05s in practice. A generous bound still fails hard + // against the old exponential behaviour. + $this->assertLessThan(10.0, $elapsed, 'HTML block parsing should be linear'); + } + + /** + * Well-formed nested block tags (up to the parser's supported depth) must + * still be recognised as a single HTML block and passed through verbatim. + */ + public function testWellFormedNestedBlocksAreUnchanged() + { + $html = "
\n
\n
\n
\nhello\n
\n
\n
\n
"; + + $result = (new \Michelf\Markdown())->transform($html); + + // The whole block is emitted as-is, not wrapped in

and not escaped. + $this->assertStringContainsString("

\nhello\n
", $result); + $this->assertStringNotContainsString('

', $result); + $this->assertStringNotContainsString('<', $result); + } + + public function tooDeeplyNested(): array + { + $cases = array(); + foreach (array(5, 6, 8) as $depth) { + $cases["depth $depth"] = array( + str_repeat("

\n", $depth) . "hello\n" . str_repeat("
\n", $depth), + ); + } + return $cases; + } + + /** + * Nesting deeper than the parser's hard limit (nested_tags_level = 4) was + * never supported and still produces malformed HTML either way. What must + * hold is that it never triggers the catastrophic empty-string wipe: the + * inner content always survives. + * + * @dataProvider tooDeeplyNested + */ + public function testNestingBeyondSupportedDepthKeepsContent(string $markdown) + { + $html = (new \Michelf\Markdown())->transform($markdown); + + $this->assertNotSame('', trim($html)); + $this->assertStringContainsString('hello', $html); + } +}