diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ea97113cb45b2..0afbbd00bb995 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -165,7 +165,7 @@ parameters: - message: '#^Unreachable statement \- code above always terminates\.$#' identifier: deadCode.unreachable - count: 15 + count: 12 path: src/wp-includes/html-api/class-wp-html-processor.php - message: '#^If condition is always true\.$#' diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 7f871ec9e6628..0979f2dfe0a66 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -262,6 +262,12 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor { */ private $context_node = null; + /** @var bool */ + private $has_produced_after_body_content = false; + + /** @var bool */ + private $has_produced_after_html_content = false; + /* * Public Interface Functions */ @@ -4366,6 +4372,9 @@ private function step_after_body(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_body_anything_else; @@ -4378,8 +4387,15 @@ private function step_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of BODY is unsupported.' ); - break; + if ( $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } + if ( ! $this->has_produced_after_body_content ) { + $this->state->stack_of_open_elements->pop_until( 'BODY' ); + $this->has_produced_after_body_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4422,6 +4438,9 @@ private function step_after_body(): bool { * > Parse error. Switch the insertion mode to "in body" and reprocess the token. */ after_body_anything_else: + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot return to in body when content has been produced outside of body.' ); + } $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; return $this->step( self::REPROCESS_CURRENT_NODE ); } @@ -4460,6 +4479,9 @@ private function step_in_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in frameset.' ); @@ -4582,6 +4604,9 @@ private function step_after_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in after frameset' ); @@ -4667,8 +4692,14 @@ private function step_after_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_produced_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_produced_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4688,6 +4719,9 @@ private function step_after_after_body(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_after_body_anything_else; @@ -4697,6 +4731,9 @@ private function step_after_after_body(): bool { * > Parse error. Switch the insertion mode to "in body" and reprocess the token. */ after_after_body_anything_else: + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot return to in body when content has been produced outside of body.' ); + } $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; return $this->step( self::REPROCESS_CURRENT_NODE ); } @@ -4732,8 +4769,14 @@ private function step_after_after_frameset(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_produced_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_produced_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4756,6 +4799,9 @@ private function step_after_after_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in after after frameset.' ); @@ -4765,6 +4811,9 @@ private function step_after_after_frameset(): bool { * > A start tag whose tag name is "noframes" */ case '+NOFRAMES': + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_head(); } @@ -5575,6 +5624,8 @@ public function seek( $bookmark_name ): bool { $this->state->current_token = null; $this->current_element = null; $this->element_queue = array(); + $this->has_produced_after_body_content = false; + $this->has_produced_after_html_content = false; /* * The absence of a context node indicates a full parse. diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php index 094fea367878a..23a2dff5e83c4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php @@ -170,4 +170,35 @@ public function test_bookmarks_not_allowed_on_virtual_nodes() { $this->assertTrue( $processor->next_token() ); $this->assertTrue( $processor->set_bookmark( 'mark' ) ); } + + /** + * Ensures that after a backward seek, content after BODY and HTML parses as it did the first time. + * + * @ticket 65967 + * + * @covers ::seek + */ + public function test_seek_backward_reparses_content_after_body_and_html() { + $processor = WP_HTML_Processor::create_full_parser( '

x

' ); + $this->assertTrue( $processor->next_tag( 'P' ) ); + $this->assertTrue( $processor->set_bookmark( 'p' ), 'Failed to set bookmark "p".' ); + + $walk = static function () use ( $processor ) { + $tokens = array(); + while ( $processor->next_token() ) { + $closer = $processor->is_tag_closer() ? '/' : ''; + $tokens[] = $closer . $processor->get_token_name() . ' ' . implode( ' > ', $processor->get_breadcrumbs() ); + } + return $tokens; + }; + + $first_pass = $walk(); + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document on the first pass.' ); + + $this->assertTrue( $processor->seek( 'p' ), 'Failed to seek to bookmark "p".' ); + + $second_pass = $walk(); + $this->assertSame( $first_pass, $second_pass, 'Found different tokens after seeking backward.' ); + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document after seeking backward.' ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php index 1ce8855305ae9..23920fb7915cc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php @@ -552,6 +552,105 @@ public static function data_provider_serialize_doctype() { ); } + /** + * Ensures that full documents serialize content after the BODY and HTML end tags + * at its place in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_provider_serialize_content_after_body_and_html + * + * @covers ::serialize + * + * @param string $html Full HTML document. + * @param string $expected Expected serialization. + */ + public function test_full_document_serialize_includes_content_after_body_and_html( string $html, string $expected ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $this->assertSame( $expected, $processor->serialize() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_provider_serialize_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( + '', + '', + ), + 'Processing instruction after BODY' => array( + '', + '', + ), + 'Whitespace and comment after BODY' => array( + ' ', + ' ', + ), + 'Comment after unclosed P and BODY' => array( + '

x', + '

x

', + ), + 'Comment after HTML' => array( + '', + '', + ), + 'Processing instruction after HTML' => array( + '', + '', + ), + 'Whitespace and comments after HTML' => array( + ' ', + ' ', + ), + 'Comments after BODY and after HTML' => array( + '', + '', + ), + 'Comment after FRAMESET and HTML' => array( + '', + '', + ), + 'Document with a trailing comment' => array( + 't

x

', + 't

x

', + ), + ); + } + + /** + * Ensures that normalize(), which parses its input as a BODY fragment, keeps + * content after BODY and HTML end tags inside BODY. + * + * @ticket 65967 + * + * @dataProvider data_provider_normalize_content_after_body_and_html + * + * @covers ::normalize + * + * @param string $html HTML fragment. + * @param string $expected Expected normalized HTML. + */ + public function test_normalize_keeps_content_after_body_and_html_in_body( string $html, string $expected ) { + $this->assertSame( $expected, WP_HTML_Processor::normalize( $html ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_provider_normalize_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( '

x

', '

x

' ), + 'Comment after unclosed P and BODY' => array( '

x', '

x

' ), + 'Whitespace after comment after BODY' => array( '

x

', '

x

' ), + 'Processing instruction and newline after HTML' => array( "

x

\n", "

x

\n" ), + ); + } + /** * Ensures that leading newlines in PRE, LISTING, and TEXTAREA elements are normalized * according to their parsing namespace, and that normalization is idempotent in these cases. diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 58e7ef18cba41..046c7a00dca08 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -1226,4 +1226,54 @@ public function test_prevents_unbounded_bookmarking() { $processor->set_bookmark( 'beyond the limit' ) ); } + + /** + * Ensures that after visiting a node outside of BODY, the processor bails + * on any later token it cannot visit in document order. + * + * @ticket 65967 + * + * @dataProvider data_unsupported_content_after_body_and_html + * + * @covers ::next_token + * + * @param string $html Full HTML document. + * @param string[] $breadcrumbs_before_bail Breadcrumbs of the last node visited before bailing. + */ + public function test_bails_on_unsupported_content_after_body_and_html( string $html, array $breadcrumbs_before_bail ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $breadcrumbs = array(); + while ( $processor->next_token() ) { + $breadcrumbs = $processor->get_breadcrumbs(); + } + + $this->assertSame( WP_HTML_Processor::ERROR_UNSUPPORTED, $processor->get_last_error(), 'Failed to bail on unsupported content.' ); + $this->assertSame( $breadcrumbs_before_bail, $breadcrumbs, 'Bailed at the wrong token.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unsupported_content_after_body_and_html(): array { + return array( + 'Whitespace after comment after BODY' => array( ' ', array( 'HTML', '#comment' ) ), + 'Whitespace after processing instruction after BODY' => array( ' ', array( 'HTML', '#processing-instruction' ) ), + 'Whitespace after presumptuous tag after BODY' => array( ' ', array( 'HTML', '#presumptuous-tag' ) ), + 'Whitespace into unclosed P after comment after BODY' => array( '

x ', array( 'HTML', '#comment' ) ), + 'Text after comment after BODY' => array( 'x', array( 'HTML', '#comment' ) ), + 'NULL byte after comment after BODY' => array( "\x00", array( 'HTML', '#comment' ) ), + 'Element after comment after BODY' => array( '

', array( 'HTML', '#comment' ) ), + 'BODY end tag after comment after BODY' => array( '', array( 'HTML', '#comment' ) ), + 'Whitespace after HTML after comment after BODY' => array( ' ', array( 'HTML', '#comment' ) ), + 'Newline after comment after HTML' => array( "\n", array( '#comment' ) ), + 'Text after comment after HTML' => array( 'x', array( '#comment' ) ), + 'Element after comment after HTML' => array( '

', array( '#comment' ) ), + 'HTML end tag after comment after HTML' => array( '', array( '#comment' ) ), + 'Whitespace after comment after FRAMESET and HTML' => array( ' ', array( '#comment' ) ), + 'NOFRAMES after comment after FRAMESET and HTML' => array( '', array( '#comment' ) ), + ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php index b54fc047ab040..9cc7c360aef91 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php @@ -580,4 +580,218 @@ public static function data_virtual_nodes_breadcrumbs() { 'Implied A tag closer on A tag descendent' => array( '', 4, 'A', 'close', array( 'HTML', 'BODY' ) ), ); } + + /** + * Ensures that nodes after the BODY and HTML end tags report their place in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_content_after_body_and_html + * + * @covers WP_HTML_Processor::get_breadcrumbs + * + * @param string $html Full HTML document. + * @param string[][] $expected_breadcrumbs Breadcrumbs of each node, in document order. + */ + public function test_breadcrumbs_of_content_after_body_and_html( string $html, array $expected_breadcrumbs ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $breadcrumbs = array(); + while ( $processor->next_token() ) { + if ( ! $processor->is_tag_closer() ) { + $breadcrumbs[] = $processor->get_breadcrumbs(); + } + } + + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document.' ); + $this->assertSame( $expected_breadcrumbs, $breadcrumbs, 'Found the wrong breadcrumbs.' ); + } + + /** + * Ensures that nodes after the BODY and HTML end tags report their depth in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_content_after_body_and_html + * + * @covers WP_HTML_Processor::get_current_depth + * + * @param string $html Full HTML document. + * @param string[][] $expected_breadcrumbs Breadcrumbs of each node, in document order. + */ + public function test_depth_of_content_after_body_and_html( string $html, array $expected_breadcrumbs ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $depths = array(); + while ( $processor->next_token() ) { + if ( ! $processor->is_tag_closer() ) { + $depths[] = $processor->get_current_depth(); + } + } + + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document.' ); + $this->assertSame( array_map( 'count', $expected_breadcrumbs ), $depths, 'Found the wrong depths.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + ), + ), + 'Processing instruction after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#processing-instruction' ), + ), + ), + 'Funky comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#funky-comment' ), + ), + ), + 'Whitespace after BODY' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + ), + ), + 'Whitespace and comment after BODY' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comments after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + array( 'HTML', '#comment' ), + ), + ), + 'Ignored DOCTYPE after comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after closed P and BODY' => array( + '

x

', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', 'P' ), + array( 'HTML', 'BODY', 'P', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after unclosed P and BODY' => array( + '

x', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', 'P' ), + array( 'HTML', 'BODY', 'P', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#comment' ), + ), + ), + 'Processing instruction after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#processing-instruction' ), + ), + ), + 'Whitespace after HTML' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + ), + ), + 'Whitespace and comment after HTML' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + array( '#comment' ), + ), + ), + 'Ignored DOCTYPE after comment after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#comment' ), + ), + ), + 'Comments after BODY and after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + array( '#comment' ), + ), + ), + 'Comment after FRAMESET and HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'FRAMESET' ), + array( '#comment' ), + ), + ), + ); + } }