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 01/12] 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 b1f6ec242a6be1d9f1fb586e2efe52ef5189f049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 10:42:24 +0200 Subject: [PATCH 02/12] Recognize import and image-set strings as CSS URLs --- components/DataLiberation/README.md | 10 +++ .../Tests/CSSURLContextProcessTest.php | 68 ++++++++++++++++++ .../fixtures/css-context/rewrite-file.php | 13 ++++ .../URL/class-cssurlprocessor.php | 72 ++++++++++++++----- 4 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 components/DataLiberation/Tests/CSSURLContextProcessTest.php create mode 100644 components/DataLiberation/Tests/fixtures/css-context/rewrite-file.php diff --git a/components/DataLiberation/README.md b/components/DataLiberation/README.md index bc7df9817..8981230cf 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -293,3 +293,13 @@ 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. + +## Find CSS URLs in imports and image sets + +`CSSURLProcessor::next_url()` recognizes `url()`, bare `@import` strings, and +strings used as images in `image-set()` or `-webkit-image-set()`. Comments, +displayed text, MIME-type strings, and malformed string or URL tokens are +not returned. More than 128 nested image sets throw an error. + +[The file-rewrite caller](Tests/fixtures/css-context/rewrite-file.php) uses the +whole-string iterator and writes the output only after iteration completes. diff --git a/components/DataLiberation/Tests/CSSURLContextProcessTest.php b/components/DataLiberation/Tests/CSSURLContextProcessTest.php new file mode 100644 index 000000000..e16acb474 --- /dev/null +++ b/components/DataLiberation/Tests/CSSURLContextProcessTest.php @@ -0,0 +1,68 @@ +directory = sys_get_temp_dir() . '/css-context-' . bin2hex( random_bytes( 8 ) ); + mkdir( $this->directory ); + } + + /** @after Removes only this test's files. */ + public function remove_directory() { + foreach ( glob( $this->directory . '/*' ) as $path ) { + unlink( $path ); + } + rmdir( $this->directory ); + } + + /** Import and image-set strings are URLs, but comments, MIME types, and displayed text are not. */ + public function test_file_rewrite_finds_only_url_contexts() { + $input = '/* https://old.example/comment */ @import "https://old.example/theme.css";a{src:image-set("https://old.example/a" 1x,url(https://old.example/b) 2x,"https://old.example/c" type("https://old.example/mime"));content:"https://old.example/text"}'; + $expected = '/* https://old.example/comment */ @import "https://new.example/theme.css";a{src:image-set("https://new.example/a" 1x,url("https://new.example/b") 2x,"https://new.example/c" type("https://old.example/mime"));content:"https://old.example/text"}'; + file_put_contents( $this->directory . '/source.css', $input ); + $this->assertSame( 0, $this->run_worker(), file_get_contents( $this->directory . '/worker.log' ) ); + $this->assertSame( $expected, file_get_contents( $this->directory . '/target.css' ) ); + $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + } + + /** The caller must not publish a stylesheet after URL-context tracking rejects excessive nesting. */ + public function test_nesting_failure_leaves_the_existing_output_untouched() { + $input = 'a{src:' . str_repeat( 'image-set(', 129 ) . '"https://old.example/a"' . str_repeat( ')', 129 ) . '}'; + file_put_contents( $this->directory . '/source.css', $input ); + file_put_contents( $this->directory . '/target.css', 'previous output' ); + $this->assertNotSame( 0, $this->run_worker() ); + $this->assertStringContainsString( 'nesting exceeds 128', file_get_contents( $this->directory . '/worker.log' ) ); + $this->assertSame( 'previous output', file_get_contents( $this->directory . '/target.css' ) ); + $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + } + + /** The existing iterator recognizes import and image-set URLs without matching displayed text. */ + public function test_whole_string_finder_recognizes_import_and_image_set_urls() { + $css = '@import "https://old.example/theme.css";a{src:image-set("https://old.example/a" 1x,url(https://old.example/b) 2x,"https://old.example/c" 3x);content:"https://old.example/text"}'; + $processor = new CSSURLProcessor( $css ); + $urls = array(); + while ( $processor->next_url() ) { + $urls[] = $processor->get_raw_url(); + } + $this->assertSame( array( 'https://old.example/theme.css', 'https://old.example/a', 'https://old.example/b', 'https://old.example/c' ), $urls ); + } + + /** Runs a whole-file caller without streamed input or a saved cursor. */ + private function run_worker() { + $arguments = array( PHP_BINARY, __DIR__ . '/fixtures/css-context/rewrite-file.php', $this->directory ); + $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-context/rewrite-file.php b/components/DataLiberation/Tests/fixtures/css-context/rewrite-file.php new file mode 100644 index 000000000..be680bf80 --- /dev/null +++ b/components/DataLiberation/Tests/fixtures/css-context/rewrite-file.php @@ -0,0 +1,13 @@ +next_url() ) { + $processor->set_raw_url( str_replace( 'https://old.example/', 'https://new.example/', $processor->get_raw_url() ) ); +} +// A failed parse must leave any previous output intact. +file_put_contents( $directory . '/target.css', $processor->get_updated_css() ); diff --git a/components/DataLiberation/URL/class-cssurlprocessor.php b/components/DataLiberation/URL/class-cssurlprocessor.php index 92f4876d5..b62f8be4b 100644 --- a/components/DataLiberation/URL/class-cssurlprocessor.php +++ b/components/DataLiberation/URL/class-cssurlprocessor.php @@ -12,6 +12,13 @@ class CSSURLProcessor { * @var CSSProcessor */ private $processor; + /** @var array URL syntax context shared by whole-string and streaming callers. */ + private $context = array( + 'depth' => 0, + 'images' => array(), + 'expect' => '', + ); + /** * @param string $css CSS source without wrapping braces. @@ -27,31 +34,58 @@ public function __construct( string $css ) { */ public function next_url(): bool { while ( $this->processor->next_token() ) { - $type = $this->processor->get_token_type(); - - // Direct URL token. - if ( CSSProcessor::TOKEN_URL === $type ) { + $type = $this->processor->get_token_type(); + $name = in_array( $type, array( CSSProcessor::TOKEN_FUNCTION, CSSProcessor::TOKEN_AT_KEYWORD ), true ) ? $this->processor->get_token_value() : ''; + $is_url = $this->inspect_url_context( $type, $name ); + if ( $is_url && in_array( $type, array( CSSProcessor::TOKEN_STRING, CSSProcessor::TOKEN_URL ), true ) ) { return true; } + } + return false; + } - // url() function with STRING token. - if ( CSSProcessor::TOKEN_FUNCTION === $type && - 0 === strcasecmp( $this->processor->get_token_value(), 'url' ) ) { - // Look ahead for STRING token, skipping whitespace. - while ( $this->processor->next_token() ) { - $inner_type = $this->processor->get_token_type(); - if ( CSSProcessor::TOKEN_WHITESPACE === $inner_type ) { - continue; // Skip whitespace. - } - if ( CSSProcessor::TOKEN_STRING === $inner_type ) { - return true; // Found the URL string. - } - // Hit something else (like RIGHT_PAREN or another token). - break; + /** + * Recognizes URL values without treating comments or displayed text as URLs. + * + * The url() function expects a STRING token after whitespace. Bare @import and + * image-set strings use the same lookahead; another token clears that expectation. + * Direct unquoted URL tokens already include their url() wrapper. + * + * @param string $type CSS token type. + * @param string $name Decoded function or at-keyword name, otherwise empty. + * @return bool Whether this begins a URL value. + */ + private function inspect_url_context( string $type, string $name ): bool { + if ( in_array( $type, array( CSSProcessor::TOKEN_WHITESPACE, CSSProcessor::TOKEN_COMMENT ), true ) ) { + return false; + } + $expected = $this->context['expect']; + $this->context['expect'] = ''; + if ( CSSProcessor::TOKEN_FUNCTION === $type ) { + ++$this->context['depth']; + $name = strtolower( $name ); + $this->context['expect'] = 'url' === $name ? 'url' : ''; + if ( in_array( $name, array( 'image-set', '-webkit-image-set' ), true ) ) { + if ( count( $this->context['images'] ) >= 128 ) { + throw new \RuntimeException( 'CSS image-set nesting exceeds 128 open functions.' ); } + $this->context['images'][] = $this->context['depth']; + $this->context['expect'] = 'image'; + } + } elseif ( CSSProcessor::TOKEN_AT_KEYWORD === $type ) { + $this->context['expect'] = 'import' === strtolower( $name ) ? 'import' : ''; + } elseif ( CSSProcessor::TOKEN_LEFT_PAREN === $type ) { + ++$this->context['depth']; + } elseif ( CSSProcessor::TOKEN_RIGHT_PAREN === $type ) { + if ( end( $this->context['images'] ) === $this->context['depth'] ) { + array_pop( $this->context['images'] ); } + $this->context['depth'] = max( 0, $this->context['depth'] - 1 ); + } elseif ( CSSProcessor::TOKEN_COMMA === $type && end( $this->context['images'] ) === $this->context['depth'] ) { + $this->context['expect'] = 'image'; } - return false; + return in_array( $type, array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_BAD_URL ), true ) || + ( '' !== $expected && in_array( $type, array( CSSProcessor::TOKEN_STRING, CSSProcessor::TOKEN_BAD_STRING ), true ) ); } /** From 4558bee203ce3884b7c3cd9b73d07d08396d38f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 10:44:46 +0200 Subject: [PATCH 03/12] Stream CSS tokens and resume unfinished input --- .../DataLiberation/CSS/class-cssprocessor.php | 125 +++++++++++++++++- components/DataLiberation/README.md | 22 +++ .../Tests/CSSStreamProcessTest.php | 72 ++++++++++ .../DataLiberation/Tests/CSSStreamTest.php | 82 ++++++++++++ .../css-token-stream/rewrite-file.php | 47 +++++++ 5 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 components/DataLiberation/Tests/CSSStreamProcessTest.php create mode 100644 components/DataLiberation/Tests/CSSStreamTest.php create mode 100644 components/DataLiberation/Tests/fixtures/css-token-stream/rewrite-file.php diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index 49e6033be..5fcb63cd7 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -308,6 +308,9 @@ class CSSProcessor { */ private $lexical_updates = array(); + /** @var bool Whether an unfinished token may receive more source bytes. */ + private $expecting_more_input = false; + /** * Constructor for the CSS processor. * @@ -341,6 +344,98 @@ public static function create( string $css, string $encoding = 'UTF-8' ) { return new static( $css ); } + /** + * Opens a processor that keeps unfinished tokens until more input arrives. + * + * @param array|null $cursor { + * Optional state from get_reentrancy_cursor(), after flushing processed CSS. + * @type string $pending_b64 Unprocessed source bytes, base64 encoded. + * @type bool $expecting_more_input Whether the source has more bytes. + * } + * @return static + */ + public static function create_for_streaming( ?array $cursor = null ) { + if ( null !== $cursor && ( ! isset( $cursor['pending_b64'], $cursor['expecting_more_input'] ) || ! is_string( $cursor['pending_b64'] ) || ! is_bool( $cursor['expecting_more_input'] ) ) ) { + throw new \InvalidArgumentException( 'The CSS cursor must contain a base64 input tail and whether more input is expected.' ); + } + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Cursor JSON must preserve arbitrary source bytes. + $pending = null === $cursor ? '' : base64_decode( $cursor['pending_b64'], true ); + if ( false === $pending ) { + throw new \InvalidArgumentException( 'The CSS cursor contains an invalid base64 input tail.' ); + } + $processor = new static( $pending ); + $processor->expecting_more_input = $cursor['expecting_more_input'] ?? true; + return $processor; + } + + /** + * Appends source bytes after the caller has flushed completed tokens. + * + * The unfinished token stays in memory and is parsed again. Neither its size + * nor the saved cursor size is capped. Use small reads to limit other input. + * + * @param string $bytes Next source bytes. + * @param bool $is_last Whether this is the actual end of the stylesheet. + */ + public function append_bytes( string $bytes, bool $is_last = false ): void { + if ( ! $this->expecting_more_input ) { + throw new \LogicException( 'CSS input cannot be appended after the end of the stylesheet.' ); + } + if ( 0 !== $this->at ) { + throw new \LogicException( 'Flush processed CSS before appending more input.' ); + } + $this->css .= $bytes; + $this->length = strlen( $this->css ); + $this->expecting_more_input = ! $is_last; + } + + /** Returns whether more source bytes may be appended. */ + public function is_expecting_more_input(): bool { + return $this->expecting_more_input; + } + + /** + * Returns edited, completed input and keeps the unfinished token for another read. + * + * Flush before appending input or saving a cursor. Existing whole-token setters + * work in streaming mode; their edits are applied only to the returned prefix. + * + * @return string Processed CSS, including edits made with set_token_value(). + */ + public function flush_processed_css(): string { + if ( 0 === $this->at ) { + return ''; + } + $pending = substr( $this->css, $this->at ); + $updated = $this->get_updated_css(); + $output = substr( $updated, 0, strlen( $updated ) - strlen( $pending ) ); + $this->css = $pending; + $this->length = strlen( $pending ); + $this->at = 0; + $this->lexical_updates = array(); + $this->after_token(); + return $output; + } + + /** + * Returns unfinished input to save beside the caller's source and output offsets. + * + * @return array { + * @type string $pending_b64 Unprocessed source bytes, base64 encoded. + * @type bool $expecting_more_input Whether the source has more bytes. + * } + */ + public function get_reentrancy_cursor(): array { + if ( 0 !== $this->at ) { + throw new \LogicException( 'Flush processed CSS before saving a cursor.' ); + } + return array( + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Cursor JSON must preserve arbitrary source bytes. + 'pending_b64' => base64_encode( $this->css ), + 'expecting_more_input' => $this->expecting_more_input, + ); + } + /** * Moves to the next token in the CSS stream. * @@ -348,9 +443,27 @@ public static function create( string $css, string $encoding = 'UTF-8' ) { * * @see https://www.w3.org/TR/css-syntax-3/#consume-token * - * @return bool Whether a token was found. + * @return bool Whether a complete token was found; false also means more input is needed. */ public function next_token(): bool { + $start = $this->at; + if ( ! $this->scan_next_token() ) { + return false; + } + // A CSS escape needs at most six hex digits and CRLF after its backslash. + // Ten bytes also cover UTF-8 and the lookahead used to classify a token. + // A name may become a function, and a number may acquire a unit. Retry the + // whole token when more bytes arrive instead of saving its internal state. + if ( $this->expecting_more_input && $this->at > $this->length - 10 ) { + $this->at = $start; + $this->after_token(); + return false; + } + return true; + } + + /** Reads a whole token with the same CSS rules for complete and growing input. */ + private function scan_next_token(): bool { $this->after_token(); // Bale out once we reach the end. @@ -1408,6 +1521,11 @@ private function consume_url(): bool { // Repeatedly consume the next input code point from the stream. while ( $this->at < $this->length ) { + $plain = strspn( $this->css, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&*+,;=%', $this->at ); + if ( $plain > 0 ) { + $this->at += $plain; + continue; + } // U+0029 RIGHT PARENTHESIS ()) // Return the . if ( ')' === $this->css[ $this->at ] ) { @@ -1564,6 +1682,11 @@ private function consume_remnants_of_bad_url(): bool { */ private function consume_ident_sequence() { while ( $this->at < $this->length ) { + $plain = strspn( $this->css, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_', $this->at ); + if ( $plain > 0 ) { + $this->at += $plain; + continue; + } $codepoint_bytes = $this->consume_ident_codepoint( $this->at ); if ( $codepoint_bytes > 0 ) { $this->at += $codepoint_bytes; diff --git a/components/DataLiberation/README.md b/components/DataLiberation/README.md index 8981230cf..e850f2348 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -303,3 +303,25 @@ not returned. More than 128 nested image sets throw an error. [The file-rewrite caller](Tests/fixtures/css-context/rewrite-file.php) uses the whole-string iterator and writes the output only after iteration completes. + +## Stream CSS tokens and resume + +`CSSProcessor::create_for_streaming()` accepts source chunks through +`append_bytes($bytes, $is_last)`. Read complete tokens with the existing +`next_token()`, getters, and `set_token_value()`. If a token is unfinished, +the processor keeps it and tries again after the next read, like XML. +Only mark actual source EOF, not the end of an interrupted response. + +`flush_processed_css()` returns completed input with edits applied and releases +its source bytes. Call it before appending more input or saving a cursor. +`get_reentrancy_cursor()` saves the unfinished input in a JSON-safe array; +pass it to `create_for_streaming()` in the next process. Save the source offset, +output offset, and cursor together after writing and flushing the output. +Resume truncates output to that saved offset before replaying more source. +[The separate-process file caller](Tests/fixtures/css-token-stream/rewrite-file.php) +shows both sides of that checkpoint boundary. + +There is no token-size cap. A large comment, string, identifier, or embedded +image increases memory use and the saved cursor size. Each read reparses the +unfinished token. Small chunks therefore do not bound the largest token's +memory use or parsing work. diff --git a/components/DataLiberation/Tests/CSSStreamProcessTest.php b/components/DataLiberation/Tests/CSSStreamProcessTest.php new file mode 100644 index 000000000..ec4643d00 --- /dev/null +++ b/components/DataLiberation/Tests/CSSStreamProcessTest.php @@ -0,0 +1,72 @@ +directory = sys_get_temp_dir() . '/css-token-stream-' . bin2hex( random_bytes( 8 ) ); + mkdir( $this->directory ); + } + + /** @after */ + public function remove_directory() { + foreach ( glob( $this->directory . '/*' ) as $path ) { + unlink( $path ); + } + rmdir( $this->directory ); + } + + /** @dataProvider interruptions */ + public function test_token_edits_resume_after_process_death( $stop ) { + // The first boundary splits a CSS escape; the second remains inside a comment. + $prefix = '/*' . str_repeat( 'a', 32743 ) . '*/a{src:url("https://\\6f ld.example/a.png")}'; + $input = $prefix . '/*' . str_repeat( 'b', 32768 ) . '*/' + . '@import "https://old.example/theme.css";' + . 'a{src:url(https://old.example/' . str_repeat( 'c', 131072 ) . ')}'; + $this->assertSame( '\\6', substr( $input, 32766, 2 ) ); + $whole = CSSProcessor::create( $input ); + while ( $whole->next_token() ) { + if ( in_array( $whole->get_token_type(), array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_STRING ), true ) ) { + $whole->set_token_value( str_replace( 'https://old.example/', 'https://old.example/moved/', $whole->get_token_value() ) ); + } + } + $expected = $whole->get_updated_css(); + file_put_contents( $this->directory . '/source.css', $input ); + $this->assertSame( 'none' === $stop ? 0 : 99, $this->run_worker( $stop ), file_get_contents( $this->directory . '/worker.log' ) ); + if ( 'none' !== $stop ) { + $state = json_decode( file_get_contents( $this->directory . '/state.json' ), true ); + $this->assertGreaterThan( 0, $state['source_bytes'] ); + $this->assertLessThan( strlen( $input ), $state['source_bytes'] ); + $this->assertNotSame( '', $state['css']['pending_b64'] ); + $this->assertSame( 0, $this->run_worker( 'none' ), file_get_contents( $this->directory . '/worker.log' ) ); + } + $this->assertSame( hash( 'sha256', $expected ), hash_file( 'sha256', $this->directory . '/target.css' ) ); + $this->assertSame( $input, file_get_contents( $this->directory . '/source.css' ) ); + $state = json_decode( file_get_contents( $this->directory . '/state.json' ), true ); + $this->assertSame( strlen( $input ), $state['source_bytes'] ); + $this->assertSame( strlen( $expected ), $state['output_bytes'] ); + } + + /** Runs through completion or exits on either side of the second file checkpoint. */ + public static function interruptions() { + return array( array( 'none' ), array( 'before' ), array( 'after' ) ); + } + + /** Runs the same caller used for uninterrupted and resumed file rewrites. */ + private function run_worker( $stop ) { + $arguments = array( PHP_BINARY, __DIR__ . '/fixtures/css-token-stream/rewrite-file.php', $this->directory . '/source.css', $this->directory . '/target.css', $this->directory . '/state.json', $stop ); + $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/CSSStreamTest.php b/components/DataLiberation/Tests/CSSStreamTest.php new file mode 100644 index 000000000..ad420415a --- /dev/null +++ b/components/DataLiberation/Tests/CSSStreamTest.php @@ -0,0 +1,82 @@ +next_token() ) { + $whole_tokens[] = array( $whole->get_token_type(), $whole->get_token_value(), $whole->get_unnormalized_token() ); + } + for ( $offset = 0; $offset <= strlen( $input ); ++$offset ) { + $processor->append_bytes( substr( $input, $offset, 1 ), strlen( $input ) === $offset ); + $steps = 0; + while ( $processor->next_token() ) { + $tokens[] = array( $processor->get_token_type(), $processor->get_token_value(), $processor->get_unnormalized_token() ); + $this->assertLessThan( 64, ++$steps, 'The lexer must consume input or finish its current token.' ); + } + $output .= $processor->flush_processed_css(); + $processor = CSSProcessor::create_for_streaming( json_decode( json_encode( $processor->get_reentrancy_cursor() ), true ) ); + } + $this->assertSame( $input, $output ); + $this->assertSame( $whole_tokens, $tokens ); + } + + /** Supplies the CSS corpus plus byte sequences that cross UTF-8 and escape boundaries. */ + public static function corpus() { + $cases = json_decode( file_get_contents( __DIR__ . '/css-test-cases.json' ), true ); + foreach ( $cases as $name => $case ) { + yield $name => array( $case['css'] ); + } + yield 'invalid UTF-8' => array( "a{content:\"bad\xff\xc3x\xe2\x82\x80\"}" ); + yield 'UTF-8 at every position' => array( 'a{content:"aé東京😀\1f600 \0000e9 b"}' ); + } + + /** A buffered string uses the same UTF-8 and escape rules as whole-string input. */ + public function test_buffered_strings_match_the_whole_token() { + foreach ( array( '"é東京😀\1f600 \0000e9 b"', "\"before\\\r\nafter\\\nend\\\ftail\"", "\"\xc3x\xe2\x82\x80\xff\x00\"" ) as $input ) { + $whole = CSSProcessor::create( $input ); + $this->assertTrue( $whole->next_token() ); + $expected = $whole->get_token_value(); + $processor = CSSProcessor::create_for_streaming(); + $decoded = ''; + for ( $offset = 0; $offset <= strlen( $input ); ++$offset ) { + $processor->append_bytes( substr( $input, $offset, 1 ), strlen( $input ) === $offset ); + while ( $processor->next_token() ) { + $decoded .= $processor->get_token_value(); + } + $processor->flush_processed_css(); + $processor = CSSProcessor::create_for_streaming( $processor->get_reentrancy_cursor() ); + } + $this->assertSame( $expected, $decoded ); + } + } + + /** The existing whole-token setter must survive flushing and a fresh processor. */ + public function test_buffered_tokens_use_the_existing_value_setter() { + $processor = CSSProcessor::create_for_streaming(); + $output = ''; + foreach ( array( 'a{src:url("https://old.exa', 'mple/a");color:red} ' ) as $input ) { + $processor->append_bytes( $input ); + while ( $processor->next_token() ) { + if ( 'https://old.example/a' === $processor->get_token_value() ) { + $this->assertTrue( $processor->set_token_value( 'https://new.example/moved/a' ) ); + } + } + $output .= $processor->flush_processed_css(); + $processor = CSSProcessor::create_for_streaming( $processor->get_reentrancy_cursor() ); + } + $processor->append_bytes( '', true ); + while ( $processor->next_token() ) {} + $output .= $processor->flush_processed_css(); + $this->assertSame( 'a{src:url("https://new.example/moved/a");color:red} ', $output ); + } + +} diff --git a/components/DataLiberation/Tests/fixtures/css-token-stream/rewrite-file.php b/components/DataLiberation/Tests/fixtures/css-token-stream/rewrite-file.php new file mode 100644 index 000000000..124abdb0e --- /dev/null +++ b/components/DataLiberation/Tests/fixtures/css-token-stream/rewrite-file.php @@ -0,0 +1,47 @@ + 0, 'output_bytes' => 0, 'css' => null ); +$input = fopen( $input_path, 'rb' ); +$output = fopen( $output_path, 'c+b' ); +fseek( $input, $state['source_bytes'] ); +// Bytes after the last saved boundary were written by an interrupted process. +// Discard them before replaying the corresponding source chunk. +ftruncate( $output, $state['output_bytes'] ); +fseek( $output, $state['output_bytes'] ); +$processor = CSSProcessor::create_for_streaming( $state['css'] ); +$chunks = 0; +while ( ! feof( $input ) ) { + $chunk = fread( $input, 32768 ); + $processor->append_bytes( $chunk, feof( $input ) ); + while ( $processor->next_token() ) { + if ( in_array( $processor->get_token_type(), array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_STRING ), true ) ) { + $processor->set_token_value( str_replace( 'https://old.example/', 'https://old.example/moved/', $processor->get_token_value() ) ); + } + } + $rewritten = $processor->flush_processed_css(); + $written = fwrite( $output, $rewritten ); + if ( strlen( $rewritten ) !== $written ) { + throw new RuntimeException( 'CSS output wrote ' . $written . ' of ' . strlen( $rewritten ) . ' bytes.' ); + } + fflush( $output ); + ++$chunks; + if ( 'before' === $stop && 2 === $chunks ) { + exit( 99 ); + } + $state = array( 'source_bytes' => ftell( $input ), 'output_bytes' => ftell( $output ), 'css' => $processor->get_reentrancy_cursor() ); + file_put_contents( $state_path . '.tmp', json_encode( $state ) ); + rename( $state_path . '.tmp', $state_path ); + if ( 'after' === $stop && 2 === $chunks ) { + exit( 99 ); + } +} +fclose( $input ); +fclose( $output ); From 2c5845bf1f61786ad0e5a2d335ed2b7a25a34d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 10:46:16 +0200 Subject: [PATCH 04/12] Describe URL context before streamed rewriting is added --- components/DataLiberation/URL/class-cssurlprocessor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DataLiberation/URL/class-cssurlprocessor.php b/components/DataLiberation/URL/class-cssurlprocessor.php index b62f8be4b..d95a4f84b 100644 --- a/components/DataLiberation/URL/class-cssurlprocessor.php +++ b/components/DataLiberation/URL/class-cssurlprocessor.php @@ -12,14 +12,14 @@ class CSSURLProcessor { * @var CSSProcessor */ private $processor; - /** @var array URL syntax context shared by whole-string and streaming callers. */ + + /** @var array URL syntax context used by the whole-string iterator. */ private $context = array( 'depth' => 0, 'images' => array(), 'expect' => '', ); - /** * @param string $css CSS source without wrapping braces. */ 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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. From 3ab187d1570a19caa75d20abba6b4074ccfc3078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 20:40:25 +0200 Subject: [PATCH 09/12] Explain the native scan of ordinary CSS URL bytes --- components/DataLiberation/CSS/class-cssprocessor.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index dd680287e..d41b4ebf1 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -1586,6 +1586,12 @@ private function consume_url(): bool { // Repeatedly consume the next input code point from the stream. while ( $this->at < $this->length ) { + // Scan runs such as https://example.com/photo.png in native code rather + // than one PHP iteration per byte. These ASCII bytes need no special + // handling in an unquoted URL. Quotes, parentheses, whitespace, escapes, + // and non-ASCII bytes fall through to the rules below. + // Only the cursor advances; source bytes stay unchanged. This also makes + // reparsing a long unfinished URL cheaper when another chunk arrives. $plain = strspn( $this->css, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&*+,;=%', $this->at ); if ( $plain > 0 ) { $this->at += $plain; From fb072532497ecde44957cea6f8862f5ee2738d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 8 Sep 2026 20:49:41 +0200 Subject: [PATCH 10/12] Inline URL context tracking in next_url --- .../URL/class-cssurlprocessor.php | 83 +++++++++---------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/components/DataLiberation/URL/class-cssurlprocessor.php b/components/DataLiberation/URL/class-cssurlprocessor.php index d95a4f84b..71e38d558 100644 --- a/components/DataLiberation/URL/class-cssurlprocessor.php +++ b/components/DataLiberation/URL/class-cssurlprocessor.php @@ -30,62 +30,53 @@ public function __construct( string $css ) { /** * Moves the cursor to the next URL token, if available. * - * @return bool - */ - public function next_url(): bool { - while ( $this->processor->next_token() ) { - $type = $this->processor->get_token_type(); - $name = in_array( $type, array( CSSProcessor::TOKEN_FUNCTION, CSSProcessor::TOKEN_AT_KEYWORD ), true ) ? $this->processor->get_token_value() : ''; - $is_url = $this->inspect_url_context( $type, $name ); - if ( $is_url && in_array( $type, array( CSSProcessor::TOKEN_STRING, CSSProcessor::TOKEN_URL ), true ) ) { - return true; - } - } - return false; - } - - /** * Recognizes URL values without treating comments or displayed text as URLs. - * * The url() function expects a STRING token after whitespace. Bare @import and * image-set strings use the same lookahead; another token clears that expectation. * Direct unquoted URL tokens already include their url() wrapper. * - * @param string $type CSS token type. - * @param string $name Decoded function or at-keyword name, otherwise empty. - * @return bool Whether this begins a URL value. + * @return bool */ - private function inspect_url_context( string $type, string $name ): bool { - if ( in_array( $type, array( CSSProcessor::TOKEN_WHITESPACE, CSSProcessor::TOKEN_COMMENT ), true ) ) { - return false; - } - $expected = $this->context['expect']; - $this->context['expect'] = ''; - if ( CSSProcessor::TOKEN_FUNCTION === $type ) { - ++$this->context['depth']; - $name = strtolower( $name ); - $this->context['expect'] = 'url' === $name ? 'url' : ''; - if ( in_array( $name, array( 'image-set', '-webkit-image-set' ), true ) ) { - if ( count( $this->context['images'] ) >= 128 ) { - throw new \RuntimeException( 'CSS image-set nesting exceeds 128 open functions.' ); + public function next_url(): bool { + while ( $this->processor->next_token() ) { + $type = $this->processor->get_token_type(); + $name = in_array( $type, array( CSSProcessor::TOKEN_FUNCTION, CSSProcessor::TOKEN_AT_KEYWORD ), true ) ? $this->processor->get_token_value() : ''; + if ( in_array( $type, array( CSSProcessor::TOKEN_WHITESPACE, CSSProcessor::TOKEN_COMMENT ), true ) ) { + continue; + } + $expected = $this->context['expect']; + $this->context['expect'] = ''; + if ( CSSProcessor::TOKEN_FUNCTION === $type ) { + ++$this->context['depth']; + $name = strtolower( $name ); + $this->context['expect'] = 'url' === $name ? 'url' : ''; + if ( in_array( $name, array( 'image-set', '-webkit-image-set' ), true ) ) { + if ( count( $this->context['images'] ) >= 128 ) { + throw new \RuntimeException( 'CSS image-set nesting exceeds 128 open functions.' ); + } + $this->context['images'][] = $this->context['depth']; + $this->context['expect'] = 'image'; + } + } elseif ( CSSProcessor::TOKEN_AT_KEYWORD === $type ) { + $this->context['expect'] = 'import' === strtolower( $name ) ? 'import' : ''; + } elseif ( CSSProcessor::TOKEN_LEFT_PAREN === $type ) { + ++$this->context['depth']; + } elseif ( CSSProcessor::TOKEN_RIGHT_PAREN === $type ) { + if ( end( $this->context['images'] ) === $this->context['depth'] ) { + array_pop( $this->context['images'] ); } - $this->context['images'][] = $this->context['depth']; - $this->context['expect'] = 'image'; + $this->context['depth'] = max( 0, $this->context['depth'] - 1 ); + } elseif ( CSSProcessor::TOKEN_COMMA === $type && end( $this->context['images'] ) === $this->context['depth'] ) { + $this->context['expect'] = 'image'; } - } elseif ( CSSProcessor::TOKEN_AT_KEYWORD === $type ) { - $this->context['expect'] = 'import' === strtolower( $name ) ? 'import' : ''; - } elseif ( CSSProcessor::TOKEN_LEFT_PAREN === $type ) { - ++$this->context['depth']; - } elseif ( CSSProcessor::TOKEN_RIGHT_PAREN === $type ) { - if ( end( $this->context['images'] ) === $this->context['depth'] ) { - array_pop( $this->context['images'] ); + if ( + CSSProcessor::TOKEN_URL === $type || + ( '' !== $expected && CSSProcessor::TOKEN_STRING === $type ) + ) { + return true; } - $this->context['depth'] = max( 0, $this->context['depth'] - 1 ); - } elseif ( CSSProcessor::TOKEN_COMMA === $type && end( $this->context['images'] ) === $this->context['depth'] ) { - $this->context['expect'] = 'image'; } - return in_array( $type, array( CSSProcessor::TOKEN_URL, CSSProcessor::TOKEN_BAD_URL ), true ) || - ( '' !== $expected && in_array( $type, array( CSSProcessor::TOKEN_STRING, CSSProcessor::TOKEN_BAD_STRING ), true ) ); + return false; } /** From eb702d84871b5424240afc42f7347e28a76bd730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 9 Sep 2026 00:45:01 +0200 Subject: [PATCH 11/12] Explain image-set nesting and its stack limit --- components/DataLiberation/URL/class-cssurlprocessor.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/DataLiberation/URL/class-cssurlprocessor.php b/components/DataLiberation/URL/class-cssurlprocessor.php index 71e38d558..a5e99972f 100644 --- a/components/DataLiberation/URL/class-cssurlprocessor.php +++ b/components/DataLiberation/URL/class-cssurlprocessor.php @@ -51,6 +51,12 @@ public function next_url(): bool { $name = strtolower( $name ); $this->context['expect'] = 'url' === $name ? 'url' : ''; if ( in_array( $name, array( 'image-set', '-webkit-image-set' ), true ) ) { + // In image-set("a.png" type("image/png"), "b.png" 2x), + // type() is nested one level deeper than image-set(). Save each + // open image-set's depth so only commas at that depth start images. + // The matching ')' removes that entry. Cap the stack at 128 open + // image-set() calls, even for malformed input. This does not cap + // images within a set or separate image-set() calls in the file. if ( count( $this->context['images'] ) >= 128 ) { throw new \RuntimeException( 'CSS image-set nesting exceeds 128 open functions.' ); } From 223012fb379b893d3ec9b3ac89e572e2be2d3fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 9 Sep 2026 18:08:11 +0200 Subject: [PATCH 12/12] Explain CSS identifier and URL scan fast paths --- components/DataLiberation/CSS/class-cssprocessor.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index d41b4ebf1..3a9e55713 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -1592,6 +1592,8 @@ private function consume_url(): bool { // and non-ASCII bytes fall through to the rules below. // Only the cursor advances; source bytes stay unchanged. This also makes // reparsing a long unfinished URL cheaper when another chunk arrives. + // This set only selects the fast path; it does not validate the URL or + // reject other bytes, which still reach the CSS token rules below. $plain = strspn( $this->css, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&*+,;=%', $this->at ); if ( $plain > 0 ) { $this->at += $plain; @@ -1753,6 +1755,12 @@ private function consume_remnants_of_bad_url(): bool { */ private function consume_ident_sequence() { while ( $this->at < $this->length ) { + // Scan ASCII name characters, as in margin-top or item_2, in native + // code rather than one PHP iteration per byte. Letters, digits, "-", + // and "_" can continue a name; deciding whether an identifier may + // start here is a separate check, not the purpose of this byte list. + // Non-ASCII bytes, NULL, and escapes use the rules below. Advancing + // only the cursor preserves the source spelling for later decoding. $plain = strspn( $this->css, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_', $this->at ); if ( $plain > 0 ) { $this->at += $plain;