diff --git a/crates/html/src/cascade/build.rs b/crates/html/src/cascade/build.rs index 2eccce1b5..70218d257 100644 --- a/crates/html/src/cascade/build.rs +++ b/crates/html/src/cascade/build.rs @@ -141,9 +141,14 @@ pub fn collect_static_css_text( style_texts.join("\n") } +// A quoted url cannot span a line and an unquoted one cannot hold +// whitespace, quotes, parens, braces, or semicolons (CSS syntax), so a +// stray `url(` can never pair with a `)` in a later rule. static CSS_URL_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)"']*?))\s*\)"#) - .expect("CSS_URL_RE") + Regex::new( + r#"(?i)url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\s{};]*))\s*\)"#, + ) + .expect("CSS_URL_RE") }); static URL_SCHEME_RE: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9+.-]*:").expect("URL_SCHEME_RE")); @@ -153,8 +158,26 @@ static URL_SCHEME_RE: Lazy = /// directory has its relative urls rewritten to page-relative form here. /// This is what lets the sampled-contrast path (#560) resolve the image the /// winning declaration named. Root-relative, remote, `data:`, fragment, and -/// escaped urls are left as they are. +/// escaped urls are left as they are, and comments are copied through +/// untouched, so nothing inside one can reach the rules after it. pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String { + let mut out = String::with_capacity(css.len()); + let mut rest = css; + while let Some(start) = rest.find("/*") { + out.push_str(&rewrite_code_urls(&rest[..start], sheet_dir, page_dir)); + // An unclosed comment runs to the end of the sheet, as in CSS. + let end = rest[start + 2..] + .find("*/") + .map(|i| start + 2 + i + 2) + .unwrap_or(rest.len()); + out.push_str(&rest[start..end]); + rest = &rest[end..]; + } + out.push_str(&rewrite_code_urls(rest, sheet_dir, page_dir)); + out +} + +fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String { CSS_URL_RE .replace_all(css, |caps: ®ex::Captures| { let whole = caps.get(0).map(|m| m.as_str()).unwrap_or(""); diff --git a/crates/html/tests/cascade_units.rs b/crates/html/tests/cascade_units.rs index 84098b404..4afd266ed 100644 --- a/crates/html/tests/cascade_units.rs +++ b/crates/html/tests/cascade_units.rs @@ -360,7 +360,8 @@ fn linked_sheet_urls_are_rewritten_page_relative() { ); assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}"); assert!(out.contains("url(#clip)"), "{out}"); - assert!(out.contains("url(\"css/a b.png\")"), "{out}"); + // An unquoted url with a space is not a url token in CSS; it stays put. + assert!(out.contains("URL( a b.png )"), "{out}"); // A sheet beside the page keeps every path where it was (the engine // does not even call the rewrite for that case). let same = rewrite_sheet_urls(css, "/site", "/site"); @@ -369,4 +370,23 @@ fn linked_sheet_urls_are_rewritten_page_relative() { // A sheet above the page walks back up. let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages"); assert_eq!(up, ".a { background: url(../light.png) }"); + // A stray `url(` in a comment, or an unclosed quote, never swallows the + // rules after it: comments pass through untouched and a url form ends + // where CSS says it ends. + let hazards = concat!( + "/* see url( for details */ .g { color: red }\n", + ".h { background: url(a.png) }\n", + ".i { background: url(\"oops }\n", + ".j { background: url(b.png) }\n", + "/* url(unterminated.png", + ); + let out = rewrite_sheet_urls(hazards, "/site/css", "/site"); + assert!( + out.contains("/* see url( for details */ .g { color: red }"), + "{out}" + ); + assert!(out.contains(".h { background: url(css/a.png) }"), "{out}"); + assert!(out.contains(".i { background: url(\"oops }"), "{out}"); + assert!(out.contains(".j { background: url(css/b.png) }"), "{out}"); + assert!(out.ends_with("/* url(unterminated.png"), "{out}"); }