From b1db3a83c946cb06d764ac10a3bf241fa44a3b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 10:39:36 +0200 Subject: [PATCH 1/5] Preserve CSS suffix bytes when escaping URL prefixes --- .../DataLiberation/CSS/class-cssprocessor.php | 79 ++++++++++++++-- components/DataLiberation/README.md | 12 +++ .../Tests/CSSPrefixEditProcessTest.php | 91 +++++++++++++++++++ .../Tests/fixtures/css-prefix/edit-file.php | 28 ++++++ 4 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 components/DataLiberation/Tests/CSSPrefixEditProcessTest.php create mode 100644 components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index e54f88864..49e6033be 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -891,14 +891,14 @@ public function set_token_value( string $new_value ): bool { $this->lexical_updates[] = array( 'start' => $this->token_value_starts_at, 'length' => $this->token_value_length, - 'text' => $this->escape_url_value( $new_value ), + 'text' => self::escape_url_value( $new_value ), ); return true; case self::TOKEN_STRING: $this->lexical_updates[] = array( 'start' => $this->token_starts_at, 'length' => $this->token_length, - 'text' => $this->escape_url_value( $new_value ), + 'text' => self::escape_url_value( $new_value ), ); return true; default: @@ -907,10 +907,61 @@ public function set_token_value( string $new_value ): bool { } } + /** + * Finds the source byte length of a decoded CSS value prefix. + * + * Decode with the same escape and UTF-8 rules used by token values before + * cutting the source bytes of the matched URL base. + * + * @param string $raw_value CSS value bytes without quotes or url(). + * @param int $decoded_bytes Number of decoded UTF-8 bytes to consume. + * @param bool $is_string Whether CSS string line continuations are allowed. + * @return int Source byte length of that prefix. + */ + public static function measure_value_prefix( string $raw_value, int $decoded_bytes, bool $is_string ): int { + $processor = new static( $raw_value ); + $at = 0; + $decoded = 0; + while ( $decoded < $decoded_bytes && $at < $processor->length ) { + $char = $raw_value[ $at ]; + if ( '\\' === $char && $is_string && $at + 1 < $processor->length && false !== strpos( "\r\n\f", $raw_value[ $at + 1 ] ) ) { + $at += "\r" === $raw_value[ $at + 1 ] && "\n" === substr( $raw_value, $at + 2, 1 ) ? 3 : 2; + } elseif ( '\\' === $char && $processor->is_valid_escape( $at ) ) { + ++$at; + $decoded += strlen( $processor->decode_escape_at( $at, $consumed ) ); + $at += $consumed; + } else { + $next = $at; + $invalid = 0; + if ( 1 === _wp_scan_utf8( $raw_value, $next, $invalid, null, 1 ) ) { + $decoded += "\x00" === $char ? 3 : $next - $at; + $at = $next; + } else { + $decoded += 3; + $at += $invalid; + } + } + } + return $at; + } + + /** + * Escapes a replacement prefix for either quoted or unquoted CSS URL syntax. + * + * Keep the existing quotes or url() delimiters outside the replacement so + * the unmatched suffix can keep its original source spelling. + * + * @param string $value Decoded replacement URL base. + * @return string CSS value bytes without surrounding quotes. + */ + public static function escape_value_prefix( string $value ): string { + return self::escape_url_value( $value, false ); + } + /** * Escapes a URL value for use in quoted url() syntax. * - * Always returns a quoted URL string since they're easier + * Whole-value replacements use quoted URL strings because they are easier * to escape. Quoted URLs are consumed using the string token * rules, and the only values we need to escape in strings, are: * @@ -918,13 +969,20 @@ public function set_token_value( string $new_value ): bool { * * Newlines. That amounts to \n, \r, \f, \r\n when preprocessing is considered. * * U+005C REVERSE SOLIDUS (\) * + * Prefix replacements keep the surrounding syntax and also escape spaces, + * controls, apostrophes, and parentheses to work in unquoted URLs. + * + * @param string $unescaped Decoded URL value or prefix. + * @param bool $quote Whether to wrap a complete replacement in quotes. + * @return string Escaped CSS value bytes. * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token */ - private function escape_url_value( string $unescaped ): string { + private static function escape_url_value( string $unescaped, bool $quote = true ): string { $escaped = ''; $at = 0; + $unsafe = $quote ? "\n\r\f\\\"" : "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\f\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f \x7f\\\"'()"; while ( $at < strlen( $unescaped ) ) { - $safe_len = strcspn( $unescaped, "\n\r\f\\\"", $at ); + $safe_len = strcspn( $unescaped, $unsafe, $at ); if ( $safe_len > 0 ) { $escaped .= substr( $unescaped, $at, $safe_len ); $at += $safe_len; @@ -945,7 +1003,7 @@ private function escape_url_value( string $unescaped ): string { * what the caller intended. */ $escaped .= '\\a '; - if ( strlen( $unescaped ) > $at + 1 && "\n" === $unescaped[ $at + 1 ] ) { + if ( strlen( $unescaped ) > $at && "\n" === $unescaped[ $at ] ) { ++$at; } break; @@ -963,11 +1021,12 @@ private function escape_url_value( string $unescaped ): string { $escaped .= '\\22 '; break; default: - _doing_it_wrong( __METHOD__, 'Unexpected character in URL value: ' . $unsafe_char, '1.0.0' ); + ++$at; + $escaped .= '\\' . dechex( ord( $unsafe_char ) ) . ' '; break; } } - return '"' . $escaped . '"'; + return $quote ? '"' . $escaped . '"' : $escaped; } /** @@ -1747,8 +1806,8 @@ private function decode_escape_at( int $offset, &$bytes_consumed ): string { // Hex digits (CSS spec allows at most 6). $hex_len = strspn( $this->css, '0123456789ABCDEFabcdef', $at, 6 ); if ( $hex_len > 0 ) { - $hex = substr( $this->css, $at, $hex_len ); - $at += $hex_len; + $hex = substr( $this->css, $at, $hex_len ); + $at += $hex_len; // If the next input code point is whitespace, consume it as well. if ( $at < $this->length ) { diff --git a/components/DataLiberation/README.md b/components/DataLiberation/README.md index 2c19f1232..bc7df9817 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -281,3 +281,15 @@ posts: 2 block markup exported frontmatter title exported ``` + +## Replace a CSS value prefix without changing its suffix + +`CSSProcessor::measure_value_prefix()` finds how many source bytes represent a +decoded prefix, including CSS escapes and string line continuations. Replace +those bytes with `escape_value_prefix()` output to keep the existing quotes, +`url()` wrapper, and unmatched suffix unchanged. The escaped replacement also +works in unquoted URLs. Whole-value `set_token_value()` still adds quotes and +normalizes CRLF to one newline without dropping text after a lone CR. + +[The file-edit test caller](Tests/fixtures/css-prefix/edit-file.php) exercises +both prefix and whole-value edits without streamed input or saved cursors. diff --git a/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php new file mode 100644 index 000000000..2f066a99e --- /dev/null +++ b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php @@ -0,0 +1,91 @@ +directory = sys_get_temp_dir() . '/css-prefix-' . bin2hex( random_bytes( 8 ) ); + mkdir( $this->directory ); + file_put_contents( $this->directory . '/replacement.txt', 'https://new.example' ); + } + + /** @after Removes only this test's files. */ + public function remove_directory() { + foreach ( glob( $this->directory . '/*' ) as $path ) { + unlink( $path ); + } + rmdir( $this->directory ); + } + + /** Prefix edits must preserve each wrapper and escaped suffix without adding CSS syntax. */ + public function test_prefix_file_edits_keep_quotes_and_raw_suffixes() { + $source = 'https://\\6f ld.example'; + $suffix = '\\/photo\\2e png'; + $input = 'a{src:url(' . $source . $suffix . '),url("' . $source . $suffix . '"),url(\'' . $source . $suffix . '\')}'; + $replacement = "https://new.example/(a)'\" " . chr( 1 ) . '\\'; + file_put_contents( $this->directory . '/source.css', $input ); + file_put_contents( $this->directory . '/replacement.txt', $replacement ); + $this->assertSame( 0, $this->run_worker( 'prefix' ), file_get_contents( $this->directory . '/worker.log' ) ); + $output = file_get_contents( $this->directory . '/target.css' ); + $this->assertSame( 3, substr_count( $output, $suffix ) ); + $this->assertSame( 3, substr_count( $output, 'url(' ) ); + $this->assertSame( 2, substr_count( $output, '"' ) ); + $this->assertSame( 2, substr_count( $output, "'" ) ); + $processor = new CSSURLProcessor( $output ); + for ( $index = 0; $index < 3; ++$index ) { + $this->assertTrue( $processor->next_url() ); + $this->assertSame( $replacement . '/photo.png', $processor->get_raw_url() ); + } + $this->assertFalse( $processor->next_url() ); + $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + } + + /** Malformed URL and string tokens must not acquire a partially replaced prefix. */ + public function test_prefix_file_edit_leaves_malformed_tokens_unchanged() { + $input = "a{src:url(https://old.example/bad(url)}\n@import \"https://old.example/bad\n"; + file_put_contents( $this->directory . '/source.css', $input ); + $this->assertSame( 0, $this->run_worker( 'prefix' ), file_get_contents( $this->directory . '/worker.log' ) ); + $this->assertSame( $input, file_get_contents( $this->directory . '/target.css' ) ); + $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + } + + /** CRLF becomes one newline; a lone CR must not swallow the following character. */ + public function test_whole_value_file_edit_preserves_text_after_carriage_returns() { + file_put_contents( $this->directory . '/source.css', 'a{src:url(https://old.example/a)}' ); + file_put_contents( $this->directory . '/replacement.txt', "https://new.example/a\r\nb\rX\nc" ); + $this->assertSame( 0, $this->run_worker( 'whole' ), file_get_contents( $this->directory . '/worker.log' ) ); + $processor = new CSSURLProcessor( file_get_contents( $this->directory . '/target.css' ) ); + $this->assertTrue( $processor->next_url() ); + $this->assertSame( "https://new.example/a\nb\nX\nc", $processor->get_raw_url() ); + } + + /** Replacement bytes must stay inside the value in all three URL quoting forms. */ + public function test_replacement_prefix_escapes_controls_and_delimiters() { + $input = "https://new.example/" . chr( 1 ) . "\rX\n \"'()"; + $escaped = CSSProcessor::escape_value_prefix( $input ); + foreach ( array( 'url(' . $escaped . ')', 'url("' . $escaped . '")', "url('" . $escaped . "')" ) as $css ) { + $processor = new CSSURLProcessor( $css ); + $this->assertTrue( $processor->next_url() ); + $this->assertSame( str_replace( "\r", "\n", $input ), $processor->get_raw_url() ); + } + } + + /** Runs a whole-file caller without streamed input or a saved cursor. */ + private function run_worker( $mode ) { + $arguments = array( PHP_BINARY, __DIR__ . '/fixtures/css-prefix/edit-file.php', $this->directory, $mode ); + $command = implode( ' ', array_map( 'escapeshellarg', $arguments ) ); + // Windows cmd.exe strips quotes from this command. Launch PHP directly; + // the command stays a string for PHP 7.2, which cannot accept an argument array. + $process = proc_open( $command, array( 0 => array( 'pipe', 'r' ), 1 => array( 'file', $this->directory . '/worker.log', 'w' ), 2 => array( 'file', $this->directory . '/worker.log', 'a' ) ), $pipes, null, null, array( 'bypass_shell' => true ) ); + $this->assertIsResource( $process ); + fclose( $pipes[0] ); + return proc_close( $process ); + } +} diff --git a/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php b/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php new file mode 100644 index 000000000..dab8b01b9 --- /dev/null +++ b/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php @@ -0,0 +1,28 @@ +next_token() ) { + $type = $processor->get_token_type(); + $piece = $processor->get_unnormalized_token(); + if ( in_array( $type, array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_STRING ), true ) && 0 === strpos( $processor->get_token_value(), 'https://old.example' ) ) { + if ( 'whole' === $mode ) { + $processor->set_token_value( $replacement ); + } else { + $start = $processor->get_token_value_start() - $processor->get_token_start(); + $raw = substr( $piece, $start, $processor->get_token_value_length() ); + $length = CSSProcessor::measure_value_prefix( $raw, strlen( 'https://old.example' ), CSSProcessor::TOKEN_STRING === $type ); + $piece = substr_replace( $piece, CSSProcessor::escape_value_prefix( $replacement ), $start, $length ); + } + } + $output .= $piece; +} +file_put_contents( $directory . '/target.css', 'whole' === $mode ? $processor->get_updated_css() : $output ); From edca8591a642d8cc93def1eab21e8dedc90615a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 11:18:27 +0200 Subject: [PATCH 2/5] Show CSS prefix test inputs and expected file contents directly --- components/DataLiberation/README.md | 5 +- .../Tests/CSSPrefixEditProcessTest.php | 191 +++++++++++++----- .../Tests/fixtures/css-prefix/edit-file.php | 28 --- .../css-prefix/replace-url-prefix.php | 28 +++ .../fixtures/css-prefix/replace-whole-url.php | 16 ++ 5 files changed, 187 insertions(+), 81 deletions(-) delete mode 100644 components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php create mode 100644 components/DataLiberation/Tests/fixtures/css-prefix/replace-url-prefix.php create mode 100644 components/DataLiberation/Tests/fixtures/css-prefix/replace-whole-url.php diff --git a/components/DataLiberation/README.md b/components/DataLiberation/README.md index bc7df9817..97b6995b1 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -291,5 +291,6 @@ those bytes with `escape_value_prefix()` output to keep the existing quotes, works in unquoted URLs. Whole-value `set_token_value()` still adds quotes and normalizes CRLF to one newline without dropping text after a lone CR. -[The file-edit test caller](Tests/fixtures/css-prefix/edit-file.php) exercises -both prefix and whole-value edits without streamed input or saved cursors. +[The prefix-edit caller](Tests/fixtures/css-prefix/replace-url-prefix.php) and +[the whole-URL caller](Tests/fixtures/css-prefix/replace-whole-url.php) show each +operation separately, without streamed input or saved cursors. diff --git a/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php index 2f066a99e..09d3c9f24 100644 --- a/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php +++ b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php @@ -4,16 +4,15 @@ use WordPress\DataLiberation\CSS\CSSProcessor; use WordPress\DataLiberation\URL\CSSURLProcessor; -/** Edits real CSS files in a separate PHP process, then parses the written output. */ +/** Rewrites real CSS files in a separate PHP process and checks their resulting contents. */ class CSSPrefixEditProcessTest extends TestCase { - /** @var string */ + /** @var string Temporary directory containing this test's input, replacement, output, and log. */ private $directory; - /** @before Creates source, replacement, output, and log paths for one caller. */ + /** @before Gives each test its own files; no replacement is selected implicitly. */ public function create_directory() { $this->directory = sys_get_temp_dir() . '/css-prefix-' . bin2hex( random_bytes( 8 ) ); mkdir( $this->directory ); - file_put_contents( $this->directory . '/replacement.txt', 'https://new.example' ); } /** @after Removes only this test's files. */ @@ -24,68 +23,158 @@ public function remove_directory() { rmdir( $this->directory ); } - /** Prefix edits must preserve each wrapper and escaped suffix without adding CSS syntax. */ - public function test_prefix_file_edits_keep_quotes_and_raw_suffixes() { - $source = 'https://\\6f ld.example'; - $suffix = '\\/photo\\2e png'; - $input = 'a{src:url(' . $source . $suffix . '),url("' . $source . $suffix . '"),url(\'' . $source . $suffix . '\')}'; - $replacement = "https://new.example/(a)'\" " . chr( 1 ) . '\\'; - file_put_contents( $this->directory . '/source.css', $input ); - file_put_contents( $this->directory . '/replacement.txt', $replacement ); - $this->assertSame( 0, $this->run_worker( 'prefix' ), file_get_contents( $this->directory . '/worker.log' ) ); - $output = file_get_contents( $this->directory . '/target.css' ); - $this->assertSame( 3, substr_count( $output, $suffix ) ); - $this->assertSame( 3, substr_count( $output, 'url(' ) ); - $this->assertSame( 2, substr_count( $output, '"' ) ); - $this->assertSame( 2, substr_count( $output, "'" ) ); - $processor = new CSSURLProcessor( $output ); - for ( $index = 0; $index < 3; ++$index ) { - $this->assertTrue( $processor->next_url() ); - $this->assertSame( $replacement . '/photo.png', $processor->get_raw_url() ); + /** Only the host changes; the three quoting styles and escaped filename stay as written. */ + public function test_prefix_replacement_preserves_quotes_and_escaped_filenames() { + // In CSS, \6f decodes to "o", \/ to "/", and \2e to ".". + $input_css = <<<'CSS' +a{src:url(https://\6f ld.example\/photo\2e png)} +b{src:url("https://\6f ld.example\/photo\2e png")} +c{src:url('https://\6f ld.example\/photo\2e png')} +CSS; + $expected_css = <<<'CSS' +a{src:url(https://new.example\/photo\2e png)} +b{src:url("https://new.example\/photo\2e png")} +c{src:url('https://new.example\/photo\2e png')} +CSS; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, 'https://new.example' ); + + $this->assertSame( $expected_css, $actual_css ); + } + + /** Quotes, parentheses, spaces, and backslashes in the new prefix must remain URL data. */ + public function test_replacement_characters_cannot_close_a_quote_or_url_function() { + $input_css = <<<'CSS' +a{src:url(https://old.example/photo.png)} +b{src:url("https://old.example/photo.png")} +c{src:url('https://old.example/photo.png')} +CSS; + $replacement_prefix = <<<'URL' +https://new.example/(a)'" \ +URL; + // CSS hex escapes: ( = \28, ) = \29, ' = \27, " = \22, space = \20, \ = \5C. + // The space after each hex escape ends that escape; it is not part of the URL. + $expected_css = <<<'CSS' +a{src:url(https://new.example/\28 a\29 \27 \22 \20 \5C /photo.png)} +b{src:url("https://new.example/\28 a\29 \27 \22 \20 \5C /photo.png")} +c{src:url('https://new.example/\28 a\29 \27 \22 \20 \5C /photo.png')} +CSS; + $expected_decoded_url = <<<'URL' +https://new.example/(a)'" \/photo.png +URL; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, $replacement_prefix ); + + $this->assertSame( $expected_css, $actual_css ); + $url_reader = new CSSURLProcessor( $actual_css ); + foreach ( array( 'unquoted', 'double quoted', 'single quoted' ) as $quoting_style ) { + $this->assertTrue( $url_reader->next_url(), $quoting_style ); + $this->assertSame( $expected_decoded_url, $url_reader->get_raw_url(), $quoting_style ); } - $this->assertFalse( $processor->next_url() ); - $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + $this->assertFalse( $url_reader->next_url(), 'Escaping must not introduce another URL.' ); + } + + /** An unescaped opening parenthesis makes an unquoted URL invalid. Leave it untouched. */ + public function test_invalid_unquoted_url_is_copied_unchanged() { + $input_css = 'a{src:url(https://old.example/bad(url)}'; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, 'https://new.example' ); + + $this->assertSame( $input_css, $actual_css ); + } + + /** A literal newline ends this string before a closing quote. Do not rewrite its prefix. */ + public function test_string_with_an_unescaped_newline_is_copied_unchanged() { + $input_css = <<<'CSS' +@import "https://old.example/bad +next-line; +CSS; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, 'https://new.example' ); + + $this->assertSame( $input_css, $actual_css ); } - /** Malformed URL and string tokens must not acquire a partially replaced prefix. */ - public function test_prefix_file_edit_leaves_malformed_tokens_unchanged() { - $input = "a{src:url(https://old.example/bad(url)}\n@import \"https://old.example/bad\n"; - file_put_contents( $this->directory . '/source.css', $input ); - $this->assertSame( 0, $this->run_worker( 'prefix' ), file_get_contents( $this->directory . '/worker.log' ) ); - $this->assertSame( $input, file_get_contents( $this->directory . '/target.css' ) ); - $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + /** A Windows-style CRLF line ending becomes one CSS newline escape, not two. */ + public function test_whole_url_replacement_encodes_crlf_as_one_newline() { + $input_css = 'a{src:url(https://old.example/photo.png)}'; + $replacement_url = "https://new.example/first\r\nsecond"; + $expected_css = 'a{src:url("https://new.example/first\a second")}'; + + $actual_css = $this->replace_whole_url_in_file( $input_css, $replacement_url ); + + $this->assertSame( $expected_css, $actual_css ); } - /** CRLF becomes one newline; a lone CR must not swallow the following character. */ - public function test_whole_value_file_edit_preserves_text_after_carriage_returns() { - file_put_contents( $this->directory . '/source.css', 'a{src:url(https://old.example/a)}' ); - file_put_contents( $this->directory . '/replacement.txt', "https://new.example/a\r\nb\rX\nc" ); - $this->assertSame( 0, $this->run_worker( 'whole' ), file_get_contents( $this->directory . '/worker.log' ) ); - $processor = new CSSURLProcessor( file_get_contents( $this->directory . '/target.css' ) ); - $this->assertTrue( $processor->next_url() ); - $this->assertSame( "https://new.example/a\nb\nX\nc", $processor->get_raw_url() ); + /** The X after a lone carriage return must survive; the following LF is a separate newline. */ + public function test_whole_url_replacement_keeps_text_after_a_lone_carriage_return() { + $input_css = 'a{src:url(https://old.example/photo.png)}'; + $replacement_url = "https://new.example/first\rX\nlast"; + $expected_css = 'a{src:url("https://new.example/first\a X\a last")}'; + + $actual_css = $this->replace_whole_url_in_file( $input_css, $replacement_url ); + + $this->assertSame( $expected_css, $actual_css ); } - /** Replacement bytes must stay inside the value in all three URL quoting forms. */ - public function test_replacement_prefix_escapes_controls_and_delimiters() { - $input = "https://new.example/" . chr( 1 ) . "\rX\n \"'()"; - $escaped = CSSProcessor::escape_value_prefix( $input ); - foreach ( array( 'url(' . $escaped . ')', 'url("' . $escaped . '")', "url('" . $escaped . "')" ) as $css ) { + /** Control byte 0x01 is escaped, and CR/LF normalize to newlines in every quoting style. */ + public function test_escaped_control_bytes_decode_to_the_expected_url() { + $url_prefix = "https://new.example/\x01\rX\n"; + $expected_escaped_prefix = 'https://new.example/\1 \a X\a '; + $expected_decoded_url = "https://new.example/\x01\nX\n"; + + $escaped_prefix = CSSProcessor::escape_value_prefix( $url_prefix ); + + $this->assertSame( $expected_escaped_prefix, $escaped_prefix ); + $css_values = array( + 'unquoted' => 'url(' . $escaped_prefix . ')', + 'double quoted' => 'url("' . $escaped_prefix . '")', + 'single quoted' => "url('" . $escaped_prefix . "')", + ); + foreach ( $css_values as $quoting_style => $css ) { $processor = new CSSURLProcessor( $css ); - $this->assertTrue( $processor->next_url() ); - $this->assertSame( str_replace( "\r", "\n", $input ), $processor->get_raw_url() ); + $this->assertTrue( $processor->next_url(), $quoting_style ); + $this->assertSame( $expected_decoded_url, $processor->get_raw_url(), $quoting_style ); + $this->assertFalse( $processor->next_url(), 'Escaping must not introduce another URL.' ); } } - /** Runs a whole-file caller without streamed input or a saved cursor. */ - private function run_worker( $mode ) { - $arguments = array( PHP_BINARY, __DIR__ . '/fixtures/css-prefix/edit-file.php', $this->directory, $mode ); + /** Replaces https://old.example in a real CSS file; returns CSS text with the suffix intact. */ + private function replace_url_prefix_in_file( string $input_css, string $replacement_prefix ): string { + return $this->rewrite_file_in_separate_php_process( 'replace-url-prefix.php', $input_css, $replacement_prefix ); + } + + /** Replaces the complete url() value in a real CSS file; returns CSS text with a quoted value. */ + private function replace_whole_url_in_file( string $input_css, string $replacement_url ): string { + return $this->rewrite_file_in_separate_php_process( 'replace-whole-url.php', $input_css, $replacement_url ); + } + + /** + * Runs one fixture script against real files and returns the written CSS, not an exit code. + * + * Both scripts read source.css and replacement.txt from the directory passed + * on the command line, and write target.css. A failed process fails the test + * here with its log, before the test compares the output CSS. + * + * @param string $fixture_script PHP filename under fixtures/css-prefix/. + * @param string $input_css CSS to write into source.css. + * @param string $replacement_url Decoded URL or prefix to write into replacement.txt. + * @return string Contents of target.css after a successful process exit. + */ + private function rewrite_file_in_separate_php_process( string $fixture_script, string $input_css, string $replacement_url ): string { + file_put_contents( $this->directory . '/source.css', $input_css ); + file_put_contents( $this->directory . '/replacement.txt', $replacement_url ); + $arguments = array( PHP_BINARY, __DIR__ . '/fixtures/css-prefix/' . $fixture_script, $this->directory ); $command = implode( ' ', array_map( 'escapeshellarg', $arguments ) ); + $log_path = $this->directory . '/rewrite.log'; // Windows cmd.exe strips quotes from this command. Launch PHP directly; // the command stays a string for PHP 7.2, which cannot accept an argument array. - $process = proc_open( $command, array( 0 => array( 'pipe', 'r' ), 1 => array( 'file', $this->directory . '/worker.log', 'w' ), 2 => array( 'file', $this->directory . '/worker.log', 'a' ) ), $pipes, null, null, array( 'bypass_shell' => true ) ); + $process = proc_open( $command, array( 0 => array( 'pipe', 'r' ), 1 => array( 'file', $log_path, 'w' ), 2 => array( 'file', $log_path, 'a' ) ), $pipes, null, null, array( 'bypass_shell' => true ) ); $this->assertIsResource( $process ); fclose( $pipes[0] ); - return proc_close( $process ); + $exit_code = proc_close( $process ); + $this->assertSame( 0, $exit_code, "The PHP file rewrite should exit successfully. Output:\n" . file_get_contents( $log_path ) ); + $this->assertSame( $input_css, file_get_contents( $this->directory . '/source.css' ), 'Rewriting the output must not change the source file.' ); + return file_get_contents( $this->directory . '/target.css' ); } } diff --git a/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php b/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php deleted file mode 100644 index dab8b01b9..000000000 --- a/components/DataLiberation/Tests/fixtures/css-prefix/edit-file.php +++ /dev/null @@ -1,28 +0,0 @@ -next_token() ) { - $type = $processor->get_token_type(); - $piece = $processor->get_unnormalized_token(); - if ( in_array( $type, array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_STRING ), true ) && 0 === strpos( $processor->get_token_value(), 'https://old.example' ) ) { - if ( 'whole' === $mode ) { - $processor->set_token_value( $replacement ); - } else { - $start = $processor->get_token_value_start() - $processor->get_token_start(); - $raw = substr( $piece, $start, $processor->get_token_value_length() ); - $length = CSSProcessor::measure_value_prefix( $raw, strlen( 'https://old.example' ), CSSProcessor::TOKEN_STRING === $type ); - $piece = substr_replace( $piece, CSSProcessor::escape_value_prefix( $replacement ), $start, $length ); - } - } - $output .= $piece; -} -file_put_contents( $directory . '/target.css', 'whole' === $mode ? $processor->get_updated_css() : $output ); diff --git a/components/DataLiberation/Tests/fixtures/css-prefix/replace-url-prefix.php b/components/DataLiberation/Tests/fixtures/css-prefix/replace-url-prefix.php new file mode 100644 index 000000000..f161a0a8c --- /dev/null +++ b/components/DataLiberation/Tests/fixtures/css-prefix/replace-url-prefix.php @@ -0,0 +1,28 @@ +next_token() ) { + $token_type = $processor->get_token_type(); + $token_css = $processor->get_unnormalized_token(); + if ( in_array( $token_type, array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_STRING ), true ) && 0 === strpos( $processor->get_token_value(), $source_prefix ) ) { + $value_offset_in_token = $processor->get_token_value_start() - $processor->get_token_start(); + $raw_value = substr( $token_css, $value_offset_in_token, $processor->get_token_value_length() ); + // The decoded prefix and its escaped CSS spelling can have different lengths. + $source_prefix_bytes = CSSProcessor::measure_value_prefix( $raw_value, strlen( $source_prefix ), CSSProcessor::TOKEN_STRING === $token_type ); + $escaped_replacement = CSSProcessor::escape_value_prefix( $replacement_prefix ); + $token_css = substr_replace( $token_css, $escaped_replacement, $value_offset_in_token, $source_prefix_bytes ); + } + $output_css .= $token_css; +} +file_put_contents( $directory . '/target.css', $output_css ); diff --git a/components/DataLiberation/Tests/fixtures/css-prefix/replace-whole-url.php b/components/DataLiberation/Tests/fixtures/css-prefix/replace-whole-url.php new file mode 100644 index 000000000..588302899 --- /dev/null +++ b/components/DataLiberation/Tests/fixtures/css-prefix/replace-whole-url.php @@ -0,0 +1,16 @@ +next_url() ) { + $processor->set_raw_url( $replacement_url ); +} +file_put_contents( $directory . '/target.css', $processor->get_updated_css() ); From 948e5eccbb5490c34e9dbb2b866b980dce1c95d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 14:54:31 +0200 Subject: [PATCH 3/5] Use native string operations for CSS URL prefix edits --- .../DataLiberation/CSS/class-cssprocessor.php | 114 +++++++++------ components/DataLiberation/README.md | 4 + .../Tests/CSSPrefixEditProcessTest.php | 30 ++++ .../Tests/CSSValuePrefixTest.php | 132 ++++++++++++++++++ 4 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 components/DataLiberation/Tests/CSSValuePrefixTest.php diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index 49e6033be..1c47c0b4f 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -923,6 +923,13 @@ public static function measure_value_prefix( string $raw_value, int $decoded_byt $at = 0; $decoded = 0; while ( $decoded < $decoded_bytes && $at < $processor->length ) { + // Ordinary URL bytes need no decoding. Stop the native scan at the prefix boundary. + $plain_bytes = strspn( $raw_value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&*+,;=%', $at, $decoded_bytes - $decoded ); + if ( $plain_bytes > 0 ) { + $at += $plain_bytes; + $decoded += $plain_bytes; + continue; + } $char = $raw_value[ $at ]; if ( '\\' === $char && $is_string && $at + 1 < $processor->length && false !== strpos( "\r\n\f", $raw_value[ $at + 1 ] ) ) { $at += "\r" === $raw_value[ $at + 1 ] && "\n" === substr( $raw_value, $at + 2, 1 ) ? 3 : 2; @@ -978,54 +985,69 @@ public static function escape_value_prefix( string $value ): string { * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token */ private static function escape_url_value( string $unescaped, bool $quote = true ): string { - $escaped = ''; - $at = 0; - $unsafe = $quote ? "\n\r\f\\\"" : "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\f\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f \x7f\\\"'()"; - while ( $at < strlen( $unescaped ) ) { - $safe_len = strcspn( $unescaped, $unsafe, $at ); - if ( $safe_len > 0 ) { - $escaped .= substr( $unescaped, $at, $safe_len ); - $at += $safe_len; - continue; - } + $unsafe = $quote ? "\n\r\f\\\"" : "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\f\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f \x7f\\\"'()"; + // Scanning once is cheaper than a replacement lookup for long URLs with nothing to escape. + if ( strcspn( $unescaped, $unsafe ) === strlen( $unescaped ) ) { + return $quote ? '"' . $unescaped . '"' : $unescaped; + } - $unsafe_char = $unescaped[ $at ]; - switch ( $unsafe_char ) { - case "\r": - ++$at; - /** - * Add a trailing space to prevent accidentally creating a - * wrong escape sequence. This is a valid CSS syntax and - * CSS parsers will ignore that whitespace. - * - * Without the space, "carriage\return" would be encoded as "carriage\aeturn", - * making `e` a part of the escape sequence `\ae` which is not - * what the caller intended. - */ - $escaped .= '\\a '; - if ( strlen( $unescaped ) > $at && "\n" === $unescaped[ $at ] ) { - ++$at; - } - break; - case "\f": - case "\n": - ++$at; - $escaped .= '\\a '; - break; - case '\\': - ++$at; - $escaped .= '\\5C '; - break; - case '"': - ++$at; - $escaped .= '\\22 '; - break; - default: - ++$at; - $escaped .= '\\' . dechex( ord( $unsafe_char ) ) . ' '; - break; - } + /** + * Add a trailing space to prevent accidentally creating a + * wrong escape sequence. This is a valid CSS syntax and + * CSS parsers will ignore that whitespace. + * + * Without the space, "carriage\return" would be encoded as "carriage\aeturn", + * making `e` a part of the escape sequence `\ae` which is not + * what the caller intended. + */ + $escapes = array( + "\r\n" => '\a ', + "\r" => '\a ', + "\n" => '\a ', + "\f" => '\a ', + '\\' => '\5C ', + '"' => '\22 ', + ); + if ( ! $quote ) { + $escapes += array( + "\x00" => '\0 ', + "\x01" => '\1 ', + "\x02" => '\2 ', + "\x03" => '\3 ', + "\x04" => '\4 ', + "\x05" => '\5 ', + "\x06" => '\6 ', + "\x07" => '\7 ', + "\x08" => '\8 ', + "\x09" => '\9 ', + "\x0b" => '\b ', + "\x0e" => '\e ', + "\x0f" => '\f ', + "\x10" => '\10 ', + "\x11" => '\11 ', + "\x12" => '\12 ', + "\x13" => '\13 ', + "\x14" => '\14 ', + "\x15" => '\15 ', + "\x16" => '\16 ', + "\x17" => '\17 ', + "\x18" => '\18 ', + "\x19" => '\19 ', + "\x1a" => '\1a ', + "\x1b" => '\1b ', + "\x1c" => '\1c ', + "\x1d" => '\1d ', + "\x1e" => '\1e ', + "\x1f" => '\1f ', + ' ' => '\20 ', + "\x7f" => '\7f ', + "'" => '\27 ', + '(' => '\28 ', + ')' => '\29 ', + ); } + // strtr() matches CRLF before CR and does not escape the spaces or backslashes it inserts. + $escaped = strtr( $unescaped, $escapes ); return $quote ? '"' . $escaped . '"' : $escaped; } diff --git a/components/DataLiberation/README.md b/components/DataLiberation/README.md index 97b6995b1..48821e838 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -291,6 +291,10 @@ those bytes with `escape_value_prefix()` output to keep the existing quotes, works in unquoted URLs. Whole-value `set_token_value()` still adds quotes and normalizes CRLF to one newline without dropping text after a lone CR. +Prefix measurement scans ordinary URL bytes with `strspn()` and decodes escapes +and UTF-8 separately. Replacement escaping uses `strtr()` rather than a PHP +character loop, with an early return when no bytes need escaping. + [The prefix-edit caller](Tests/fixtures/css-prefix/replace-url-prefix.php) and [the whole-URL caller](Tests/fixtures/css-prefix/replace-whole-url.php) show each operation separately, without streamed input or saved cursors. diff --git a/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php index 09d3c9f24..022aa714b 100644 --- a/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php +++ b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php @@ -83,6 +83,36 @@ public function test_invalid_unquoted_url_is_copied_unchanged() { $this->assertSame( $input_css, $actual_css ); } + /** A string can split the hostname across lines without adding a newline to its URL. */ + public function test_prefix_replacement_skips_string_line_continuations() { + // Backslash followed by a newline joins "o" and "ld.example" into "old.example". + $input_css = <<<'CSS' +a{src:url("https://o\ +ld.example\/photo\2e png")} +b{src:url('https://o\ +ld.example\/photo\2e png')} +CSS; + $expected_css = <<<'CSS' +a{src:url("https://new.example\/photo\2e png")} +b{src:url('https://new.example\/photo\2e png')} +CSS; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, 'https://new.example' ); + + $this->assertSame( $expected_css, $actual_css ); + } + + /** A CRLF in the new prefix must become one escape without escaping its inserted space again. */ + public function test_prefix_replacement_encodes_crlf_once_and_keeps_the_suffix() { + $input_css = 'a{src:url(https://old.example/photo.png)}'; + $replacement_prefix = "https://new.example/first\r\nsecond"; + $expected_css = 'a{src:url(https://new.example/first\a second/photo.png)}'; + + $actual_css = $this->replace_url_prefix_in_file( $input_css, $replacement_prefix ); + + $this->assertSame( $expected_css, $actual_css ); + } + /** A literal newline ends this string before a closing quote. Do not rewrite its prefix. */ public function test_string_with_an_unescaped_newline_is_copied_unchanged() { $input_css = <<<'CSS' diff --git a/components/DataLiberation/Tests/CSSValuePrefixTest.php b/components/DataLiberation/Tests/CSSValuePrefixTest.php new file mode 100644 index 000000000..09d7e3415 --- /dev/null +++ b/components/DataLiberation/Tests/CSSValuePrefixTest.php @@ -0,0 +1,132 @@ +assertSame( 19, $source_bytes ); + $this->assertSame( '/photo.png', substr( $raw_value, $source_bytes ) ); + } + + /** A four-byte CSS escape represents one decoded byte between two ordinary ASCII spans. */ + public function test_hex_escape_between_plain_spans_counts_its_source_bytes() { + $raw_value = 'https://\6f ld.example/photo.png'; + + $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, 19, false ); + + $this->assertSame( 22, $source_bytes ); + $this->assertSame( '/photo.png', substr( $raw_value, $source_bytes ) ); + } + + /** The backslash and CRLF occupy three source bytes but add no decoded bytes. */ + public function test_string_line_continuation_does_not_count_toward_the_decoded_prefix() { + $raw_value = "https://o\\\r\nld.example/photo.png"; + + $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, 19, true ); + + $this->assertSame( 22, $source_bytes ); + $this->assertSame( '/photo.png', substr( $raw_value, $source_bytes ) ); + } + + /** The é occupies two UTF-8 bytes; the ASCII scan resumes at z, not inside the character. */ + public function test_unicode_between_plain_spans_keeps_the_suffix_boundary() { + $raw_value = 'aéz/suffix'; + + $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, 4, true ); + + $this->assertSame( 4, $source_bytes ); + $this->assertSame( '/suffix', substr( $raw_value, $source_bytes ) ); + } + + /** NUL becomes the three-byte replacement character when the CSS value is decoded. */ + public function test_null_byte_counts_as_three_decoded_bytes() { + $raw_value = "a\x00z/suffix"; + + $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, 5, true ); + + $this->assertSame( 3, $source_bytes ); + $this->assertSame( '/suffix', substr( $raw_value, $source_bytes ) ); + } + + /** One incomplete UTF-8 sequence becomes one three-byte replacement character. */ + public function test_invalid_utf8_counts_as_one_replacement_character() { + $raw_value = "a\xe2\x82z/suffix"; + + $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, 5, true ); + + $this->assertSame( 4, $source_bytes ); + $this->assertSame( '/suffix', substr( $raw_value, $source_bytes ) ); + } + + /** An empty prefix must not enter the native scan or consume any source bytes. */ + public function test_empty_prefix_consumes_no_source_bytes() { + $this->assertSame( 0, CSSProcessor::measure_value_prefix( 'https://old.example', 0, false ) ); + } + + /** + * Every unsafe byte must trigger escaping even when no other unsafe byte is present. + * + * @dataProvider unsafe_prefix_bytes + * @param string $unsafe_byte The one byte appended to an otherwise plain URL. + * @param string $expected_escape Literal CSS bytes that must replace it. + */ + public function test_each_unsafe_byte_is_escaped( string $unsafe_byte, string $expected_escape ) { + $url_prefix = 'https://new.example/' . $unsafe_byte; + + $escaped_prefix = CSSProcessor::escape_value_prefix( $url_prefix ); + + $this->assertSame( 'https://new.example/' . $expected_escape, $escaped_prefix ); + } + + /** Pairs each unsafe byte with its literal CSS escape, including the terminating space. */ + public function unsafe_prefix_bytes() { + return array( + 'NUL' => array( "\x00", '\0 ' ), + '0x01' => array( "\x01", '\1 ' ), + '0x02' => array( "\x02", '\2 ' ), + '0x03' => array( "\x03", '\3 ' ), + '0x04' => array( "\x04", '\4 ' ), + '0x05' => array( "\x05", '\5 ' ), + '0x06' => array( "\x06", '\6 ' ), + '0x07' => array( "\x07", '\7 ' ), + '0x08' => array( "\x08", '\8 ' ), + 'tab' => array( "\t", '\9 ' ), + 'line feed' => array( "\n", '\a ' ), + 'vertical tab' => array( "\x0b", '\b ' ), + 'form feed' => array( "\f", '\a ' ), + 'carriage return' => array( "\r", '\a ' ), + '0x0e' => array( "\x0e", '\e ' ), + '0x0f' => array( "\x0f", '\f ' ), + '0x10' => array( "\x10", '\10 ' ), + '0x11' => array( "\x11", '\11 ' ), + '0x12' => array( "\x12", '\12 ' ), + '0x13' => array( "\x13", '\13 ' ), + '0x14' => array( "\x14", '\14 ' ), + '0x15' => array( "\x15", '\15 ' ), + '0x16' => array( "\x16", '\16 ' ), + '0x17' => array( "\x17", '\17 ' ), + '0x18' => array( "\x18", '\18 ' ), + '0x19' => array( "\x19", '\19 ' ), + '0x1a' => array( "\x1a", '\1a ' ), + '0x1b' => array( "\x1b", '\1b ' ), + '0x1c' => array( "\x1c", '\1c ' ), + '0x1d' => array( "\x1d", '\1d ' ), + '0x1e' => array( "\x1e", '\1e ' ), + '0x1f' => array( "\x1f", '\1f ' ), + 'space' => array( ' ', '\20 ' ), + 'DEL' => array( "\x7f", '\7f ' ), + 'backslash' => array( '\\', '\5C ' ), + 'double quote' => array( '"', '\22 ' ), + 'apostrophe' => array( "'", '\27 ' ), + 'opening parenthesis' => array( '(', '\28 ' ), + 'closing parenthesis' => array( ')', '\29 ' ), + ); + } +} From 825a45195d7e8b6d8b33b9b1e08faa544121178b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 15:32:16 +0200 Subject: [PATCH 4/5] Explain CSS prefix byte lengths with replacement examples --- .../DataLiberation/CSS/class-cssprocessor.php | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index 1c47c0b4f..66d8587d7 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -908,15 +908,44 @@ public function set_token_value( string $new_value ): bool { } /** - * Finds the source byte length of a decoded CSS value prefix. + * Returns how many original CSS bytes to replace for an already-matched URL prefix. * - * Decode with the same escape and UTF-8 rules used by token values before - * cutting the source bytes of the matched URL base. + * For example, "https://old.example" is 19 bytes. CSS can spell the "o" + * as "\6f " instead: four source bytes, including the space, that decode + * to one letter. The same URL prefix then takes 22 bytes in the CSS file: * - * @param string $raw_value CSS value bytes without quotes or url(). - * @param int $decoded_bytes Number of decoded UTF-8 bytes to consume. - * @param bool $is_string Whether CSS string line continuations are allowed. - * @return int Source byte length of that prefix. + * Decoded prefix: https://old.example (19 bytes) + * CSS source: https://\6f ld.example (22 bytes) + * + * The caller matches the decoded URL, but edits the original CSS. This + * method converts the matched prefix's decoded byte length to the source + * byte length needed by that edit. It uses the same escape and UTF-8 + * decoding rules as token values. It does not check whether the prefix + * matches, and does not change the CSS itself. + * + * Examples, both taken from unquoted url(...) values ($is_string = false): + * + * $decoded_prefix = 'https://old.example'; + * $decoded_bytes = strlen( $decoded_prefix ); // 19. + * + * // No CSS escapes: replace 19 source bytes for the 19-byte prefix. + * CSSProcessor::measure_value_prefix( 'https://old.example/photo.png', $decoded_bytes, false ); // 19. + * + * // Escaped "o": replace 22 source bytes for the same 19-byte prefix. + * $raw_value = 'https://\6f ld.example/photo\2e png'; + * $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, $decoded_bytes, false ); // 22. + * $updated = substr_replace( $raw_value, 'https://new.example', 0, $source_bytes ); + * // Result: https://new.example/photo\2e png + * + * The filename's "\2e " escape stays exactly as written. Using 19 instead + * of 22 in substr_replace() would leave "ple" from the old host and produce: + * https://new.exampleple/photo\2e png + * + * @param string $raw_value Original CSS value bytes, without quotes or the url() wrapper. + * @param int $decoded_bytes strlen() of the prefix already matched against the decoded value. + * @param bool $is_string True for a quoted CSS string: backslash-newline sequences occupy + * source bytes but add no decoded bytes. False for an unquoted URL. + * @return int Number of bytes to replace at the start of $raw_value, leaving the suffix untouched. */ public static function measure_value_prefix( string $raw_value, int $decoded_bytes, bool $is_string ): int { $processor = new static( $raw_value ); From 3d2c61e62b8dae2ea1e18c9df81839da44cd0b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 20:16:22 +0200 Subject: [PATCH 5/5] Explain prefix measurement using the actual CSS spelling --- .../DataLiberation/CSS/class-cssprocessor.php | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index 66d8587d7..3abcbab13 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -908,22 +908,33 @@ public function set_token_value( string $new_value ): bool { } /** - * Returns how many original CSS bytes to replace for an already-matched URL prefix. + * Measures where a matched prefix ends in the exact CSS text we received. * - * For example, "https://old.example" is 19 bytes. CSS can spell the "o" - * as "\6f " instead: four source bytes, including the space, that decode - * to one letter. The same URL prefix then takes 22 bytes in the CSS file: + * These spellings all decode to the same 19-byte prefix, "https://old.example": * - * Decoded prefix: https://old.example (19 bytes) - * CSS source: https://\6f ld.example (22 bytes) + * Actual CSS spelling Source bytes to replace + * https://old.example 19 + * https://\6f ld.example 22 + * https://\00006fld.example 25 * - * The caller matches the decoded URL, but edits the original CSS. This - * method converts the matched prefix's decoded byte length to the source - * byte length needed by that edit. It uses the same escape and UTF-8 - * decoding rules as token values. It does not check whether the prefix - * matches, and does not change the CSS itself. + * "\6f " and "\00006f" both mean "o", but occupy four and seven source + * bytes respectively. The space in "\6f " is part of that CSS escape. * - * Examples, both taken from unquoted url(...) values ($is_string = false): + * The method receives both the actual CSS text ($raw_value) and the + * matched prefix's decoded byte length ($decoded_bytes). It: + * + * 1. Reads that particular CSS spelling. + * 2. Decodes it until it has accounted for the requested decoded bytes. + * 3. Returns how many original bytes it consumed. + * + * The number 19 alone cannot determine the answer. The actual CSS source + * determines whether the result is 19, 22, or 25 in the examples above. + * The caller uses that result to cut off the old host without touching + * the filename. It has already checked that the decoded URL matches the + * prefix; this method only counts bytes and does not change the CSS. + * Escape and UTF-8 decoding follow the same rules as token values. + * + * Examples from unquoted url(...) values ($is_string = false): * * $decoded_prefix = 'https://old.example'; * $decoded_bytes = strlen( $decoded_prefix ); // 19. @@ -931,6 +942,9 @@ public function set_token_value( string $new_value ): bool { * // No CSS escapes: replace 19 source bytes for the 19-byte prefix. * CSSProcessor::measure_value_prefix( 'https://old.example/photo.png', $decoded_bytes, false ); // 19. * + * // A seven-byte spelling of "o" makes this prefix 25 source bytes long. + * CSSProcessor::measure_value_prefix( 'https://\00006fld.example/photo.png', $decoded_bytes, false ); // 25. + * * // Escaped "o": replace 22 source bytes for the same 19-byte prefix. * $raw_value = 'https://\6f ld.example/photo\2e png'; * $source_bytes = CSSProcessor::measure_value_prefix( $raw_value, $decoded_bytes, false ); // 22.