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}");
}