fix(cssom): validate font feature rule and map receivers

Enable the shared native-brand receiver checks on CSSFontFeatureValuesRule attributes and CSSFontFeatureValuesMap operations. Invalid receivers now throw the callee realm TypeError before argument conversion.

Regression coverage includes genuine cross-realm objects, forged and author/revoked Proxy receivers, conversion ordering, retained rules/maps after removal, and native objects with changed JavaScript prototypes.

Validation: fmt, strict workspace clippy, full nextest. Summary [ 107.439s] 18906 tests run: 18906 passed (9 slow), 16 skipped
This commit is contained in:
ldm0
2026-09-29 07:23:34 +08:00
committed by Donough Liu
parent 3e2b9d09a4
commit 646c4a0690
5 changed files with 208 additions and 2 deletions
@@ -10,7 +10,7 @@ struct CssFontFeatureValuesMapObjectDeclaration<'s> {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::CSSFontFeatureValuesMap, enumerable)]
#[webapi(interface = web_api_interfaces::CSSFontFeatureValuesMap, enumerable, receiver)]
struct CssFontFeatureValuesMapPrototypeDeclaration {
#[webapi(accessor_property, getter = css_font_feature_values_map_size_getter_callback)]
size: (),
@@ -654,7 +654,7 @@ pub struct CssMarginRulePrototypeDeclaration {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::CSSFontFeatureValuesRule, enumerable)]
#[webapi(interface = web_api_interfaces::CSSFontFeatureValuesRule, enumerable, receiver)]
pub struct CssFontFeatureValuesRulePrototypeDeclaration {
#[webapi(
accessor_property,
@@ -0,0 +1,85 @@
(() => {
'use strict';
const equal = (actual, expected, label) => {
if (!Object.is(actual, expected)) throw new Error(`${label}: ${String(actual)} !== ${String(expected)}`);
};
const realms = [globalThis, document.getElementById('child').contentWindow];
const records = realms.map(realm => {
const sheet = new realm.CSSStyleSheet();
sheet.replaceSync('@font-feature-values Seed { @styleset { existing: 2 3; } }');
const rule = sheet.cssRules[0];
return { realm, sheet, rule, map: rule.styleset };
});
const prototypes = records.map(record => Object.getPrototypeOf(record.map));
const prototypeFor = realm => prototypes[realms.indexOf(realm)];
let conversions = 0, traps = 0, callbacks = 0;
const key = { toString() { conversions++; return 'late'; } };
const values = { get [Symbol.iterator]() { conversions++; return [7][Symbol.iterator]; } };
const handler = {
get() { traps++; throw new Error('author get trap'); },
getPrototypeOf() { traps++; throw new Error('author prototype trap'); }
};
const revoked = Proxy.revocable(records[0].map, handler); revoked.revoke();
const invalid = [null, undefined, false, 1, 'text', Symbol(), 1n, {}, new Map(), revoked.proxy];
for (const { realm, rule, map } of records) {
invalid.push(rule, prototypeFor(realm), Object.create(map),
Object.create(prototypeFor(realm)), new Proxy(map, handler));
}
for (const realm of realms) {
const p = prototypeFor(realm);
const size = Object.getOwnPropertyDescriptor(p, 'size').get;
const operations = [
['size', size, []], ['get', p.get, [key]], ['has', p.has, [key]],
['set', p.set, [key, values]], ['delete', p.delete, [key]], ['clear', p.clear, []],
['entries', p.entries, []], ['keys', p.keys, []], ['values', p.values, []],
['iterator', p[Symbol.iterator], []], ['forEach', p.forEach, [() => { callbacks++; }]]
];
equal(p[Symbol.iterator], p.entries, 'iterator alias identity');
for (const [name, fn, args] of operations) {
for (const receiver of invalid) {
let thrown = false;
try { Reflect.apply(fn, receiver, args); } catch (error) {
equal(Object.getPrototypeOf(error), realm.TypeError.prototype, `${name} callee TypeError realm`);
thrown = true;
}
equal(thrown, true, `${name} rejects incompatible receiver`);
}
}
}
equal(conversions, 0, 'receiver check precedes conversion');
equal(traps, 0, 'no Proxy traps');
equal(callbacks, 0, 'no callback on invalid receiver');
for (const { sheet, rule, map } of records) {
for (const detached of [false, true]) {
if (detached) sheet.deleteRule(0);
for (const realm of realms) {
const p = prototypeFor(realm);
p.clear.call(map);
equal(p.set.call(map, 'late', [7, 8]), undefined, 'native map set return');
equal(p.has.call(map, 'late'), true, 'borrowed native has');
equal(JSON.stringify(p.get.call(map, 'late')), '[7,8]', 'borrowed native get');
equal(Object.getOwnPropertyDescriptor(p, 'size').get.call(map), 1, 'native size');
equal(JSON.stringify(Array.from(p.entries.call(map))), '[["late",[7,8]]]', 'native entries');
equal(JSON.stringify(Array.from(p.keys.call(map))), '["late"]', 'native keys');
equal(JSON.stringify(Array.from(p.values.call(map))), '[[7,8]]', 'native values');
const thisArg = {};
let seen = 0;
p.forEach.call(map, function(value, key, receiver) {
equal(this, thisArg, 'forEach thisArg'); equal(receiver, map, 'forEach native receiver');
equal(key, 'late', 'forEach key'); equal(JSON.stringify(value), '[7,8]', 'forEach value'); seen++;
}, thisArg);
equal(seen, 1, 'forEach called once');
equal(rule.cssText.includes('late: 7 8'), true, 'map mutation updates native rule');
equal(p.delete.call(map, 'late'), true, 'native delete');
equal(p.delete.call(map, 'late'), false, 'native missing delete');
}
}
const prototype = Object.getPrototypeOf(map);
Object.setPrototypeOf(map, null);
try {
prototypeFor(globalThis).set.call(map, 'branded', 9);
equal(JSON.stringify(prototypeFor(globalThis).get.call(map, 'branded')), '[9]', 'brand survives prototype changes');
} finally { Object.setPrototypeOf(map, prototype); }
}
return true;
})()
@@ -0,0 +1,95 @@
(() => {
'use strict';
const equal = (actual, expected, label) => {
if (!Object.is(actual, expected)) throw new Error(`${label}: ${String(actual)} !== ${String(expected)}`);
};
const throwsTypeError = (run, realm, label) => {
try { run(); } catch (error) {
equal(Object.getPrototypeOf(error), realm.TypeError.prototype, `${label} error realm`);
return;
}
throw new Error(`${label}: missing TypeError`);
};
const realms = [globalThis, document.getElementById('child').contentWindow];
const properties = ['fontFamily', 'annotation', 'ornaments', 'stylistic', 'swash', 'characterVariant', 'styleset'];
const records = [];
for (const realm of realms) {
for (const connected of [false, true]) {
const text = '@font-feature-values Seed { @styleset { existing: 2 3; } } .other {}';
const style = connected ? realm.document.createElement('style') : null;
if (style) { style.textContent = text; realm.document.head.append(style); }
const sheet = style ? style.sheet : new realm.CSSStyleSheet();
if (!style) sheet.replaceSync(text);
records.push({ realm, style, sheet, rule: sheet.cssRules[0], wrong: sheet.cssRules[1] });
}
}
let conversions = 0, traps = 0;
const value = { toString() { conversions++; return 'Converted'; } };
const handler = {
get() { traps++; throw new Error('author get trap'); },
getPrototypeOf() { traps++; throw new Error('author prototype trap'); }
};
const revoked = Proxy.revocable(records[0].rule, handler); revoked.revoke();
const invalid = [null, undefined, false, 1, 'text', Symbol(), 1n, {},
new Map(), document.createElement('div'), CSSRule.prototype,
{ cssText: records[0].rule.cssText, fontFamily: 'Seed' }, revoked.proxy];
for (const { realm, rule, wrong } of records) {
invalid.push(wrong, rule.styleset, realm.CSSFontFeatureValuesRule.prototype,
Object.create(realm.CSSFontFeatureValuesRule.prototype), Object.create(rule), new Proxy(rule, handler));
}
for (const realm of realms) {
for (const property of properties) {
const get = Object.getOwnPropertyDescriptor(realm.CSSFontFeatureValuesRule.prototype, property).get;
for (const receiver of invalid) throwsTypeError(() => get.call(receiver), realm, property);
for (const { rule } of records) {
const actual = get.call(rule), expected = rule[property];
if (property === 'fontFamily') equal(actual, expected, `borrow ${property}`);
else equal(JSON.stringify(Array.from(actual)), JSON.stringify(Array.from(expected)), `borrow ${property}`);
}
}
const set = Object.getOwnPropertyDescriptor(realm.CSSFontFeatureValuesRule.prototype, 'fontFamily').set;
for (const receiver of invalid) throwsTypeError(() => set.call(receiver, value), realm, 'fontFamily setter');
}
equal(conversions, 0, 'reject before DOMString conversion');
equal(traps, 0, 'reject without Proxy traps');
for (const { rule } of records) equal(rule.fontFamily, 'Seed', 'invalid receiver did not mutate rule');
const marker = {};
for (const realm of realms) {
const set = Object.getOwnPropertyDescriptor(realm.CSSFontFeatureValuesRule.prototype, 'fontFamily').set;
for (const { rule } of records) {
let caught;
try { set.call(rule, { toString() { throw marker; } }); } catch (error) { caught = error; }
equal(caught, marker, 'genuine receiver propagates conversion exception');
equal(rule.fontFamily, 'Seed', 'failed conversion leaves native rule intact');
}
}
for (const { sheet, rule, style } of records) {
const maps = properties.slice(1).map(property => rule[property]);
const mapPrototype = Object.getPrototypeOf(maps[0]);
for (const detached of [false, true]) {
if (detached) sheet.deleteRule(0);
for (const realm of realms) {
const proto = realm.CSSFontFeatureValuesRule.prototype;
const { get, set } = Object.getOwnPropertyDescriptor(proto, 'fontFamily');
equal(set.call(rule, { toString() { conversions++; return 'Updated'; } }), undefined, 'setter return');
equal(get.call(rule), 'Updated', 'native family after borrowed setter');
properties.slice(1).forEach((property, index) => {
const read = Object.getOwnPropertyDescriptor(proto, property).get;
equal(JSON.stringify(Array.from(read.call(rule))), JSON.stringify(Array.from(maps[index])), 'retained feature values');
equal(Object.getPrototypeOf(maps[index]), mapPrototype, 'retained map prototype');
});
}
equal(rule.parentStyleSheet, detached ? null : sheet, 'rule lifecycle');
}
const prototype = Object.getPrototypeOf(rule);
Object.setPrototypeOf(rule, null);
try {
const { get, set } = Object.getOwnPropertyDescriptor(CSSFontFeatureValuesRule.prototype, 'fontFamily');
set.call(rule, 'Branded');
equal(get.call(rule), 'Branded', 'native brand survives prototype changes');
} finally { Object.setPrototypeOf(rule, prototype); }
if (style) style.remove();
}
equal(conversions, 16, 'each genuine setter converts once');
return true;
})()
@@ -1,5 +1,31 @@
use super::*;
#[test]
fn css_font_feature_rule_receivers_use_native_brands_before_conversion() {
let mut vm = new_parsed_test_vm(
"https://css-font-feature-receivers.test/",
"<!doctype html><body><iframe id=child></iframe>",
);
assert_eq!(
vm.eval(include_str!("css_font_feature_rule_receivers.js"))
.unwrap(),
"true"
);
}
#[test]
fn css_font_feature_map_receivers_preserve_cross_realm_native_operations() {
let mut vm = new_parsed_test_vm(
"https://css-font-feature-map-receivers.test/",
"<!doctype html><body><iframe id=child></iframe>",
);
assert_eq!(
vm.eval(include_str!("css_font_feature_map_receivers.js"))
.unwrap(),
"true"
);
}
#[test]
fn css_font_face_rule_style_exposes_font_descriptors() {
let mut vm = new_storage_test_vm("https://css-font-face-rule-style.test/");