Skip to content
1 change: 1 addition & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,7 @@ mod stylesheet_impl {
err: None,
selector_expansion_multiplier: 1,
selector_expansion_total: 0,
split_clone_weight_total: 0,
};

if self.rules.minify(&mut minify_ctx, false).is_err() {
Expand Down
9 changes: 9 additions & 0 deletions src/css/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ pub enum MinifyErrorKind {
/// Compiling nested rules for the configured browser targets would expand to
/// more than [`crate::css_rules::MAX_SELECTOR_EXPANSION`] selectors.
selector_expansion_limit_exceeded,
/// Splitting a rule's selectors for the configured browser targets clones
/// the rule's declarations and nested rules once per split-off selector,
/// and the cumulative weight of those clones exceeded
/// [`crate::css_rules::MAX_SELECTOR_SPLIT_CLONE_WEIGHT`].
selector_split_clone_limit_exceeded,
/// Rule minification failed without recording a more specific diagnostic on
/// `MinifyContext::err`. Defensive fallback — every failing path is expected
/// to record one before returning an error.
Expand All @@ -540,6 +545,10 @@ impl fmt::Display for MinifyErrorKind {
"Nested CSS rules expand to more than {} selectors when compiled for the configured browser targets. Reduce the nesting depth or the number of selectors per rule, or target browsers that support CSS nesting.",
crate::css_rules::MAX_SELECTOR_EXPANSION,
),
Self::selector_split_clone_limit_exceeded => write!(
f,
"Splitting nested CSS rules with selectors unsupported by the configured browser targets duplicates too much CSS. Reduce the nesting depth or the number of selectors per rule, or target browsers that support these selectors.",
),
Self::unknown => write!(f, "CSS minification failed"),
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/css/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ pub struct Printer<'a> {
pub sources: Option<&'a Vec<Box<[u8]>>>,
pub dest: &'a mut dyn Write,
pub loc: Location,
pub indent_amt: u8,
/// Two per nesting level. `u32` because valid stylesheets can nest rules
/// hundreds of levels deep (bounded only by input size), which overflows a
/// `u8` at 128 levels.
pub indent_amt: u32,
pub line: u32,
pub col: u32,
pub minify: bool,
Expand Down
295 changes: 295 additions & 0 deletions src/css/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,29 @@
supports: Vec<CssRule<R>>,
logical: Vec<CssRule<R>>,
}
if incompatible.len() > 0 {
// Each split-off selector clones this rule's declarations and entire
// nested-rule subtree (loop below). The subtree already contains the
// clones split off while minifying deeper levels, so under nesting the
// cloned payload compounds exponentially with depth.
// `charge_selector_expansion` bounds how many rules this produces but
// not their size; bound the cumulative cloned payload too, so huge
// token lists or declaration blocks can't multiply into gigabytes
// while staying under the selector-count cap.
let per_clone = clone_weight::RULE
.saturating_add(clone_weight::decl_block(&sty.declarations))
.saturating_add(clone_weight::rule_list(&sty.rules));
context.split_clone_weight_total = context
.split_clone_weight_total
.saturating_add(per_clone.saturating_mul(incompatible.len() as u64));
if context.split_clone_weight_total > MAX_SELECTOR_SPLIT_CLONE_WEIGHT {
context.err = Some(crate::error::MinifyError {
kind: crate::error::MinifyErrorKind::selector_split_clone_limit_exceeded,
loc: sty.loc,
});
return Err(MinifyErr::minify_err);
}
}
let mut incompatible_rules: SmallList<IncompatibleRuleEntry<R>, 1> =
SmallList::init_capacity(incompatible.len());
while incompatible.len() > 0 {
Expand Down Expand Up @@ -891,6 +914,259 @@
Ok(())
}

/// Weight estimate for the rule clones made by `minify_style_arm`'s
/// incompatible-selector split, in roughly byte-sized units, charged against
/// [`MAX_SELECTOR_SPLIT_CLONE_WEIGHT`].
///
/// The walk counts the structures whose count or size scales with user input:
/// rules, declarations, selector components (recursing into selector-list
/// arguments), and raw token lists (token count plus borrowed text length —
/// the text is borrowed by the clone but re-emitted per clone when printed).
/// Every variant that stores its text inline is charged that text: token
/// payloads (including dimension units and dashed idents), var()/env()/
/// function and pseudo-class names, custom-property names, and selector
/// text (classes, ids, element and attribute names, attribute values).
/// Parsed leaf values and `url()` (whose text lives in the stylesheet's
/// import records, not reachable here) are charged a flat constant: their
/// per-rule size is bounded by the input, and the number of clones is bounded
/// by [`MAX_SELECTOR_EXPANSION`]. The walk runs once per split rule, so its
/// own cost stays proportional to the weight it charges.
mod clone_weight {
use super::{CssRule, CssRuleList};
use crate as css;
use css::properties::Property;
use css::properties::custom::{TokenList, TokenOrValue};
use css::selectors::{Component, PseudoClass, PseudoElement, Selector, SelectorList};

/// Flat weight of one rule (struct + bookkeeping).
pub(super) const RULE: u64 = 64;
/// Flat weight of one declaration.
const DECL: u64 = 64;
const COMPONENT: u64 = 16;
const TOKEN: u64 = 16;

pub(super) fn rule_list<R>(rules: &CssRuleList<R>) -> u64 {
rules.v.iter().map(rule).fold(0u64, u64::saturating_add)
}

fn rule<R>(rule: &CssRule<R>) -> u64 {
let nested = match rule {
CssRule::Style(sty) => selector_list(&sty.selectors)
.saturating_add(decl_block(&sty.declarations))
.saturating_add(rule_list(&sty.rules)),
CssRule::Media(r) => rule_list(&r.rules),
CssRule::Supports(r) => rule_list(&r.rules),
CssRule::Container(r) => rule_list(&r.rules),
CssRule::LayerBlock(r) => rule_list(&r.rules),
CssRule::MozDocument(r) => rule_list(&r.rules),
CssRule::Scope(r) => rule_list(&r.rules),
CssRule::StartingStyle(r) => rule_list(&r.rules),
CssRule::Nesting(r) => selector_list(&r.style.selectors)
.saturating_add(decl_block(&r.style.declarations))
.saturating_add(rule_list(&r.style.rules)),
CssRule::Keyframes(r) => r
.keyframes
.iter()
.map(|k| decl_block(&k.declarations))
.fold(0u64, u64::saturating_add),
CssRule::Unknown(r) => {
token_list(&r.prelude).saturating_add(r.block.as_ref().map_or(0, token_list))
}
// Remaining rules can't contain nested style rules; their payload
// is bounded per rule by the input.
_ => 0,
};
RULE.saturating_add(nested)
}

pub(super) fn decl_block(decls: &css::DeclarationBlock) -> u64 {
decls
.declarations
.iter()
.chain(decls.important_declarations.iter())
.map(property)
.fold(0u64, u64::saturating_add)
}

fn property(property: &Property) -> u64 {
use css::properties::custom::CustomPropertyName;
DECL.saturating_add(match property {
Property::Custom(c) => {
let name_len = match &c.name {
CustomPropertyName::Custom(ident) => ident.v().len(),
CustomPropertyName::Unknown(ident) => ident.v().len(),
};
(name_len as u64).saturating_add(token_list(&c.value))
}
Property::Unparsed(u) => token_list(&u.value),
_ => 0,
})
}
Comment thread
robobun marked this conversation as resolved.

pub(super) fn selector_list(list: &SelectorList) -> u64 {
selector_slice(list.v.slice())
}

fn selector_slice(selectors: &[Selector]) -> u64 {
selectors
.iter()
.map(|sel| {
sel.components
.iter()
.map(component)
.fold(0u64, u64::saturating_add)
})
.fold(0u64, u64::saturating_add)
}

fn component(component: &Component) -> u64 {
use css::selectors::parser::attrs::ParsedAttrSelectorOperation;
COMPONENT.saturating_add(match component {
Component::Negation(list)
| Component::Where(list)
| Component::Is(list)
| Component::Has(list)
| Component::Any {
selectors: list, ..
} => selector_slice(list),
Component::NthOf(data) => selector_slice(&data.selectors),
Component::Slotted(sel) => selector_slice(core::slice::from_ref(sel)),
Component::Host(Some(sel)) => selector_slice(core::slice::from_ref(sel)),
Component::NonTsPseudoClass(pseudo) => pseudo_class(pseudo),
Component::PseudoElement(pseudo) => pseudo_element(pseudo),
// CSS-modules locals (`IdentOrRef::is_ref`) print a symbol-table
// name instead of inline text; they are charged the flat constant.
Component::Id(ident) | Component::Class(ident) => {
ident.as_ident().map_or(0, |i| i.v().len() as u64)
}
Component::LocalName(name) => name.name.v().len() as u64,
Component::Namespace { prefix, url } => {
(prefix.v().len() as u64).saturating_add(url.len() as u64)
}
Component::DefaultNamespace(url) => url.len() as u64,
Component::Part(idents) => idents
.iter()
.map(|i| i.v().len() as u64)
.fold(0u64, u64::saturating_add),
Component::AttributeInNoNamespaceExists { local_name, .. } => {
local_name.v().len() as u64
}
Component::AttributeInNoNamespace {
local_name, value, ..
} => (local_name.v().len() as u64).saturating_add(value.len() as u64),
Component::AttributeOther(attr) => {
(attr.local_name.v().len() as u64).saturating_add(match &attr.operation {
ParsedAttrSelectorOperation::WithValue { expected_value, .. } => {
expected_value.len() as u64
}
ParsedAttrSelectorOperation::Exists => 0,
})
}
Comment thread
robobun marked this conversation as resolved.
_ => 0,
})
}

fn pseudo_class(pseudo: &PseudoClass) -> u64 {
match pseudo {
PseudoClass::CustomFunction { name, arguments } => {
(name.len() as u64).saturating_add(token_list(arguments))
}
PseudoClass::Custom { name } => name.len() as u64,
PseudoClass::Lang { languages } => languages
.iter()
.map(|l| l.len() as u64)
.fold(0u64, u64::saturating_add),
_ => 0,
}
}

fn pseudo_element(pseudo: &PseudoElement) -> u64 {
match pseudo {
PseudoElement::CustomFunction { name, arguments } => {
(name.len() as u64).saturating_add(token_list(arguments))
}
PseudoElement::Custom { name } => name.len() as u64,
_ => 0,
}
}

fn token_list(tokens: &TokenList) -> u64 {
tokens

Check failure on line 1094 in src/css/rules/mod.rs

View check run for this annotation

Claude / Claude Code Review

clone_weight still undercounts four more borrowed-text carriers

🔴 Round 3 of the same gap family — four more variants carry input-sized borrowed text re-emitted per clone but fall through `_ => 0`: `PseudoElement::ViewTransition{Group,ImagePair,Old,New}.part_name` (`CustomIdent` text), `PseudoElement::{CueFunction,CueRegionFunction}.selector` (wrapped `Box<Selector>` never reached by `selector_slice`), `PseudoClass::{Local,Global}.selector` (same shape, CSS-modules-gated), and `UnknownAtRule.name` in `rule()`'s `Unknown` arm (prelude/block are charged but `n
Comment thread
robobun marked this conversation as resolved.
.v
.iter()
.map(|t| {
TOKEN.saturating_add(match t {
TokenOrValue::Token(token) => token_text_len(token),
TokenOrValue::Var(var) => (var.name.ident.v().len() as u64)
.saturating_add(var.fallback.as_ref().map_or(0, token_list)),
TokenOrValue::Env(env) => env_name_len(&env.name)
.saturating_add(env.fallback.as_ref().map_or(0, token_list)),
TokenOrValue::Function(f) => {
(f.name.v().len() as u64).saturating_add(token_list(&f.arguments))
}
TokenOrValue::UnresolvedColor(color) => unresolved_color(color),
TokenOrValue::DashedIdent(ident) => ident.v().len() as u64,
TokenOrValue::AnimationName(name) => animation_name_len(name),
// `Url` stores only an import-record index; see the module
// doc. Remaining variants are fixed-size parsed values.
_ => 0,
})
})
.fold(0u64, u64::saturating_add)
}

fn env_name_len(name: &css::properties::custom::EnvironmentVariableName) -> u64 {
use css::properties::custom::EnvironmentVariableName;
match name {
EnvironmentVariableName::Ua(_) => 0,
EnvironmentVariableName::Custom(reference) => reference.ident.v().len() as u64,
EnvironmentVariableName::Unknown(ident) => ident.v().len() as u64,
}
}

fn animation_name_len(name: &css::properties::animation::AnimationName) -> u64 {
use css::properties::animation::AnimationName;
match name {
AnimationName::None => 0,
AnimationName::Ident(ident) => ident.v().len() as u64,
AnimationName::String(s) => s.len() as u64,
}
}

fn unresolved_color(color: &css::properties::custom::UnresolvedColor) -> u64 {
use css::properties::custom::UnresolvedColor;
match color {
UnresolvedColor::RGB { alpha, .. } | UnresolvedColor::HSL { alpha, .. } => {
token_list(alpha)
}
UnresolvedColor::LightDark { light, dark } => {
token_list(light).saturating_add(token_list(dark))
}
}
}

fn token_text_len(token: &css::Token) -> u64 {
use css::Token;
match token {
Token::Ident(v)
| Token::Function(v)
| Token::AtKeyword(v)
| Token::UnrestrictedHash(v)
| Token::IdHash(v)
| Token::QuotedString(v)
| Token::BadString(v)
| Token::UnquotedUrl(v)
| Token::BadUrl(v)
| Token::Whitespace(v)
| Token::Comment(v) => v.len() as u64,
// The unit of an unknown dimension is arbitrary-length ident text
// (e.g. `1aaaa...`), kept as a raw token.
Token::Dimension(d) => d.unit.len() as u64,
_ => 0,
}
}
Comment thread
robobun marked this conversation as resolved.
}

// ─── StyleRuleKey ──────────────────────────────────────────────────────────
/// A key to a `StyleRule` meant for use in a hash map for quickly detecting
/// duplicates. It stores an index into the live `rules` Vec plus a
Expand Down Expand Up @@ -1069,6 +1345,21 @@
/// instead.
pub const MAX_SELECTOR_EXPANSION: u32 = 65_536;

/// Upper bound on the cumulative weight (roughly bytes of AST payload) of
/// style-rule clones produced by splitting selector lists that are
/// incompatible with the configured targets.
///
/// `minify_style_arm` clones a rule's declarations and its entire nested-rule
/// subtree once per split-off selector. Under CSS nesting the subtree at each
/// level already contains the clones split off at deeper levels, so the cloned
/// payload compounds exponentially with nesting depth. [`MAX_SELECTOR_EXPANSION`]
/// bounds how many rules the split can produce but not their size: each clone
/// can carry arbitrarily large token lists or declaration blocks, so a few
/// hundred kilobytes of adversarial input could otherwise clone (and later
/// print) gigabytes while staying under the selector-count cap. Real-world
/// stylesheets duplicate at most a few kilobytes here.
pub const MAX_SELECTOR_SPLIT_CLONE_WEIGHT: u64 = 64 << 20;

/// Per-stylesheet minification state threaded through `CssRuleList::minify`
/// and every leaf rule's `minify`.
///
Expand Down Expand Up @@ -1104,4 +1395,8 @@
/// Running total of selectors that compiling nested rules for the targets
/// will expand to, checked against [`MAX_SELECTOR_EXPANSION`].
pub selector_expansion_total: u32,
/// Running total of the weight of rule clones made when splitting
/// target-incompatible selector lists, checked against
/// [`MAX_SELECTOR_SPLIT_CLONE_WEIGHT`].
pub split_clone_weight_total: u64,
}
Loading
Loading