Skip to main content

max / makenotwork

27.8 KB · 687 lines History Blame Raw
1 //! CSS sanitization for custom pages, built on [`lightningcss`].
2 //!
3 //! The job is to take creator CSS and make it safe to inline on a public page
4 //! without it escaping the user canvas or reaching off-platform. The pipeline:
5 //!
6 //! 1. **Parse** the creator's CSS to an AST (nesting enabled, error-recovery on
7 //! so one bad rule doesn't discard the sheet).
8 //! 2. **Visit** it once to: drop at-rules outside the allowlist, validate every
9 //! `url()` (off-platform URLs are neutralized), strip system-slot hiding
10 //! properties on `.mnw-*` selectors, enforce the strobe budget, flag
11 //! `expression()`, and count rules/selectors against the DoS caps.
12 //! 3. **Scope** by partitioning rules into ones that select elements (style,
13 //! `@media`, `@supports`, `@layer` blocks) and ones that are global by nature
14 //! (`@keyframes`, `@font-face`, `@page`, `@layer` statements). The former are
15 //! re-emitted nested inside `.user-canvas#uc-{owner}` and flattened by
16 //! lightningcss, so scoping is done by the engine's own spec-compliant
17 //! nesting resolver rather than fragile string surgery. Selectors that try to
18 //! escape (`html`, `body`, `:root`, `*`) become `.user-canvas html` etc. and
19 //! match nothing outside the canvas.
20 //! 4. **Append** a reduced-motion override as the final rule.
21 //!
22 //! The parse -> print -> wrap -> reparse round-trip is also what makes brace
23 //! injection impossible: a stray `}` in creator input is a parse error, never a
24 //! literal that could close the wrapper early.
25
26 use std::convert::Infallible;
27
28 use lightningcss::declaration::DeclarationBlock;
29 use lightningcss::properties::Property;
30 use lightningcss::properties::custom::Function;
31 use lightningcss::rules::{CssRule, CssRuleList};
32 use lightningcss::selector::{Component, Selector, SelectorList};
33 use lightningcss::stylesheet::{ParserFlags, ParserOptions, PrinterOptions, StyleSheet};
34 use lightningcss::targets::{Features, Targets};
35 use lightningcss::values::url::Url;
36 use lightningcss::visit_types;
37 use lightningcss::visitor::{Visit, VisitTypes, Visitor};
38
39 use super::url_filter::{UrlPolicy, resolve_internal_url};
40 use super::{MAX_RULES, MAX_SELECTORS, Rejection, RejectionKind};
41
42 /// Sanitize creator CSS for a profile or project page, scoping it to
43 /// `.user-canvas#uc-{scope_id}`. See [`scope_and_sanitize`].
44 pub fn sanitize_css(input: &str, scope_id: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) {
45 scope_and_sanitize(input, "user-canvas", "uc", scope_id, policy)
46 }
47
48 /// Sanitize a project's CSS for one of its item pages, scoping it to
49 /// `.item-canvas#ic-{project_id}`. Item pages have no HTML of their own; they
50 /// wear the parent project's styling re-scoped to the item canvas root.
51 pub fn sanitize_item_css(
52 input: &str,
53 project_id: &str,
54 policy: &UrlPolicy,
55 ) -> (String, Vec<Rejection>) {
56 scope_and_sanitize(input, "item-canvas", "ic", project_id, policy)
57 }
58
59 /// Sanitize creator CSS, scoping it to `.{canvas_class}#{id_prefix}-{scope_id}`.
60 ///
61 /// `scope_id` must be an id-safe token (the owner/project UUID); anything else
62 /// is refused outright. `canvas_class`/`id_prefix` are internal constants.
63 /// Returns the sanitized, scoped stylesheet plus every reference stripped along
64 /// the way. On a fatal parse failure or a complexity-cap breach, returns empty
65 /// CSS and a single explanatory rejection, a page that can't be made safe
66 /// renders as the platform default, never partially.
67 fn scope_and_sanitize(
68 input: &str,
69 canvas_class: &str,
70 id_prefix: &str,
71 scope_id: &str,
72 policy: &UrlPolicy,
73 ) -> (String, Vec<Rejection>) {
74 if input.trim().is_empty() {
75 return (String::new(), Vec::new());
76 }
77
78 if !is_id_safe(scope_id) {
79 return (
80 String::new(),
81 vec![Rejection {
82 kind: RejectionKind::MalformedCss,
83 location: "css".into(),
84 original_value: scope_id.to_string(),
85 reason: "internal: unsafe owner scope".into(),
86 }],
87 );
88 }
89
90 let Ok(mut stylesheet) = StyleSheet::parse(input, parser_options()) else {
91 tracing::warn!(
92 input_len = input.len(),
93 "custom-page CSS rejected: unparseable"
94 );
95 return (
96 String::new(),
97 vec![Rejection {
98 kind: RejectionKind::MalformedCss,
99 location: "css".into(),
100 original_value: String::new(),
101 reason: "CSS could not be parsed".into(),
102 }],
103 );
104 };
105
106 let mut sanitizer = CssSanitizer {
107 policy,
108 rejections: Vec::new(),
109 rule_count: 0,
110 selector_count: 0,
111 };
112 // Our visitor never returns Err.
113 let _: Result<(), Infallible> = stylesheet.visit(&mut sanitizer);
114
115 if sanitizer.rule_count > MAX_RULES || sanitizer.selector_count > MAX_SELECTORS {
116 tracing::warn!(
117 rule_count = sanitizer.rule_count,
118 selector_count = sanitizer.selector_count,
119 "custom-page CSS rejected: exceeds complexity limits (MAX_RULES={MAX_RULES}, MAX_SELECTORS={MAX_SELECTORS})"
120 );
121 return (
122 String::new(),
123 vec![Rejection {
124 kind: RejectionKind::ComplexityLimit,
125 location: "css".into(),
126 original_value: format!(
127 "{} rules, {} selectors",
128 sanitizer.rule_count, sanitizer.selector_count
129 ),
130 reason: format!(
131 "stylesheet too complex (limit {MAX_RULES} rules, {MAX_SELECTORS} selectors)"
132 ),
133 }],
134 );
135 }
136
137 // THE CAPS ABOVE COUNT THE PARSED TREE; FLATTENING IS MULTIPLICATIVE.
138 //
139 // `rule_count` and `selector_count` are accumulated additively over the AST
140 // as parsed, and they are checked before the scoping wrapper is flattened.
141 // Nested selectors do not add, they multiply: a rule with N selectors
142 // nested inside one with M produces N*M flattened selectors, and the caps
143 // never see it. The css soak target found the consequence in its first
144 // half-hour (infra `bd562c12`) as a 2.1 GB allocation from a 167-byte
145 // stylesheet -- nested `&` selectors, roughly 30x per level:
146 //
147 // depth 1: 43 bytes in -> 723 bytes out
148 // depth 2: 77 bytes in -> 17,253 bytes out
149 // depth 3: 111 bytes in -> 513,153 bytes out
150 // depth 4: 145 bytes in -> 15,390,153 bytes out
151 //
152 // and zero rejections at every depth. Sanitization is render-time, so that
153 // is not a slow save: it is every visitor to that page allocating
154 // gigabytes, which is a denial of service any creator could have written by
155 // accident.
156 //
157 // The projection below multiplies down the nesting tree and refuses the
158 // sheet before anything is flattened. It reuses MAX_SELECTORS and
159 // `ComplexityLimit` rather than inventing a limit: "too complex, render the
160 // platform default" is already this crate's designed answer, and it was
161 // only ever measuring the wrong number.
162 let projected = projected_expansion(&stylesheet.rules, 1);
163 if projected > MAX_SELECTORS as u64 {
164 tracing::warn!(
165 projected,
166 "custom-page CSS rejected: nested selectors project to {projected} flattened selectors (limit {MAX_SELECTORS})"
167 );
168 return (
169 String::new(),
170 vec![Rejection {
171 kind: RejectionKind::ComplexityLimit,
172 location: "css".into(),
173 original_value: format!("{projected} flattened selectors"),
174 reason: format!(
175 "nested selectors expand to more than {MAX_SELECTORS} rules once flattened"
176 ),
177 }],
178 );
179 }
180
181 let mut rejections = sanitizer.rejections;
182
183 // Partition surviving rules: element-selecting rules get scoped; rules that
184 // are global by nature stay top-level (they have no document selectors, and
185 // they cannot legally nest inside a style rule anyway).
186 let rules = std::mem::take(&mut stylesheet.rules.0);
187 let mut global = Vec::new();
188 let mut scopable = Vec::new();
189 for rule in rules {
190 match rule {
191 CssRule::Ignored => {}
192 CssRule::Keyframes(_)
193 | CssRule::FontFace(_)
194 | CssRule::Page(_)
195 | CssRule::LayerStatement(_) => global.push(rule),
196 _ => scopable.push(rule),
197 }
198 }
199
200 let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}");
201
202 let global_css = print_rules(global);
203 let scopable_css = print_rules(scopable);
204
205 // Wrap the element-selecting rules in the canvas selector and let
206 // lightningcss flatten the nesting (scoping done by the engine).
207 let flat_scoped = if scopable_css.trim().is_empty() {
208 String::new()
209 } else {
210 let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}");
211 match StyleSheet::parse(&wrapped, parser_options()) {
212 Ok(sheet) => sheet
213 .to_css(PrinterOptions {
214 targets: Targets {
215 browsers: None,
216 include: Features::Nesting,
217 exclude: Features::empty(),
218 },
219 ..Default::default()
220 })
221 .map(|r| r.code)
222 .unwrap_or_default(),
223 Err(_) => {
224 // Should not happen on already-sanitized input; fail safe.
225 rejections.push(Rejection {
226 kind: RejectionKind::MalformedCss,
227 location: "css".into(),
228 original_value: String::new(),
229 reason: "internal: re-scope failed".into(),
230 });
231 String::new()
232 }
233 }
234 };
235
236 // NOT VERIFIED AGAINST THE PRINTER, and that is a known gap rather than an
237 // oversight. See infra `bd562c12`: the css soak target found that
238 // lightningcss's nesting flattener can print a selector whose text no
239 // longer parses back to the scoped AST -- `-- &x` prints as `#uc-abcx`, the
240 // trailing type selector merging into the canvas id.
241 //
242 // A guard that reparsed this function's own printed output and dropped
243 // unscoped rules was written, and then REMOVED, because it was worse than
244 // what it fixed. Every mis-flattened shape found NARROWS the match (a
245 // longer identifier matches fewer elements), so the rules it dropped were
246 // inert. The reparse, meanwhile, ran over the FLATTENED sheet, which is far
247 // larger and more deeply nested than the input: 214 bytes of creator CSS
248 // flattens to 219 KB, and parsing that overflowed a 2 MB stack -- tokio's
249 // default worker size. A Rust stack overflow is not a catchable panic, so
250 // that traded an inert rule for an abort of the whole server process,
251 // reachable by any visitor to the page.
252 //
253 // Fixing it safely needs a check that does not reparse the output, and
254 // choosing one is a design decision rather than a mechanical fix.
255
256 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
257 let reduced_motion = format!(
258 "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}"
259 );
260
261 let mut out = String::new();
262 if !global_css.trim().is_empty() {
263 out.push_str(global_css.trim());
264 out.push('\n');
265 }
266 if !flat_scoped.trim().is_empty() {
267 out.push_str(flat_scoped.trim());
268 out.push('\n');
269 }
270 out.push_str(&reduced_motion);
271
272 (escape_lt_for_style_element(&out), rejections)
273 }
274
275 /// How far this rule tree expands when nesting is flattened.
276 ///
277 /// Not a selector count: a compound like `&&&&` is ONE selector whose four
278 /// nesting components each expand to the parent's flattened text, so the
279 /// growth is textual and multiplies down the tree. Counting selector-list
280 /// length instead measures the wrong axis entirely and waves the input
281 /// through, which is what a first attempt at this did.
282 ///
283 /// `factor` is what the enclosing rules already multiply by. Saturating
284 /// throughout: the answer only has to be "past the cap", and an overflow would
285 /// wrap to a small number and admit exactly the input this refuses.
286 fn projected_expansion(rules: &CssRuleList<'_>, factor: u64) -> u64 {
287 let mut worst = factor;
288 for rule in &rules.0 {
289 let (own, nested) = match rule {
290 CssRule::Style(style) => {
291 // Each nesting reference reproduces the parent selector; the
292 // selector list multiplies on top of that.
293 let refs: u64 = style
294 .selectors
295 .0
296 .iter()
297 .map(|s| {
298 s.iter_raw_match_order()
299 .filter(|c| matches!(c, Component::Nesting))
300 .count() as u64
301 })
302 .sum();
303 let width = refs.max(style.selectors.0.len() as u64).max(1);
304 (factor.saturating_mul(width), Some(&style.rules))
305 }
306 CssRule::Media(r) => (factor, Some(&r.rules)),
307 CssRule::Supports(r) => (factor, Some(&r.rules)),
308 CssRule::LayerBlock(r) => (factor, Some(&r.rules)),
309 _ => (factor, None),
310 };
311 worst = worst.max(own);
312 if let Some(inner) = nested {
313 worst = worst.max(projected_expansion(inner, own));
314 }
315 if worst > u64::from(u32::MAX) {
316 return worst; // Already past any cap; stop walking.
317 }
318 }
319 worst
320 }
321
322 /// Make the sheet safe to inline raw inside an HTML `<style>` element.
323 ///
324 /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
325 /// `</style` regardless of CSS syntax, so a creator's `content: "</style>..."`
326 /// would otherwise break out and inject markup. lightningcss faithfully
327 /// preserves the literal `<` inside CSS string tokens, so we escape every `<`
328 /// in the final output to its CSS hex escape `\3c ` (the trailing space
329 /// terminates the hex digits). `<` is not valid CSS syntax outside string/url
330 /// tokens, so this rewrite is lossless where it matters and never produces a
331 /// literal `<` for the HTML parser to act on. `</style>`, `<!--`, and `<script`
332 /// all require a `<`, so neutralizing it closes the whole class.
333 fn escape_lt_for_style_element(css: &str) -> String {
334 if !css.contains('<') {
335 return css.to_string();
336 }
337 css.replace('<', "\\3c ")
338 }
339
340 pub(super) fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
341 ParserOptions {
342 // Nesting is standard CSS; let creators use it and let us wrap with it.
343 //
344 // Inert at the pinned lightningcss version, which defines
345 // `ParserFlags::NESTING` and reads it nowhere, so nesting parses with
346 // or without it. Kept because a later version may start reading the
347 // flag. No test can observe this line, and mutation reports it as a
348 // permanent survivor.
349 flags: ParserFlags::NESTING,
350 // One malformed rule shouldn't discard the whole sheet.
351 error_recovery: true,
352 ..Default::default()
353 }
354 }
355
356 /// Print a set of rules to CSS (non-minified, no nesting transform).
357 fn print_rules(rules: Vec<CssRule<'_>>) -> String {
358 if rules.is_empty() {
359 return String::new();
360 }
361 let sheet = StyleSheet::new(Vec::new(), CssRuleList(rules), ParserOptions::default());
362 sheet
363 .to_css(PrinterOptions::default())
364 .map(|r| r.code)
365 .unwrap_or_default()
366 }
367
368 /// An id token safe to embed in a CSS id selector: ASCII alphanumerics and `-`.
369 fn is_id_safe(s: &str) -> bool {
370 !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
371 }
372
373 /// The single-pass AST sanitizer.
374 struct CssSanitizer<'p> {
375 policy: &'p UrlPolicy,
376 rejections: Vec<Rejection>,
377 rule_count: usize,
378 selector_count: usize,
379 }
380
381 impl<'i> Visitor<'i> for CssSanitizer<'_> {
382 type Error = Infallible;
383
384 fn visit_types(&self) -> VisitTypes {
385 visit_types!(RULES | URLS | FUNCTIONS)
386 }
387
388 fn visit_rule(&mut self, rule: &mut CssRule<'i>) -> Result<(), Self::Error> {
389 self.rule_count += 1;
390
391 // At-rule allowlist. Anything not explicitly allowed is replaced with
392 // CssRule::Ignored (prints nothing) and recorded. Allowed at-rules fall
393 // through to the recursion below so their contents are still cleaned.
394 let blocked_name: Option<&str> = match rule {
395 CssRule::Import(_) => Some("@import"),
396 CssRule::Namespace(_) => Some("@namespace"),
397 CssRule::MozDocument(_) => Some("@-moz-document"),
398 CssRule::CustomMedia(_) => Some("@custom-media"),
399 CssRule::Property(_) => Some("@property"),
400 CssRule::Viewport(_) => Some("@viewport"),
401 CssRule::CounterStyle(_) => Some("@counter-style"),
402 CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
403 CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
404 CssRule::Container(_) => Some("@container"),
405 CssRule::Scope(_) => Some("@scope"),
406 CssRule::StartingStyle(_) => Some("@starting-style"),
407 CssRule::ViewTransition(_) => Some("@view-transition"),
408 CssRule::Unknown(_) => Some("unknown at-rule"),
409 // Explicitly allowed: plain style rules and the safe grouping/at-rules.
410 // Enumerated with NO wildcard arm (UX-S2, Run 7) so a future
411 // lightningcss upgrade that adds a `CssRule` variant fails to COMPILE
412 // here until it is triaged into allow-or-block, instead of the old
413 // `_ => None` silently emitting an unknown at-rule unfiltered into the
414 // `<style>` block.
415 CssRule::Media(_)
416 | CssRule::Style(_)
417 | CssRule::Keyframes(_)
418 | CssRule::FontFace(_)
419 | CssRule::Page(_)
420 | CssRule::Supports(_)
421 | CssRule::Nesting(_)
422 | CssRule::NestedDeclarations(_)
423 | CssRule::LayerStatement(_)
424 | CssRule::LayerBlock(_)
425 | CssRule::Ignored
426 | CssRule::Custom(_) => None,
427 };
428
429 if let Some(name) = blocked_name {
430 self.rejections.push(Rejection {
431 kind: RejectionKind::BlockedAtRule,
432 location: name.to_string(),
433 original_value: name.to_string(),
434 reason: format!("{name} is not allowed in custom pages"),
435 });
436 *rule = CssRule::Ignored;
437 return Ok(());
438 }
439
440 // Style-rule-specific cleanups, with selector context in hand.
441 if let CssRule::Style(style) = rule {
442 self.selector_count += style.selectors.0.len();
443 if selectors_target_system_slot(&style.selectors) {
444 strip_hiding_properties(&mut style.declarations, &mut self.rejections);
445 }
446 enforce_animation_budget(&mut style.declarations, &mut self.rejections);
447 }
448
449 // Recurse into declarations (url()/expression()) and nested rules.
450 rule.visit_children(self)
451 }
452
453 fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> {
454 if let Err(rejection) = resolve_internal_url(&url.url, self.policy, "css url()") {
455 self.rejections.push(rejection);
456 // Neutralize: an empty url() resolves to the current document
457 // (same-origin), never the off-platform target.
458 url.url = "".into();
459 }
460 Ok(())
461 }
462
463 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
464 // expression() is dead in every browser we support, but the sanitizer's
465 // contract is "recorded as blocked => actually removed", so neutralize it
466 // rather than passing the token through: clear its arguments (dropping any
467 // url()/payload inside) and rename it so the output can't contain a
468 // working `expression(...)` (Run 21 security).
469 if function.name.as_ref().eq_ignore_ascii_case("expression") {
470 self.rejections.push(Rejection {
471 kind: RejectionKind::BlockedFunction,
472 location: "css".into(),
473 original_value: "expression()".into(),
474 reason: "the expression() function is not allowed".into(),
475 });
476 function.arguments.0.clear();
477 function.name = lightningcss::values::ident::Ident("mnw-blocked".into());
478 return Ok(());
479 }
480 function.visit_children(self)
481 }
482 }
483
484 /// True if any selector in the list targets a `.mnw-*` system-slot class
485 /// (directly or inside `:is()`/`:where()`/`:not()`/`:has()`).
486 fn selectors_target_system_slot(list: &SelectorList) -> bool {
487 list.0.iter().any(selector_has_system_class)
488 }
489
490 fn selector_has_system_class(selector: &Selector) -> bool {
491 selector
492 .iter_raw_match_order()
493 .any(component_has_system_class)
494 }
495
496 fn component_has_system_class(component: &Component) -> bool {
497 match component {
498 Component::Class(ident) => ident.0.starts_with("mnw-"),
499 Component::Is(list)
500 | Component::Where(list)
501 | Component::Negation(list)
502 | Component::Has(list) => list.iter().any(selector_has_system_class),
503 Component::Any(_, list) => list.iter().any(selector_has_system_class),
504 Component::Host(Some(inner)) => selector_has_system_class(inner),
505 _ => false,
506 }
507 }
508
509 /// Remove declarations that would hide a system slot, preserving the rest of
510 /// the rule. Records one rejection per dropped property.
511 fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
512 for list in [&mut decls.declarations, &mut decls.important_declarations] {
513 list.retain(|prop| {
514 if is_hiding_property(prop) {
515 rejections.push(Rejection {
516 kind: RejectionKind::HidingProperty,
517 location: ".mnw-* rule".into(),
518 original_value: prop_string(prop),
519 reason: "system slots (.mnw-*) cannot be hidden".into(),
520 });
521 false
522 } else {
523 true
524 }
525 });
526 }
527 }
528
529 /// Whether a property+value combination hides an element. Matched against the
530 /// serialized declaration so we don't have to enumerate every typed variant.
531 fn is_hiding_property(prop: &Property) -> bool {
532 let norm = normalize(&prop_string(prop));
533 if let Some(rest) = norm.strip_prefix("opacity:") {
534 return rest.parse::<f32>().is_ok_and(|v| v < 0.1);
535 }
536 matches!(
537 norm.as_str(),
538 "display:none"
539 | "visibility:hidden"
540 | "visibility:collapse"
541 | "pointer-events:none"
542 | "width:0"
543 | "width:0px"
544 | "height:0"
545 | "height:0px"
546 // UX-M4: 0-sized box via max-* and 0 font-size hide content too.
547 | "max-width:0"
548 | "max-width:0px"
549 | "max-height:0"
550 | "max-height:0px"
551 | "font-size:0"
552 | "font-size:0px"
553 // Legacy clip:rect(0,0,0,0) screen-reader-hide trick.
554 | "clip:rect(0,0,0,0)"
555 | "clip:rect(0px,0px,0px,0px)"
556 ) || (norm.starts_with("transform:") && norm.contains("scale(0)"))
557 // clip-path clipping the element to nothing (UX-M4).
558 || (norm.starts_with("clip-path:")
559 && (norm.contains("inset(100%") || norm.contains("circle(0")))
560 // Off-screen text via large-negative text-indent (UX-M4).
561 || is_offscreen_text_indent(&norm)
562 }
563
564 /// Large-negative `text-indent`, the classic off-screen text-hiding trick
565 /// (`text-indent:-9999px`). Anything ≤ -1000px (or unitless) counts as hiding.
566 fn is_offscreen_text_indent(norm: &str) -> bool {
567 norm.strip_prefix("text-indent:")
568 .map(|rest| rest.strip_suffix("px").unwrap_or(rest))
569 .and_then(|n| n.parse::<f32>().ok())
570 .is_some_and(|v| v <= -1000.0)
571 }
572
573 /// Drop infinite animations faster than 2s (strobe guard, decision #3). The
574 /// reduced-motion override handles accessibility; this caps the worst abuse for
575 /// everyone else.
576 fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
577 /// A finite iteration-count this high at a sub-2s duration strobes just like
578 /// `infinite` does (UX-M5), `infinite` was the only case caught before.
579 const STROBE_MAX_ITERATIONS: f32 = 20.0;
580
581 let mut has_infinite = false;
582 let mut min_duration: Option<f32> = None;
583 let mut max_iterations: Option<f32> = None;
584
585 for list in [&decls.declarations, &decls.important_declarations] {
586 for prop in list {
587 // Lowercased but whitespace-preserved: duration tokens like `1s`
588 // must stay split from neighbouring keywords.
589 let raw = prop_string(prop).to_ascii_lowercase();
590 if raw.contains("infinite") {
591 has_infinite = true;
592 }
593 if let Some(rest) = raw.strip_prefix("animation-duration:") {
594 update_min_duration(rest, &mut min_duration);
595 } else if let Some(rest) = raw.strip_prefix("animation-iteration-count:") {
596 update_max_iterations(rest, &mut max_iterations);
597 } else if let Some(rest) = raw.strip_prefix("animation:") {
598 update_min_duration(rest, &mut min_duration);
599 update_max_iterations(rest, &mut max_iterations);
600 }
601 }
602 }
603
604 let fast = min_duration.is_some_and(|d| d < 2.0);
605 let high_count = max_iterations.is_some_and(|n| n >= STROBE_MAX_ITERATIONS);
606 let strobe = (has_infinite || high_count) && fast;
607 if !strobe {
608 return;
609 }
610
611 let mut dropped = false;
612 for list in [&mut decls.declarations, &mut decls.important_declarations] {
613 list.retain(|prop| {
614 let norm = normalize(&prop_string(prop));
615 if norm.starts_with("animation") {
616 dropped = true;
617 false
618 } else {
619 true
620 }
621 });
622 }
623 if dropped {
624 rejections.push(Rejection {
625 kind: RejectionKind::AnimationBudget,
626 location: "animation".into(),
627 original_value: "infinite animation under 2s".into(),
628 reason: "fast infinite animations are not allowed (strobe guard)".into(),
629 });
630 }
631 }
632
633 fn update_min_duration(value: &str, min: &mut Option<f32>) {
634 for token in value.split([' ', ',']) {
635 if let Some(secs) = parse_seconds(token) {
636 *min = Some(min.map_or(secs, |m| m.min(secs)));
637 }
638 }
639 }
640
641 /// Track the largest finite iteration-count seen, scanning either the explicit
642 /// `animation-iteration-count` value or the `animation` shorthand (UX-M5). A bare
643 /// unitless number is the count; durations carry `s`/`ms` and percentages `%`, so
644 /// they're skipped. `infinite` is handled separately.
645 fn update_max_iterations(value: &str, max: &mut Option<f32>) {
646 for token in value.split([' ', ',']) {
647 let token = token.trim();
648 if token.is_empty() || token.ends_with('s') || token.ends_with('%') {
649 continue;
650 }
651 if let Ok(n) = token.parse::<f32>() {
652 *max = Some(max.map_or(n, |m| m.max(n)));
653 }
654 }
655 }
656
657 /// Parse a CSS time token to seconds. Returns None for non-time tokens.
658 fn parse_seconds(token: &str) -> Option<f32> {
659 let t = token.trim();
660 if let Some(ms) = t.strip_suffix("ms") {
661 ms.parse::<f32>().ok().map(|v| v / 1000.0)
662 } else if let Some(s) = t.strip_suffix('s') {
663 s.parse::<f32>().ok()
664 } else {
665 None
666 }
667 }
668
669 fn prop_string(prop: &Property) -> String {
670 prop.to_css_string(false, PrinterOptions::default())
671 .unwrap_or_default()
672 }
673
674 /// Lowercase and strip ASCII whitespace, for value matching.
675 fn normalize(s: &str) -> String {
676 s.chars()
677 .filter(|c| !c.is_whitespace())
678 .collect::<String>()
679 .to_ascii_lowercase()
680 }
681
682 #[cfg(test)]
683 mod tests;
684
685 #[cfg(test)]
686 mod proptests;
687