From 6b02ecd620f3181fa94586dc8c3dfc4650e87332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 14 Aug 2026 01:53:27 +0200 Subject: [PATCH 1/3] Limit structured URL rewriting to mapped base components --- .../class-blockmarkupurlprocessor.php | 69 ++++++-- .../Tests/BlockMarkupUrlProcessorTest.php | 70 ++++++++ .../DataLiberation/Tests/RewriteUrlsTest.php | 30 ++++ components/DataLiberation/Tests/WPURLTest.php | 154 ++++++++++++++++++ .../DataLiberation/URL/class-convertedurl.php | 11 ++ components/DataLiberation/URL/class-wpurl.php | 149 +++++++++++++++++ components/DataLiberation/URL/functions.php | 62 +++---- 7 files changed, 496 insertions(+), 49 deletions(-) diff --git a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php index 5c0452afe..668cd9595 100644 --- a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php +++ b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php @@ -23,6 +23,15 @@ class BlockMarkupUrlProcessor extends BlockMarkupProcessor { private $url_in_text_node_updated; private $css_url_processor; private $css_url_processor_updated; + /** + * One-based index of the current URL in the style attribute. + * + * Base replacement flushes CSS before a postprocess hook may call set_url(). + * The replacement processor is advanced back to the same URL afterward. + * + * @var int|null + */ + private $css_url_index; /** * The list of names of URL-related HTML attributes that may be available on @@ -158,6 +167,7 @@ private function next_url_in_css() { } $this->css_url_processor = new CSSURLProcessor( $css_value ); + $this->css_url_index = 0; } while ( $this->css_url_processor->next_url() ) { @@ -173,6 +183,7 @@ private function next_url_in_css() { if ( false === $this->parsed_url ) { continue; } + ++$this->css_url_index; return true; } @@ -383,15 +394,11 @@ public function set_url( $raw_url, $parsed_url ) { } /** - * Rewrites the components of the currently matched URL from ones - * provided in $from_url to ones specified in $to_url. + * Replaces mapped components while retaining structured URL suffix bytes. * - * It preserves the relative nature of the matched URL. - * - * @TODO: Should this method live in this class? It's specific to the import process - * and the URL rewriting logic and has knowledge about the quirks of detecting - * relative URLs in text nodes. On the other hand, the detection is performed - * by this WPURL_In_Text_Processor class so maybe the two do go hand in hand? + * WPURL supplies replacements for the decoded structured value. The existing + * HTML, CSS, or block setter then applies that value using its normal escaping. + * Text nodes retain the complete-value behavior needed by URLInTextProcessor. */ public function replace_base_url( $to_url, $base_url = null ) { $base_url = $base_url ?? $this->base_url_object; @@ -421,10 +428,52 @@ public function replace_base_url( $to_url, $base_url = null ) { if ( false === $result ) { return false; } + if ( '#text' === parent::get_token_type() ) { + return $this->set_url( (string) $result, $result->new_url ); + } + if ( null === $result->raw_url_base_replacements ) { + return false; + } + $raw_url = $this->get_raw_url(); + if ( ! is_string( $raw_url ) ) { + return false; + } - $this->set_url( $result . '', $result->new_url ); + $updated_raw_url = $raw_url; + foreach ( array_reverse( $result->raw_url_base_replacements ) as $replacement ) { + $updated_raw_url = substr_replace( + $updated_raw_url, + $replacement['replacement'], + $replacement['start'], + $replacement['length'] + ); + } + if ( empty( $result->raw_url_base_replacements ) ) { + $this->parsed_url = $result->new_url; + return true; + } + if ( null !== $this->css_url_processor ) { + /* + * CSSProcessor queues complete token replacements. Materialize the base + * replacement now so a postprocess hook may replace the same URL again. + */ + $css_url_index = $this->css_url_index; + if ( ! $this->set_url( $updated_raw_url, $result->new_url ) ) { + return false; + } + $this->get_updated_html(); + $this->css_url_processor = null; + for ( $index = 0; $index < $css_url_index; ++$index ) { + if ( ! $this->next_url_in_css() ) { + return false; + } + } + $this->raw_url = $updated_raw_url; + $this->parsed_url = $result->new_url; + return true; + } - return true; + return $this->set_url( $updated_raw_url, $result->new_url ); } /** diff --git a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php index 22193d9fe..a5b7751c1 100644 --- a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php +++ b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php @@ -305,6 +305,76 @@ public static function provider_test_next_url_replace_base_url() { ); } + /** + * @dataProvider provider_replace_base_url_in_structured_values + */ + public function test_replace_base_url_in_structured_values( $markup, $expected ) { + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertSame( $expected, $p->get_updated_html() ); + } + + public static function provider_replace_base_url_in_structured_values() { + return array( + 'HTML attribute' => array( + '', + '', + ), + 'CSS URL' => array( + '
', + '
', + ), + 'block attribute' => array( + '', + '', + ), + ); + } + + public function test_replace_base_url_updates_parsed_url_without_a_source_replacement() { + $markup = ''; + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://old.example/media' ) ); + $this->assertSame( 'https://old.example/media/file', $p->get_parsed_url()->toString() ); + $this->assertSame( $markup, $p->get_updated_html() ); + } + + public function test_replace_base_url_rewrites_later_css_urls_after_flushing() { + $markup = '
'; + $p = new BlockMarkupUrlProcessor( $markup, 'http://very-long-old.example/very/long/base' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $p->get_updated_html(); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $this->assertSame( + '
', + $p->get_updated_html() + ); + } + + public function test_set_url_replaces_a_materialized_css_base_update() { + $p = new BlockMarkupUrlProcessor( + '
', + 'http://old.example/media' + ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertTrue( $p->set_url( 'https://other.example/file', WPURL::parse( 'https://other.example/file' ) ) ); + $this->assertSame( + '
', + $p->get_updated_html() + ); + } + /** * @dataProvider provider_test_css_url_detection */ diff --git a/components/DataLiberation/Tests/RewriteUrlsTest.php b/components/DataLiberation/Tests/RewriteUrlsTest.php index 17aca6d57..8babab2c0 100644 --- a/components/DataLiberation/Tests/RewriteUrlsTest.php +++ b/components/DataLiberation/Tests/RewriteUrlsTest.php @@ -5,6 +5,36 @@ use function WordPress\DataLiberation\URL\wp_rewrite_urls; class RewriteUrlsTest extends TestCase { + public function test_structured_urls_use_the_selected_mapping_base() { + $this->assertSame( + '', + wp_rewrite_urls( + array( + 'block_markup' => '', + 'base_url' => 'http://old.example/root/first/', + 'url-mapping' => array( + 'http://old.example/root/first/' => 'https://first.example/first/', + 'http://old.example/root/second/' => 'https://second.example/assets/', + ), + ) + ) + ); + } + + public function test_text_urls_keep_the_configured_base_url_semantics() { + $this->assertSame( + '

https://new.example/assets/sub/file

', + wp_rewrite_urls( + array( + 'block_markup' => '

http://old.example/root/sub/file

', + 'base_url' => 'http://old.example/root/', + 'url-mapping' => array( + 'http://old.example/root/sub/' => 'https://new.example/assets/', + ), + ) + ) + ); + } /** * diff --git a/components/DataLiberation/Tests/WPURLTest.php b/components/DataLiberation/Tests/WPURLTest.php index 337e79491..91afa2cec 100644 --- a/components/DataLiberation/Tests/WPURLTest.php +++ b/components/DataLiberation/Tests/WPURLTest.php @@ -87,4 +87,158 @@ public static function provider_test_replace_base_url_trailing_slash() { ), ); } + + /** + * @dataProvider provider_replace_base_url_raw_url_base_replacements + */ + public function test_replace_base_url_returns_raw_url_base_replacements( + $raw_url, + $old_base_url, + $new_base_url, + $expected_raw_url, + $expected_replacement_count = null, + $include_raw_url = true + ) { + $url = WPURL::parse( $raw_url, $old_base_url ); + $options = array( + 'old_base_url' => $old_base_url, + 'new_base_url' => $new_base_url, + 'is_relative' => ! WPURL::can_parse( $raw_url ), + ); + if ( $include_raw_url ) { + $options['raw_url'] = $raw_url; + } + $result = WPURL::replace_base_url( + $url, + $options + ); + + $this->assertNotFalse( $result ); + if ( null === $expected_raw_url ) { + $this->assertNull( $result->raw_url_base_replacements ); + return; + } + + $this->assertNotNull( $result->raw_url_base_replacements ); + if ( null !== $expected_replacement_count ) { + $this->assertCount( $expected_replacement_count, $result->raw_url_base_replacements ); + } + foreach ( array_reverse( $result->raw_url_base_replacements ) as $replacement ) { + $raw_url = substr_replace( + $raw_url, + $replacement['replacement'], + $replacement['start'], + $replacement['length'] + ); + } + $this->assertSame( $expected_raw_url, $raw_url ); + $this->assertSame( + WPURL::parse( $expected_raw_url, $new_base_url )->toString(), + $result->new_url->toString() + ); + } + + public static function provider_replace_base_url_raw_url_base_replacements() { + return array( + 'absolute' => array( + 'http://old.example/media/file?x=1#section', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file?x=1#section', + ), + 'protocol-relative' => array( + '//old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '//new.example/assets/file', + ), + 'root-relative' => array( + '/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'percent-encoded source segment' => array( + '/m%65dia/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'path-relative' => array( + 'file', + 'http://old.example/', + 'https://new.example/assets/', + '/assets/file', + ), + 'query-only' => array( + '?x=1', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/?x=1', + ), + 'empty relative URL' => array( + '', + 'http://old.example/media/', + 'https://new.example/assets/', + '/assets/', + ), + 'exact absolute base' => array( + 'http://old.example/media', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets', + ), + 'explicit default port' => array( + 'http://old.example:80/media/file', + 'http://old.example/media', + 'https://old.example/assets', + 'https://old.example/assets/file', + ), + 'no source changes' => array( + 'http://OLD.example/media/%7euser', + 'http://old.example/media', + 'http://old.example/media', + 'http://OLD.example/media/%7euser', + 0, + ), + 'file URL' => array( + 'file://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'explicit scheme without slashes' => array( + 'http:media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'parent-directory-relative path' => array( + '../file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'backslashes in mapped components' => array( + 'http://old.example\\media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'encoded slash at the mapped boundary' => array( + '/media%2Fa', + 'http://old.example/media', + 'https://new.example/', + null, + ), + 'raw URL not supplied' => array( + 'http://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + null, + false, + ), + ); + } } diff --git a/components/DataLiberation/URL/class-convertedurl.php b/components/DataLiberation/URL/class-convertedurl.php index 1a12dade6..a057a53e1 100644 --- a/components/DataLiberation/URL/class-convertedurl.php +++ b/components/DataLiberation/URL/class-convertedurl.php @@ -22,6 +22,17 @@ class ConvertedUrl { /** @var string|null */ public $new_raw_relative_url; + /** + * Base-component replacements at ascending decoded-URL byte offsets. + * + * Null means the source spelling could not be aligned safely. An empty array + * means the mapped URL needs no source-byte changes. Apply non-empty entries + * from last to first so earlier offsets remain valid. + * + * @var array|null + */ + public $raw_url_base_replacements = null; + /** @var bool */ public $was_relative = false; diff --git a/components/DataLiberation/URL/class-wpurl.php b/components/DataLiberation/URL/class-wpurl.php index b12013617..7fb6334d3 100644 --- a/components/DataLiberation/URL/class-wpurl.php +++ b/components/DataLiberation/URL/class-wpurl.php @@ -230,11 +230,160 @@ public static function replace_base_url( $url, $options ) { $converted_url->was_relative = true; $converted_url->new_raw_relative_url = $relative_url; } + + $raw_url_base_replacements = self::create_raw_url_base_replacements( + $options['raw_url'], + $url, + $old_base_url, + $new_base_url + ); + if ( null !== $raw_url_base_replacements ) { + $updated_raw_url = $options['raw_url']; + foreach ( array_reverse( $raw_url_base_replacements ) as $replacement ) { + $updated_raw_url = substr_replace( + $updated_raw_url, + $replacement['replacement'], + $replacement['start'], + $replacement['length'] + ); + } + + $updated_raw_url = self::parse( $updated_raw_url, $new_base_url->toString() ); + $expected_url = self::parse( (string) $converted_url, $new_base_url->toString() ); + if ( + false !== $updated_raw_url && + false !== $expected_url && + $updated_raw_url->toString() === $expected_url->toString() + ) { + $converted_url->raw_url_base_replacements = $raw_url_base_replacements; + $converted_url->new_url = $updated_raw_url; + } + } } return $converted_url; } + /** + * Creates decoded-URL base replacements using slash-delimited path segments. + * + * @return array|null + */ + private static function create_raw_url_base_replacements( $raw_url, $url, $old_base_url, $new_base_url ) { + $source_url = self::parse( $raw_url, $old_base_url->toString() ); + if ( + false === $source_url || + $source_url->toString() !== $url->toString() || + ! is_child_url_of( $source_url, $old_base_url ) + ) { + return null; + } + + $is_absolute = 1 === preg_match( '/\A([A-Za-z][A-Za-z0-9+.\-]*):\/\//', $raw_url, $absolute_match ); + $is_protocol_relative = 0 === strpos( $raw_url, '//' ); + $is_path_relative = ! $is_absolute && ! $is_protocol_relative && 0 !== strpos( $raw_url, '/' ); + if ( + ( $is_absolute && 'file' === strtolower( $absolute_match[1] ) ) || + ( ! $is_absolute && 1 === preg_match( '/\A[A-Za-z][A-Za-z0-9+.\-]*:/', $raw_url ) ) || + ( $is_path_relative && ( 0 === strpos( $raw_url, './' ) || 0 === strpos( $raw_url, '../' ) ) ) + ) { + return null; + } + + $path_end = strcspn( $raw_url, '?#' ); + $path_start = 0; + $replacements = array(); + if ( $is_absolute || $is_protocol_relative ) { + $scheme_end = $is_absolute ? strpos( $raw_url, ':' ) : null; + $authority_start = $is_absolute ? $scheme_end + 3 : 2; + $path_start = $authority_start + strcspn( $raw_url, '/?#', $authority_start ); + if ( $authority_start === $path_start ) { + return null; + } + + $authority = substr( $raw_url, $authority_start, $path_start - $authority_start ); + $userinfo_at = strrpos( $authority, '@' ); + $host_start = $authority_start + ( false === $userinfo_at ? 0 : $userinfo_at + 1 ); + $raw_host = substr( $raw_url, $host_start, $path_start - $host_start ); + if ( $is_absolute && $url->protocol !== $new_base_url->protocol ) { + $replacements[] = array( + 'start' => 0, + 'length' => $scheme_end, + 'replacement' => rtrim( $new_base_url->protocol, ':' ), + ); + } + if ( + $url->host !== $new_base_url->host || + ( + $url->protocol !== $new_base_url->protocol && + 1 === preg_match( '/:\d+$/', $raw_host ) + ) + ) { + $replacements[] = array( + 'start' => $host_start, + 'length' => $path_start - $host_start, + 'replacement' => $new_base_url->host, + ); + } + } + + if ( $old_base_url->pathname !== $new_base_url->pathname ) { + $raw_path = substr( $raw_url, $path_start, $path_end - $path_start ); + + /* + * Consume the same number of slash-delimited segments as the parsed base. + * The caller rejects spellings where those segments do not produce the mapped URL. + */ + $source_segment_count = substr_count( rtrim( $old_base_url->pathname, '/' ), '/' ); + if ( $is_path_relative ) { + $source_segment_count = ( + '' === $raw_path || + 0 === $source_segment_count || + '/' === substr( $old_base_url->pathname, -1 ) + ) ? 0 : 1; + } + + $raw_path_segments = explode( '/', $raw_path ); + $segments_to_use = $source_segment_count + ( 0 === strpos( $raw_path, '/' ) ? 1 : 0 ); + if ( $segments_to_use > count( $raw_path_segments ) ) { + return null; + } + $source_length = strlen( implode( '/', array_slice( $raw_path_segments, 0, $segments_to_use ) ) ); + + $unmatched_path = substr( $raw_path, $source_length ); + $target_path = '/' === $new_base_url->pathname ? '' : rtrim( $new_base_url->pathname, '/' ); + if ( + '' !== $target_path && + ( + ( '' !== $unmatched_path && '/' !== $unmatched_path[0] ) || + ( '' === $unmatched_path && $path_end < strlen( $raw_url ) ) || + ( $is_path_relative && '' === $raw_path && '/' === substr( $old_base_url->pathname, -1 ) ) + ) + ) { + $target_path .= '/'; + } elseif ( + '' === $unmatched_path && + '' === $target_path && + ( ! $is_path_relative || '' === $raw_path ) + ) { + $target_path = '/'; + } + $replacements[] = array( + 'start' => $path_start, + 'length' => $source_length, + 'replacement' => $target_path, + ); + } + + foreach ( $replacements as $replacement ) { + if ( false !== strpos( substr( $raw_url, $replacement['start'], $replacement['length'] ), '\\' ) ) { + return null; + } + } + + return $replacements; + } + /** * Prepends a protocol to any matched URL without the double slash. * diff --git a/components/DataLiberation/URL/functions.php b/components/DataLiberation/URL/functions.php index fe931ce3d..ae5e4fdb4 100644 --- a/components/DataLiberation/URL/functions.php +++ b/components/DataLiberation/URL/functions.php @@ -8,8 +8,12 @@ require_once __DIR__ . '/class-urlrewritecache.php'; /** - * Migrate URLs in post content. See WPRewriteUrlsTests for - * specific examples. TODO: A better description. + * Rewrites mapped URLs in post content. + * + * Structured values apply WPURL's decoded-URL base replacements instead of its + * rendering of the unmatched suffix. The existing HTML, CSS, or block serializer + * still applies its normal escaping to the complete value. Text-node URLs retain + * their existing complete-value behavior. * * Example: * @@ -68,48 +72,28 @@ function wp_rewrite_urls( $options ) { $p = new BlockMarkupUrlProcessor( $options['block_markup'], $options['base_url'] ); while ( $p->next_url() ) { - $token_type = $p->get_token_type(); - $raw_url = $p->get_raw_url(); - $cache_key = $mapping_cache_key . "\0" . $token_type . "\0" . $raw_url; - - $cached = $rewrite_cache->get( $cache_key ); - if ( null !== $cached ) { - if ( false !== $cached ) { - $p->set_url( $cached['raw_url'], $cached['parsed_url'] ); + $cache_key = $mapping_cache_key . "\0" . $p->get_parsed_url()->toString(); + + $mapping_index = $rewrite_cache->get( $cache_key ); + if ( null === $mapping_index ) { + $mapping_index = false; + foreach ( $url_mapping as $index => $mapping ) { + if ( is_child_url_of( $p->get_parsed_url(), $mapping['from_url'] ) ) { + $mapping_index = $index; + break; + } } - continue; + $rewrite_cache->set( $cache_key, $mapping_index ); } - $parsed_url = $p->get_parsed_url(); - $converted = false; - foreach ( $url_mapping as $mapping ) { - if ( is_child_url_of( $parsed_url, $mapping['from_url'] ) ) { - $converted = WPURL::replace_base_url( - $parsed_url, - array( - 'old_base_url' => $base_url_object, - 'new_base_url' => $mapping['to_url'], - 'raw_url' => $raw_url, - 'is_relative' => ( - '#text' !== $token_type && - ! WPURL::can_parse( $raw_url ) - ), - ) - ); - break; - } - } - - $cache_value = false; - if ( false !== $converted ) { - $cache_value = array( - 'raw_url' => (string) $converted, - 'parsed_url' => $converted->new_url, + if ( false !== $mapping_index ) { + $p->replace_base_url( + $url_mapping[ $mapping_index ]['to_url'], + '#text' === $p->get_token_type() + ? $base_url_object + : $url_mapping[ $mapping_index ]['from_url'] ); - $p->set_url( $cache_value['raw_url'], $cache_value['parsed_url'] ); } - - $rewrite_cache->set( $cache_key, $cache_value ); } return $p->get_updated_html(); From e61be57b03c682042e9a7e11280f12eac9f09d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 14 Aug 2026 21:17:52 +0200 Subject: [PATCH 2/3] Validate structured URL base source boundaries --- components/DataLiberation/Tests/WPURLTest.php | 12 ++++++++++++ components/DataLiberation/URL/class-wpurl.php | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/components/DataLiberation/Tests/WPURLTest.php b/components/DataLiberation/Tests/WPURLTest.php index 91afa2cec..4ff21d07d 100644 --- a/components/DataLiberation/Tests/WPURLTest.php +++ b/components/DataLiberation/Tests/WPURLTest.php @@ -231,6 +231,18 @@ public static function provider_replace_base_url_raw_url_base_replacements() { 'https://new.example/', null, ), + 'parent segment hides a different lexical base' => array( + 'http://old.example/x/../media/file', + 'http://old.example/media', + 'http://old.example/foo/media', + null, + ), + 'encoded slash and dot hide different lexical segments' => array( + '/a%2Fb/./file', + 'http://old.example/a/b', + 'https://new.example/assets', + null, + ), 'raw URL not supplied' => array( 'http://old.example/media/file', 'http://old.example/media', diff --git a/components/DataLiberation/URL/class-wpurl.php b/components/DataLiberation/URL/class-wpurl.php index 7fb6334d3..826c48915 100644 --- a/components/DataLiberation/URL/class-wpurl.php +++ b/components/DataLiberation/URL/class-wpurl.php @@ -331,8 +331,9 @@ private static function create_raw_url_base_replacements( $raw_url, $url, $old_b $raw_path = substr( $raw_url, $path_start, $path_end - $path_start ); /* - * Consume the same number of slash-delimited segments as the parsed base. - * The caller rejects spellings where those segments do not produce the mapped URL. + * Propose a boundary after the same number of slash-delimited segments as the + * parsed base. Parsing that prefix validates its normalized path. Split before + * decoding so "%2F" cannot become a delimiter. */ $source_segment_count = substr_count( rtrim( $old_base_url->pathname, '/' ), '/' ); if ( $is_path_relative ) { @@ -350,6 +351,20 @@ private static function create_raw_url_base_replacements( $raw_url, $url, $old_b } $source_length = strlen( implode( '/', array_slice( $raw_path_segments, 0, $segments_to_use ) ) ); + $source_prefix_url = self::parse( + substr( $raw_url, 0, $path_start + $source_length ), + $old_base_url->toString() + ); + if ( + false === $source_prefix_url || + $source_prefix_url->protocol !== $old_base_url->protocol || + $source_prefix_url->host !== $old_base_url->host || + array_map( 'rawurldecode', explode( '/', rtrim( $source_prefix_url->pathname, '/' ) ) ) !== + array_map( 'rawurldecode', explode( '/', rtrim( $old_base_url->pathname, '/' ) ) ) + ) { + return null; + } + $unmatched_path = substr( $raw_path, $source_length ); $target_path = '/' === $new_base_url->pathname ? '' : rtrim( $new_base_url->pathname, '/' ); if ( From 4af53750e8e294ad8245c04b56ba57acb1c60021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 14 Aug 2026 22:08:41 +0200 Subject: [PATCH 3/3] Route source-preserving URL updates through setters --- .../class-blockmarkupurlprocessor.php | 71 +++++-------------- .../DataLiberation/CSS/class-cssprocessor.php | 37 ++++++---- .../Tests/BlockMarkupUrlProcessorTest.php | 6 +- .../DataLiberation/Tests/CSSProcessorTest.php | 3 +- components/DataLiberation/Tests/WPURLTest.php | 48 +++++++------ .../DataLiberation/URL/class-convertedurl.php | 11 ++- components/DataLiberation/URL/class-wpurl.php | 42 +++++------ components/DataLiberation/URL/functions.php | 7 +- 8 files changed, 102 insertions(+), 123 deletions(-) diff --git a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php index 668cd9595..fbfd62c10 100644 --- a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php +++ b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php @@ -23,15 +23,6 @@ class BlockMarkupUrlProcessor extends BlockMarkupProcessor { private $url_in_text_node_updated; private $css_url_processor; private $css_url_processor_updated; - /** - * One-based index of the current URL in the style attribute. - * - * Base replacement flushes CSS before a postprocess hook may call set_url(). - * The replacement processor is advanced back to the same URL afterward. - * - * @var int|null - */ - private $css_url_index; /** * The list of names of URL-related HTML attributes that may be available on @@ -167,7 +158,6 @@ private function next_url_in_css() { } $this->css_url_processor = new CSSURLProcessor( $css_value ); - $this->css_url_index = 0; } while ( $this->css_url_processor->next_url() ) { @@ -183,7 +173,6 @@ private function next_url_in_css() { if ( false === $this->parsed_url ) { continue; } - ++$this->css_url_index; return true; } @@ -362,6 +351,11 @@ public function set_url( $raw_url, $parsed_url ) { if ( null === $this->raw_url ) { return false; } + if ( $raw_url === $this->raw_url ) { + // A relative URL may resolve against a new base without changing its source spelling. + $this->parsed_url = $parsed_url; + return true; + } $this->raw_url = $raw_url; $this->parsed_url = $parsed_url; switch ( parent::get_token_type() ) { @@ -394,11 +388,16 @@ public function set_url( $raw_url, $parsed_url ) { } /** - * Replaces mapped components while retaining structured URL suffix bytes. + * Rewrites the components of the currently matched URL from ones + * provided in $base_url to ones specified in $to_url. * - * WPURL supplies replacements for the decoded structured value. The existing - * HTML, CSS, or block setter then applies that value using its normal escaping. - * Text nodes retain the complete-value behavior needed by URLInTextProcessor. + * Structured values retain unmatched decoded URL bytes and their relative + * nature. Text nodes retain their complete-value behavior. + * + * @TODO: Should this method live in this class? It's specific to the import process + * and the URL rewriting logic and has knowledge about the quirks of detecting + * relative URLs in text nodes. On the other hand, URLInTextProcessor performs + * that detection, so maybe the two do go hand in hand? */ public function replace_base_url( $to_url, $base_url = null ) { $base_url = $base_url ?? $this->base_url_object; @@ -431,49 +430,11 @@ public function replace_base_url( $to_url, $base_url = null ) { if ( '#text' === parent::get_token_type() ) { return $this->set_url( (string) $result, $result->new_url ); } - if ( null === $result->raw_url_base_replacements ) { - return false; - } - $raw_url = $this->get_raw_url(); - if ( ! is_string( $raw_url ) ) { + if ( null === $result->new_source_preserving_raw_url ) { return false; } - $updated_raw_url = $raw_url; - foreach ( array_reverse( $result->raw_url_base_replacements ) as $replacement ) { - $updated_raw_url = substr_replace( - $updated_raw_url, - $replacement['replacement'], - $replacement['start'], - $replacement['length'] - ); - } - if ( empty( $result->raw_url_base_replacements ) ) { - $this->parsed_url = $result->new_url; - return true; - } - if ( null !== $this->css_url_processor ) { - /* - * CSSProcessor queues complete token replacements. Materialize the base - * replacement now so a postprocess hook may replace the same URL again. - */ - $css_url_index = $this->css_url_index; - if ( ! $this->set_url( $updated_raw_url, $result->new_url ) ) { - return false; - } - $this->get_updated_html(); - $this->css_url_processor = null; - for ( $index = 0; $index < $css_url_index; ++$index ) { - if ( ! $this->next_url_in_css() ) { - return false; - } - } - $this->raw_url = $updated_raw_url; - $this->parsed_url = $result->new_url; - return true; - } - - return $this->set_url( $updated_raw_url, $result->new_url ); + return $this->set_url( $result->new_source_preserving_raw_url, $result->new_url ); } /** diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index e54f88864..0b32b77c4 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -888,23 +888,36 @@ public function set_token_value( string $new_value ): bool { // Only URL and string tokens are currently supported. switch ( $this->token_type ) { case self::TOKEN_URL: - $this->lexical_updates[] = array( - 'start' => $this->token_value_starts_at, - 'length' => $this->token_value_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_value_starts_at; + $update_length = $this->token_value_length; + break; case self::TOKEN_STRING: - $this->lexical_updates[] = array( - 'start' => $this->token_starts_at, - 'length' => $this->token_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_starts_at; + $update_length = $this->token_length; + break; default: _doing_it_wrong( __METHOD__, 'set_token_value() only supports URL and string tokens. Got token type: ' . $this->token_type, '1.0.0' ); return false; } + + $escaped_value = $this->escape_url_value( $new_value ); + $last_update_index = count( $this->lexical_updates ) - 1; + // A later write to the current token supersedes its pending update. + if ( + 0 <= $last_update_index && + $update_start === $this->lexical_updates[ $last_update_index ]['start'] && + $update_length === $this->lexical_updates[ $last_update_index ]['length'] + ) { + $this->lexical_updates[ $last_update_index ]['text'] = $escaped_value; + return true; + } + + $this->lexical_updates[] = array( + 'start' => $update_start, + 'length' => $update_length, + 'text' => $escaped_value, + ); + return true; } /** diff --git a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php index a5b7751c1..f8b3d65e9 100644 --- a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php +++ b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php @@ -196,7 +196,7 @@ public static function provider_test_set_url_examples() { 'In the "url" block attribute of a navigation-link block' => array( '', 'https://w.org', - '', + '', ), 'In a text node' => array( 'Have you seen https://wordpress.org yet?', @@ -343,7 +343,7 @@ public function test_replace_base_url_updates_parsed_url_without_a_source_replac $this->assertSame( $markup, $p->get_updated_html() ); } - public function test_replace_base_url_rewrites_later_css_urls_after_flushing() { + public function test_replace_base_url_rewrites_later_css_urls_after_rendering_an_earlier_update() { $markup = '
'; $p = new BlockMarkupUrlProcessor( $markup, 'http://very-long-old.example/very/long/base' ); @@ -359,7 +359,7 @@ public function test_replace_base_url_rewrites_later_css_urls_after_flushing() { ); } - public function test_set_url_replaces_a_materialized_css_base_update() { + public function test_set_url_replaces_a_pending_css_base_update() { $p = new BlockMarkupUrlProcessor( '
', 'http://old.example/media' diff --git a/components/DataLiberation/Tests/CSSProcessorTest.php b/components/DataLiberation/Tests/CSSProcessorTest.php index 965f9e5a3..30b79c1bf 100644 --- a/components/DataLiberation/Tests/CSSProcessorTest.php +++ b/components/DataLiberation/Tests/CSSProcessorTest.php @@ -1615,7 +1615,7 @@ public function test_set_token_value_handles_emoji(): void { } /** - * Tests that multiple URL values can be updated in the same CSS. + * Tests that multiple URL values can be updated and the last update to a token wins. */ public function test_set_token_value_multiple_urls(): void { $css = 'background: url(old1.jpg); border-image: url(old2.png);'; @@ -1628,6 +1628,7 @@ public function test_set_token_value_multiple_urls(): void { if ( 1 === $url_count ) { $processor->set_token_value( 'new1.jpg' ); } elseif ( 2 === $url_count ) { + $processor->set_token_value( 'discarded.png' ); $processor->set_token_value( 'new2.png' ); } } diff --git a/components/DataLiberation/Tests/WPURLTest.php b/components/DataLiberation/Tests/WPURLTest.php index 4ff21d07d..3de4eb9a6 100644 --- a/components/DataLiberation/Tests/WPURLTest.php +++ b/components/DataLiberation/Tests/WPURLTest.php @@ -89,14 +89,13 @@ public static function provider_test_replace_base_url_trailing_slash() { } /** - * @dataProvider provider_replace_base_url_raw_url_base_replacements + * @dataProvider provider_replace_base_url_source_preserving_raw_url */ - public function test_replace_base_url_returns_raw_url_base_replacements( + public function test_replace_base_url_returns_source_preserving_raw_url( $raw_url, $old_base_url, $new_base_url, $expected_raw_url, - $expected_replacement_count = null, $include_raw_url = true ) { $url = WPURL::parse( $raw_url, $old_base_url ); @@ -114,31 +113,18 @@ public function test_replace_base_url_returns_raw_url_base_replacements( ); $this->assertNotFalse( $result ); + $this->assertSame( $expected_raw_url, $result->new_source_preserving_raw_url ); if ( null === $expected_raw_url ) { - $this->assertNull( $result->raw_url_base_replacements ); return; } - $this->assertNotNull( $result->raw_url_base_replacements ); - if ( null !== $expected_replacement_count ) { - $this->assertCount( $expected_replacement_count, $result->raw_url_base_replacements ); - } - foreach ( array_reverse( $result->raw_url_base_replacements ) as $replacement ) { - $raw_url = substr_replace( - $raw_url, - $replacement['replacement'], - $replacement['start'], - $replacement['length'] - ); - } - $this->assertSame( $expected_raw_url, $raw_url ); $this->assertSame( WPURL::parse( $expected_raw_url, $new_base_url )->toString(), $result->new_url->toString() ); } - public static function provider_replace_base_url_raw_url_base_replacements() { + public static function provider_replace_base_url_source_preserving_raw_url() { return array( 'absolute' => array( 'http://old.example/media/file?x=1#section', @@ -194,12 +180,17 @@ public static function provider_replace_base_url_raw_url_base_replacements() { 'https://old.example/assets', 'https://old.example/assets/file', ), - 'no source changes' => array( + 'non-default source port' => array( + 'http://old.example:81/media/file', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file', + ), + 'no-op keeps same input' => array( 'http://OLD.example/media/%7euser', 'http://old.example/media', 'http://old.example/media', 'http://OLD.example/media/%7euser', - 0, ), 'file URL' => array( 'file://old.example/media/file', @@ -248,9 +239,24 @@ public static function provider_replace_base_url_raw_url_base_replacements() { 'http://old.example/media', 'https://new.example/assets', null, - null, false, ), ); } + + public function test_replace_base_url_source_preserving_raw_url_follows_the_semantic_result() { + $raw_url = 'http://user@old.example/media/file'; + $result = WPURL::replace_base_url( + WPURL::parse( $raw_url ), + array( + 'old_base_url' => 'http://old.example/media', + 'new_base_url' => 'https://new.example/assets', + 'raw_url' => $raw_url, + 'is_relative' => false, + ) + ); + + $this->assertNotFalse( $result ); + $this->assertSame( (string) $result, $result->new_source_preserving_raw_url ); + } } diff --git a/components/DataLiberation/URL/class-convertedurl.php b/components/DataLiberation/URL/class-convertedurl.php index a057a53e1..abcfe5ceb 100644 --- a/components/DataLiberation/URL/class-convertedurl.php +++ b/components/DataLiberation/URL/class-convertedurl.php @@ -23,15 +23,12 @@ class ConvertedUrl { public $new_raw_relative_url; /** - * Base-component replacements at ascending decoded-URL byte offsets. + * Updated decoded URL with only its base spelling changed, or null when the + * source spelling could not be aligned safely. * - * Null means the source spelling could not be aligned safely. An empty array - * means the mapped URL needs no source-byte changes. Apply non-empty entries - * from last to first so earlier offsets remain valid. - * - * @var array|null + * @var string|null */ - public $raw_url_base_replacements = null; + public $new_source_preserving_raw_url = null; /** @var bool */ public $was_relative = false; diff --git a/components/DataLiberation/URL/class-wpurl.php b/components/DataLiberation/URL/class-wpurl.php index 826c48915..61cb1d3b4 100644 --- a/components/DataLiberation/URL/class-wpurl.php +++ b/components/DataLiberation/URL/class-wpurl.php @@ -234,29 +234,30 @@ public static function replace_base_url( $url, $options ) { $raw_url_base_replacements = self::create_raw_url_base_replacements( $options['raw_url'], $url, + $updated_url, $old_base_url, $new_base_url ); if ( null !== $raw_url_base_replacements ) { - $updated_raw_url = $options['raw_url']; + $source_preserving_raw_url = $options['raw_url']; foreach ( array_reverse( $raw_url_base_replacements ) as $replacement ) { - $updated_raw_url = substr_replace( - $updated_raw_url, + $source_preserving_raw_url = substr_replace( + $source_preserving_raw_url, $replacement['replacement'], $replacement['start'], $replacement['length'] ); } - $updated_raw_url = self::parse( $updated_raw_url, $new_base_url->toString() ); - $expected_url = self::parse( (string) $converted_url, $new_base_url->toString() ); + $source_preserving_url = self::parse( $source_preserving_raw_url, $new_base_url->toString() ); + $expected_url = self::parse( (string) $converted_url, $new_base_url->toString() ); if ( - false !== $updated_raw_url && + false !== $source_preserving_url && false !== $expected_url && - $updated_raw_url->toString() === $expected_url->toString() + $source_preserving_url->toString() === $expected_url->toString() ) { - $converted_url->raw_url_base_replacements = $raw_url_base_replacements; - $converted_url->new_url = $updated_raw_url; + $converted_url->new_source_preserving_raw_url = $source_preserving_raw_url; + $converted_url->new_url = $source_preserving_url; } } } @@ -267,9 +268,13 @@ public static function replace_base_url( $url, $options ) { /** * Creates decoded-URL base replacements using slash-delimited path segments. * + * Only spellings whose base-component boundaries can be located from literal delimiters are accepted. + * Ambiguous spellings return null. The caller reparses the edited string and accepts + * it only when it matches the normal semantic conversion. + * * @return array|null */ - private static function create_raw_url_base_replacements( $raw_url, $url, $old_base_url, $new_base_url ) { + private static function create_raw_url_base_replacements( $raw_url, $url, $updated_url, $old_base_url, $new_base_url ) { $source_url = self::parse( $raw_url, $old_base_url->toString() ); if ( false === $source_url || @@ -305,24 +310,24 @@ private static function create_raw_url_base_replacements( $raw_url, $url, $old_b $userinfo_at = strrpos( $authority, '@' ); $host_start = $authority_start + ( false === $userinfo_at ? 0 : $userinfo_at + 1 ); $raw_host = substr( $raw_url, $host_start, $path_start - $host_start ); - if ( $is_absolute && $url->protocol !== $new_base_url->protocol ) { + if ( $is_absolute && $url->protocol !== $updated_url->protocol ) { $replacements[] = array( 'start' => 0, 'length' => $scheme_end, - 'replacement' => rtrim( $new_base_url->protocol, ':' ), + 'replacement' => rtrim( $updated_url->protocol, ':' ), ); } if ( - $url->host !== $new_base_url->host || + $url->host !== $updated_url->host || ( - $url->protocol !== $new_base_url->protocol && + $url->protocol !== $updated_url->protocol && 1 === preg_match( '/:\d+$/', $raw_host ) ) ) { $replacements[] = array( 'start' => $host_start, 'length' => $path_start - $host_start, - 'replacement' => $new_base_url->host, + 'replacement' => $updated_url->host, ); } } @@ -346,10 +351,7 @@ private static function create_raw_url_base_replacements( $raw_url, $url, $old_b $raw_path_segments = explode( '/', $raw_path ); $segments_to_use = $source_segment_count + ( 0 === strpos( $raw_path, '/' ) ? 1 : 0 ); - if ( $segments_to_use > count( $raw_path_segments ) ) { - return null; - } - $source_length = strlen( implode( '/', array_slice( $raw_path_segments, 0, $segments_to_use ) ) ); + $source_length = strlen( implode( '/', array_slice( $raw_path_segments, 0, $segments_to_use ) ) ); $source_prefix_url = self::parse( substr( $raw_url, 0, $path_start + $source_length ), @@ -358,7 +360,7 @@ private static function create_raw_url_base_replacements( $raw_url, $url, $old_b if ( false === $source_prefix_url || $source_prefix_url->protocol !== $old_base_url->protocol || - $source_prefix_url->host !== $old_base_url->host || + $source_prefix_url->hostname !== $old_base_url->hostname || array_map( 'rawurldecode', explode( '/', rtrim( $source_prefix_url->pathname, '/' ) ) ) !== array_map( 'rawurldecode', explode( '/', rtrim( $old_base_url->pathname, '/' ) ) ) ) { diff --git a/components/DataLiberation/URL/functions.php b/components/DataLiberation/URL/functions.php index ae5e4fdb4..3081bc169 100644 --- a/components/DataLiberation/URL/functions.php +++ b/components/DataLiberation/URL/functions.php @@ -10,10 +10,9 @@ /** * Rewrites mapped URLs in post content. * - * Structured values apply WPURL's decoded-URL base replacements instead of its - * rendering of the unmatched suffix. The existing HTML, CSS, or block serializer - * still applies its normal escaping to the complete value. Text-node URLs retain - * their existing complete-value behavior. + * Structured values change only the mapped base in the decoded URL. The existing + * HTML, CSS, or block serializer still applies its normal escaping to the complete + * value. Text-node URLs retain their existing complete-value behavior. * * Example: *