diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index e54f88864..3abcbab13 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,111 @@ public function set_token_value( string $new_value ): bool { } } + /** + * Measures where a matched prefix ends in the exact CSS text we received. + * + * These spellings all decode to the same 19-byte prefix, "https://old.example": + * + * Actual CSS spelling Source bytes to replace + * https://old.example 19 + * https://\6f ld.example 22 + * https://\00006fld.example 25 + * + * "\6f " and "\00006f" both mean "o", but occupy four and seven source + * bytes respectively. The space in "\6f " is part of that CSS escape. + * + * 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. + * + * // 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. + * $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 ); + $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; + } 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,56 +1019,79 @@ 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 { - $escaped = ''; - $at = 0; - while ( $at < strlen( $unescaped ) ) { - $safe_len = strcspn( $unescaped, "\n\r\f\\\"", $at ); - if ( $safe_len > 0 ) { - $escaped .= substr( $unescaped, $at, $safe_len ); - $at += $safe_len; - continue; - } + private static function escape_url_value( string $unescaped, bool $quote = true ): string { + $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 + 1 && "\n" === $unescaped[ $at + 1 ] ) { - ++$at; - } - break; - case "\f": - case "\n": - ++$at; - $escaped .= '\\a '; - break; - case '\\': - ++$at; - $escaped .= '\\5C '; - break; - case '"': - ++$at; - $escaped .= '\\22 '; - break; - default: - _doing_it_wrong( __METHOD__, 'Unexpected character in URL value: ' . $unsafe_char, '1.0.0' ); - 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 ', + ); } - return '"' . $escaped . '"'; + // strtr() matches CRLF before CR and does not escape the spaces or backslashes it inserts. + $escaped = strtr( $unescaped, $escapes ); + return $quote ? '"' . $escaped . '"' : $escaped; } /** @@ -1747,8 +1871,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..48821e838 100644 --- a/components/DataLiberation/README.md +++ b/components/DataLiberation/README.md @@ -281,3 +281,20 @@ 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. + +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 new file mode 100644 index 000000000..022aa714b --- /dev/null +++ b/components/DataLiberation/Tests/CSSPrefixEditProcessTest.php @@ -0,0 +1,210 @@ +directory = sys_get_temp_dir() . '/css-prefix-' . 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 ); + } + + /** 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( $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 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' +@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 ); + } + + /** 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 ); + } + + /** 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 ); + } + + /** 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(), $quoting_style ); + $this->assertSame( $expected_decoded_url, $processor->get_raw_url(), $quoting_style ); + $this->assertFalse( $processor->next_url(), 'Escaping must not introduce another URL.' ); + } + } + + /** 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', $log_path, 'w' ), 2 => array( 'file', $log_path, 'a' ) ), $pipes, null, null, array( 'bypass_shell' => true ) ); + $this->assertIsResource( $process ); + fclose( $pipes[0] ); + $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/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 ' ), + ); + } +} 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() );