| 340 |
340 |
|
pub(super) fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
|
| 341 |
341 |
|
ParserOptions {
|
| 342 |
342 |
|
// Nesting is standard CSS; let creators use it and let us wrap with it.
|
|
343 |
+ |
//
|
|
344 |
+ |
// Inert at the pinned lightningcss version and kept anyway: measured
|
|
345 |
+ |
// 2026-08-26, `ParserFlags::NESTING` is defined in 1.0.0-alpha.71's
|
|
346 |
+ |
// parser.rs and read nowhere in the crate, so nesting parses with or
|
|
347 |
+ |
// without it. The pin is deliberate (see Cargo.toml) and a later alpha
|
|
348 |
+ |
// may start reading the flag again, so stating the intent costs
|
|
349 |
+ |
// nothing. It does mean no test can observe this line, and mutation
|
|
350 |
+ |
// reports it as a permanent survivor.
|
| 343 |
351 |
|
flags: ParserFlags::NESTING,
|
| 344 |
352 |
|
// One malformed rule shouldn't discard the whole sheet.
|
| 345 |
353 |
|
error_recovery: true,
|
| 1127 |
1135 |
|
"@view-transition",
|
| 1128 |
1136 |
|
);
|
| 1129 |
1137 |
|
}
|
|
1138 |
+ |
|
|
1139 |
+ |
// ---- The caps, at their exact boundaries -------------------------------
|
|
1140 |
+ |
//
|
|
1141 |
+ |
// Filed from the first mutation run of this module (infra `c1b3900b`,
|
|
1142 |
+ |
// 2026-08-26): 31 of 108 mutants survived, and six of them lived in the two
|
|
1143 |
+ |
// cap comparisons. A cap tested only far past its limit does not pin the
|
|
1144 |
+ |
// comparison, `>` and `>=` agree on 5001 rules and disagree on 5000, so the
|
|
1145 |
+ |
// boundary is where the test has to stand.
|
|
1146 |
+ |
|
|
1147 |
+ |
/// `n` single-selector rules: rule-heavy, selector-light.
|
|
1148 |
+ |
fn n_rules(n: usize) -> String {
|
|
1149 |
+ |
use std::fmt::Write;
|
|
1150 |
+ |
let mut css = String::new();
|
|
1151 |
+ |
for i in 0..n {
|
|
1152 |
+ |
let _ = write!(css, ".c{i}{{color:red}}");
|
|
1153 |
+ |
}
|
|
1154 |
+ |
css
|
|
1155 |
+ |
}
|
|
1156 |
+ |
|
|
1157 |
+ |
/// One rule carrying `n` selectors: selector-heavy, rule-light. Its
|
|
1158 |
+ |
/// flattening projection is `n` as well, since the projection takes the
|
|
1159 |
+ |
/// widest rule rather than the sum.
|
|
1160 |
+ |
fn one_rule_of(n: usize) -> String {
|
|
1161 |
+ |
let selectors = (0..n)
|
|
1162 |
+ |
.map(|i| format!(".s{i}"))
|
|
1163 |
+ |
.collect::<Vec<_>>()
|
|
1164 |
+ |
.join(",");
|
|
1165 |
+ |
format!("{selectors}{{color:red}}")
|
|
1166 |
+ |
}
|
|
1167 |
+ |
|
|
1168 |
+ |
fn refused_for_complexity(css: &str) -> bool {
|
|
1169 |
+ |
let (out, rejections) = san(css);
|
|
1170 |
+ |
out.is_empty()
|
|
1171 |
+ |
&& rejections
|
|
1172 |
+ |
.iter()
|
|
1173 |
+ |
.any(|r| r.kind == RejectionKind::ComplexityLimit)
|
|
1174 |
+ |
}
|
|
1175 |
+ |
|
|
1176 |
+ |
#[test]
|
|
1177 |
+ |
fn exactly_max_rules_is_accepted() {
|
|
1178 |
+ |
let (out, rejections) = san(&n_rules(MAX_RULES));
|
|
1179 |
+ |
assert!(
|
|
1180 |
+ |
!rejections
|
|
1181 |
+ |
.iter()
|
|
1182 |
+ |
.any(|r| r.kind == RejectionKind::ComplexityLimit),
|
|
1183 |
+ |
"the limit is inclusive: {MAX_RULES} rules are allowed"
|
|
1184 |
+ |
);
|
|
1185 |
+ |
assert!(!out.is_empty());
|
|
1186 |
+ |
}
|
|
1187 |
+ |
|
|
1188 |
+ |
#[test]
|
|
1189 |
+ |
fn one_rule_past_the_cap_is_refused() {
|
|
1190 |
+ |
// Rule-heavy and nothing else: this sheet's selector count and its
|
|
1191 |
+ |
// flattening projection both stay far inside their limits, so only the
|
|
1192 |
+ |
// rule half of the comparison can refuse it.
|
|
1193 |
+ |
assert!(refused_for_complexity(&n_rules(MAX_RULES + 1)));
|
|
1194 |
+ |
}
|
|
1195 |
+ |
|
|
1196 |
+ |
#[test]
|
|
1197 |
+ |
fn exactly_max_selectors_is_accepted() {
|
|
1198 |
+ |
let (out, rejections) = san(&one_rule_of(MAX_SELECTORS));
|
|
1199 |
+ |
assert!(
|
|
1200 |
+ |
!rejections
|
|
1201 |
+ |
.iter()
|
|
1202 |
+ |
.any(|r| r.kind == RejectionKind::ComplexityLimit),
|
|
1203 |
+ |
"the limit is inclusive: {MAX_SELECTORS} selectors are allowed, and \
|
|
1204 |
+ |
the flattening projection of one such rule is exactly the limit too"
|
|
1205 |
+ |
);
|
|
1206 |
+ |
assert!(!out.is_empty());
|
|
1207 |
+ |
}
|
|
1208 |
+ |
|
|
1209 |
+ |
#[test]
|
|
1210 |
+ |
fn the_selector_cap_is_reached_by_breadth_too() {
|
|
1211 |
+ |
// 200 rules of 51 selectors: 10,200 selectors, which is past the cap,
|
|
1212 |
+ |
// while rule_count (200) and the projection (51, the widest rule) are
|
|
1213 |
+ |
// both nowhere near theirs. This is the shape that proves
|
|
1214 |
+ |
// `selector_count` accumulates at all, since a counter that never
|
|
1215 |
+ |
// leaves zero is invisible to every other check.
|
|
1216 |
+ |
use std::fmt::Write;
|
|
1217 |
+ |
let mut css = String::new();
|
|
1218 |
+ |
for rule in 0..200 {
|
|
1219 |
+ |
let selectors = (0..51)
|
|
1220 |
+ |
.map(|s| format!(".r{rule}s{s}"))
|
|
1221 |
+ |
.collect::<Vec<_>>()
|
|
1222 |
+ |
.join(",");
|
|
1223 |
+ |
let _ = write!(css, "{selectors}{{color:red}}");
|
|
1224 |
+ |
}
|
|
1225 |
+ |
assert!(refused_for_complexity(&css));
|
|
1226 |
+ |
}
|
|
1227 |
+ |
|
|
1228 |
+ |
// ---- The flattening projection ----------------------------------------
|
|
1229 |
+ |
|
|
1230 |
+ |
/// The nested-`&` bomb from infra `bd562c12`, ~30x per level.
|
|
1231 |
+ |
fn amplifying_rule() -> String {
|
|
1232 |
+ |
let amp = "&".repeat(30);
|
|
1233 |
+ |
format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}")
|
|
1234 |
+ |
}
|
|
1235 |
+ |
|
|
1236 |
+ |
#[test]
|
|
1237 |
+ |
fn nested_amplification_is_refused_inside_at_rules_too() {
|
|
1238 |
+ |
// The projection has to descend through the grouping at-rules it allows,
|
|
1239 |
+ |
// or the bomb is one `@media print` away from being invisible again.
|
|
1240 |
+ |
let inner = amplifying_rule();
|
|
1241 |
+ |
for css in [
|
|
1242 |
+ |
format!("@media print {{ {inner} }}"),
|
|
1243 |
+ |
format!("@supports (display: grid) {{ {inner} }}"),
|
|
1244 |
+ |
format!("@layer base {{ {inner} }}"),
|
|
1245 |
+ |
] {
|
|
1246 |
+ |
assert!(
|
|
1247 |
+ |
refused_for_complexity(&css),
|
|
1248 |
+ |
"amplification survived its wrapper: {css:.60}"
|
|
1249 |
+ |
);
|
|
1250 |
+ |
}
|
|
1251 |
+ |
}
|
|
1252 |
+ |
|
|
1253 |
+ |
#[test]
|
|
1254 |
+ |
fn the_projection_walks_past_the_first_rule() {
|
|
1255 |
+ |
// The early return at the foot of the walk is an optimisation, and an
|
|
1256 |
+ |
// optimisation that fires too early is a hole: a cheap rule first, the
|
|
1257 |
+ |
// bomb second.
|
|
1258 |
+ |
let css = format!(".a {{ color: red }} {}", amplifying_rule());
|
|
1259 |
+ |
assert!(refused_for_complexity(&css));
|
|
1260 |
+ |
}
|
|
1261 |
+ |
|
|
1262 |
+ |
// ---- Parser options ----------------------------------------------------
|
|
1263 |
+ |
|
|
1264 |
+ |
#[test]
|
|
1265 |
+ |
fn nesting_survives_into_the_output() {
|
|
1266 |
+ |
// `ordinary_nesting_is_not_refused` passes even with nesting disabled,
|
|
1267 |
+ |
// because the outer declarations still print. Assert the nested rule
|
|
1268 |
+ |
// itself arrives.
|
|
1269 |
+ |
let out = scoped(".card { color: red; &:hover { color: blue } }");
|
|
1270 |
+ |
assert!(
|
|
1271 |
+ |
out.contains(":hover"),
|
|
1272 |
+ |
"the nested rule was dropped rather than parsed: {out}"
|
|
1273 |
+ |
);
|
|
1274 |
+ |
}
|
|
1275 |
+ |
|
|
1276 |
+ |
#[test]
|
|
1277 |
+ |
fn one_bad_rule_does_not_discard_the_sheet() {
|
|
1278 |
+ |
// A stray `}` is a hard parse error without error recovery, and this
|
|
1279 |
+ |
// crate's answer to a fatal parse failure is to render nothing at all.
|
|
1280 |
+ |
// Recovery is what keeps one typo from blanking a creator's page. It
|
|
1281 |
+ |
// does not save everything: recovery still discards from the stray
|
|
1282 |
+ |
// brace onward, so `h1` is gone either way and `p` is the difference.
|
|
1283 |
+ |
let (out, rejections) = san("p { color: red } } h1 { color: blue }");
|
|
1284 |
+ |
assert!(
|
|
1285 |
+ |
!rejections
|
|
1286 |
+ |
.iter()
|
|
1287 |
+ |
.any(|r| r.kind == RejectionKind::MalformedCss),
|
|
1288 |
+ |
"one stray brace discarded the whole sheet: {rejections:?}"
|
|
1289 |
+ |
);
|
|
1290 |
+ |
assert!(
|
|
1291 |
+ |
out.to_lowercase().contains("red"),
|
|
1292 |
+ |
"the whole sheet was discarded: {out}"
|
|
1293 |
+ |
);
|
|
1294 |
+ |
}
|
|
1295 |
+ |
|
|
1296 |
+ |
// ---- The item-page entry point -----------------------------------------
|
|
1297 |
+ |
|
|
1298 |
+ |
#[test]
|
|
1299 |
+ |
fn item_css_is_scoped_to_the_item_canvas() {
|
|
1300 |
+ |
// The only test that enters through `sanitize_item_css`. Without it the
|
|
1301 |
+ |
// whole function is unobserved: item pages have no HTML of their own,
|
|
1302 |
+ |
// so a wrong scope here styles nothing and nobody sees an error.
|
|
1303 |
+ |
let (out, rejections) = sanitize_item_css("p { color: red }", SCOPE, &policy());
|
|
1304 |
+ |
assert!(rejections.is_empty());
|
|
1305 |
+ |
assert!(
|
|
1306 |
+ |
out.contains(&format!(".item-canvas#ic-{SCOPE}")),
|
|
1307 |
+ |
"item CSS was not scoped to the item canvas: {out:.200}"
|
|
1308 |
+ |
);
|
|
1309 |
+ |
}
|
|
1310 |
+ |
|
|
1311 |
+ |
// ---- Reaching a system slot through the selector forms -----------------
|
|
1312 |
+ |
|
|
1313 |
+ |
#[test]
|
|
1314 |
+ |
fn hiding_a_system_slot_through_any_and_host_is_stripped() {
|
|
1315 |
+ |
// `:is`/`:where`/`:not`/`:has` have their own arm and their own test.
|
|
1316 |
+ |
// These two do not, and a selector form the walk does not recurse into
|
|
1317 |
+ |
// is a way to hide a buy button.
|
|
1318 |
+ |
for selector in [":-webkit-any(.mnw-buy)", ":host(.mnw-buy)"] {
|
|
1319 |
+ |
let (_out, rejections) = san(&format!("{selector} {{ display: none }}"));
|
|
1320 |
+ |
assert!(
|
|
1321 |
+ |
rejections
|
|
1322 |
+ |
.iter()
|
|
1323 |
+ |
.any(|r| r.kind == RejectionKind::HidingProperty),
|
|
1324 |
+ |
"{selector} reached a system slot unchecked"
|
|
1325 |
+ |
);
|
|
1326 |
+ |
}
|
|
1327 |
+ |
}
|
|
1328 |
+ |
|
|
1329 |
+ |
// ---- The hiding heuristics, on their visible side ----------------------
|
|
1330 |
+ |
|
|
1331 |
+ |
#[test]
|
|
1332 |
+ |
fn the_hiding_thresholds_keep_what_is_still_visible() {
|
|
1333 |
+ |
// Every one of these is one comparison away from being a hide, and the
|
|
1334 |
+ |
// suite only ever asserted the hiding side. A guard that also eats
|
|
1335 |
+ |
// ordinary declarations is a bug creators would hit and we would not.
|
|
1336 |
+ |
for decl in [
|
|
1337 |
+ |
// The threshold is `< 0.1`, so a tenth is still visible.
|
|
1338 |
+ |
"opacity: 0.1",
|
|
1339 |
+ |
// A transform is not a hide unless it scales to nothing.
|
|
1340 |
+ |
"transform: translateX(10px)",
|
|
1341 |
+ |
// Nor is a clip-path unless it clips everything away.
|
|
1342 |
+ |
"clip-path: inset(0)",
|
|
1343 |
+ |
// The text-indent trick is large and NEGATIVE.
|
|
1344 |
+ |
"text-indent: 5px",
|
|
1345 |
+ |
] {
|
|
1346 |
+ |
let (out, rejections) = san(&format!(".mnw-buy {{ {decl} }}"));
|
|
1347 |
+ |
assert!(
|
|
1348 |
+ |
!rejections
|
|
1349 |
+ |
.iter()
|
|
1350 |
+ |
.any(|r| r.kind == RejectionKind::HidingProperty),
|
|
1351 |
+ |
"{decl} is visible and was stripped anyway"
|
|
1352 |
+ |
);
|
|
1353 |
+ |
assert!(!out.is_empty(), "{decl} produced nothing");
|
|
1354 |
+ |
}
|
|
1355 |
+ |
}
|
|
1356 |
+ |
|
|
1357 |
+ |
// ---- The strobe guard, at its boundary ---------------------------------
|
|
1358 |
+ |
|
|
1359 |
+ |
#[test]
|
|
1360 |
+ |
fn an_infinite_animation_at_exactly_two_seconds_is_kept() {
|
|
1361 |
+ |
// The budget is "faster than 2s", so 2s itself is allowed.
|
|
1362 |
+ |
let (out, rejections) = san(".spin { animation: spin 2s infinite }");
|
|
1363 |
+ |
assert!(
|
|
1364 |
+ |
!rejections
|
|
1365 |
+ |
.iter()
|
|
1366 |
+ |
.any(|r| r.kind == RejectionKind::AnimationBudget),
|
|
1367 |
+ |
"2s is the allowed side of the boundary"
|
|
1368 |
+ |
);
|
|
1369 |
+ |
assert!(out.to_lowercase().contains("animation"));
|
|
1370 |
+ |
}
|
|
1371 |
+ |
|
|
1372 |
+ |
#[test]
|
|
1373 |
+ |
fn milliseconds_are_read_as_milliseconds() {
|
|
1374 |
+ |
// A unit test rather than a sheet, because lightningcss prints
|
|
1375 |
+ |
// `3000ms` back as `3s` and the ms branch is only reliably reached from
|
|
1376 |
+ |
// here. Getting the conversion wrong in either direction lets a 500ms
|
|
1377 |
+ |
// strobe through or eats a three-second animation.
|
|
1378 |
+ |
assert_eq!(parse_seconds("500ms"), Some(0.5));
|
|
1379 |
+ |
assert_eq!(parse_seconds("3000ms"), Some(3.0));
|
|
1380 |
+ |
assert_eq!(parse_seconds("2s"), Some(2.0));
|
|
1381 |
+ |
assert_eq!(parse_seconds("infinite"), None);
|
|
1382 |
+ |
}
|
| 1130 |
1383 |
|
}
|
| 1131 |
1384 |
|
|
| 1132 |
1385 |
|
#[cfg(test)]
|