Preprocessor: Don't allow unclosed extension tags (matching until end of input)
authorBartosz Dziewoński <matma.rex@gmail.com>
Tue, 24 Nov 2015 21:55:24 +0000 (22:55 +0100)
committerTim Starling <tstarling@wikimedia.org>
Thu, 21 Jan 2016 04:22:34 +0000 (04:22 +0000)
I think it's saner to treat this as invalid syntax, and output the
mismatched tag code verbatim. The current behavior is particularly
annoying for <ref> tags, which often swallow everything afterwards.

This does not affect HTML tags, though. Assuming Tidy is enabled, they
are still auto-closed at the end of the page content.

Related to T17712 and T58306. I think this brings the PHP parser closer
to Parsoid's interpretation.

It reduces performance somewhat in the worst case, though. Testing with
https://phabricator.wikimedia.org/F3245989 (a 1 MB page starting with
3000 opening tags of 15 different types), parsing time rises from
~0.2 seconds to ~1.1 seconds on my setup. We go from O(N) to O(kN),
where N is bytes of input and k is the number of types of tags present
on the page. Maximum k shouldn't exceed 30 or so in reasonable setups
(depends on installed extensions, it's 20 on English Wikipedia).

To consider:
* Should we keep previous behavior for unclosed <includeonly> /
  <noinclude>? This would be particularly disruptive for these if
  someone relied on the old behavior, and they're already
  special-cased in places.
* Unclosed <pre> tags are now treated as HTML tags, and are still
  displayed as preformatted text, but without suppressing wikitext
  formatting.

Change-Id: Ia2f24dbfb3567c4b0778761585e6c0303d11ddd0

includes/parser/Preprocessor_DOM.php
includes/parser/Preprocessor_Hash.php
tests/parser/parserTests.txt
tests/phpunit/includes/parser/PreprocessorTest.php

index 4ca3a87..817f153 100644 (file)
@@ -237,6 +237,8 @@ class Preprocessor_DOM extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
+               // Map of tag name => true if there are no more closing tags of given type right of $i
+               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -457,17 +459,21 @@ class Preprocessor_DOM extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if (
+                                               !isset( $noMoreClosingTag[$name] ) &&
+                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = '<close>' . htmlspecialchars( $matches[0][0] ) . '</close>';
                                        } else {
-                                               // No end tag -- let it run out to the end of the text.
-                                               $inner = substr( $text, $tagEndPos + 1 );
-                                               $i = $lengthText;
-                                               $close = '';
+                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
+                                               $i = $tagEndPos + 1;
+                                               $accum .= htmlspecialchars( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
+                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
+                                               $noMoreClosingTag[$name] = true;
+                                               continue;
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index 50eaefb..28c49fd 100644 (file)
@@ -160,6 +160,8 @@ class Preprocessor_Hash extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
+               // Map of tag name => true if there are no more closing tags of given type right of $i
+               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -380,17 +382,21 @@ class Preprocessor_Hash extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if (
+                                               !isset( $noMoreClosingTag[$name] ) &&
+                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = $matches[0][0];
                                        } else {
-                                               // No end tag -- let it run out to the end of the text.
-                                               $inner = substr( $text, $tagEndPos + 1 );
-                                               $i = $lengthText;
-                                               $close = null;
+                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
+                                               $i = $tagEndPos + 1;
+                                               $accum->addLiteral( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
+                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
+                                               $noMoreClosingTag[$name] = true;
+                                               continue;
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index cd2b769..438fe31 100644 (file)
@@ -2520,7 +2520,6 @@ Barack Obama <President> of the United States
 </p>
 !! end
 
-## PHP parser discards the "<pre " string
 !! test
 Handle broken pre-like tags (bug 64025)
 !! options
@@ -2531,8 +2530,13 @@ parsoid=wt2html
 <table><pre </table>
 !! html/php
 <pre>x</pre>
-<table><pre></pre></table>
+<table>&lt;pre </table>
 
+!! html/php+tidy
+<pre>
+x
+</pre>
+<p>&lt;pre</p>
 !! html/parsoid
 <pre about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"a":{"&lt;pre":null},"sa":{"&lt;pre":""},"stx":"html","pi":[[{"k":"1","spc":["","","",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;pre &lt;pre>x&lt;/pre>"}},"i":0}}]}'>x</pre>
 
@@ -10876,6 +10880,8 @@ Un-closed <includeonly>
 !! wikitext
 <includeonly>
 !! html
+<p>&lt;includeonly&gt;
+</p>
 !! end
 
 ## We used to, but no longer wt2wt this test since the default serializer
index 1ebba1a..b940230 100644 (file)
@@ -48,7 +48,7 @@ class PreprocessorTest extends MediaWikiTestCase {
                        array( "<noinclude> Foo bar </noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore> Foo bar <ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore>\n</root>" ),
-                       array( "<gallery>foo bar", "<root><ext><name>gallery</name><attr></attr><inner>foo bar</inner></ext></root>" ),
+                       array( "<gallery>foo bar", "<root>&lt;gallery&gt;foo bar</root>" ),
                        array( "<{{foo}}>", "<root>&lt;<template><title>foo</title></template>&gt;</root>" ),
                        array( "<{{{foo}}}>", "<root>&lt;<tplarg><title>foo</title></tplarg>&gt;</root>" ),
                        array( "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner>&lt;/gallery</inner><close>&lt;/gallery&gt;</close></ext></root>" ),