fix(service-worker): apply WebIDL promise operation semantics

Use declarative receiver checks and promise rejection adapters for container
registration and lookup methods. Convert options before invoking the Trusted
Types default policy and preserve the original conversion exceptions.

Correct getRegistration's optional-argument length and cover conversion
order, native receiver identity, and cross-realm Promise rejection.
This commit is contained in:
ldm0
2026-09-23 00:14:06 +08:00
parent 6caa312731
commit 0364afd264
5 changed files with 363 additions and 51 deletions
@@ -416,13 +416,34 @@ struct ServiceWorkerContainerDeclaration {
#[webapi(accessor_property = "controller", enumerable, getter = navigator_service_worker_controller_getter_callback)]
controller: (),
#[webapi(method, enumerable, callback = navigator_service_worker_register_callback, length = 1)]
#[webapi(
method,
enumerable,
callback = navigator_service_worker_register_callback,
receiver = web_api_interfaces::ServiceWorkerContainer::is_instance,
returns_promise,
length = 1
)]
register: (),
#[webapi(method, enumerable, callback = navigator_service_worker_get_registration_callback, length = 1)]
#[webapi(
method,
enumerable,
callback = navigator_service_worker_get_registration_callback,
receiver = web_api_interfaces::ServiceWorkerContainer::is_instance,
returns_promise,
length = 0
)]
get_registration: (),
#[webapi(method, enumerable, callback = navigator_service_worker_get_registrations_callback, length = 0)]
#[webapi(
method,
enumerable,
callback = navigator_service_worker_get_registrations_callback,
receiver = web_api_interfaces::ServiceWorkerContainer::is_instance,
returns_promise,
length = 0
)]
get_registrations: (),
#[webapi(method, enumerable, callback = simple_event_target_add_event_listener_callback)]
@@ -383,6 +383,30 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<'
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if args.length() == 0 {
throw_type_error(
scope,
"Failed to execute 'register' on 'ServiceWorkerContainer': 1 argument required, but only 0 present.",
);
return;
}
// Convert the union argument before the options dictionary. The Trusted
// Types sink algorithm runs only after all WebIDL argument conversion.
let script_argument = args.get(0);
let script_argument = match v8::Local::<v8::Object>::try_from(script_argument) {
Ok(object) if web_api_interfaces::TrustedScriptURL::is_instance(scope, object) => {
script_argument
}
_ => {
let Some(script) = script_argument.to_string(scope) else {
return;
};
script.into()
}
};
let Some(options) = service_worker_registration_options(scope, &args) else {
return;
};
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
let Some(resolver) = v8::PromiseResolver::new(scope) else {
return;
@@ -392,13 +416,6 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<'
rv.set(promise.into());
return;
};
if args.length() == 0 {
throw_type_error(
scope,
"Failed to execute 'register' on 'ServiceWorkerContainer': 1 argument required, but only 0 present.",
);
return;
}
let owner = service_worker_container_owner_scope(scope, args.this());
let Some(request_context) =
(unsafe { &mut *host_ptr }).service_worker_window_request_context(owner)
@@ -432,16 +449,13 @@ pub(in crate::context_bootstrap) fn navigator_service_worker_register_callback<'
};
let Some(script) = crate::context_bootstrap::trusted_script_url_string_or_throw(
scope,
args.get(0),
script_argument,
requirements,
"ServiceWorkerContainer register",
"register",
) else {
return;
};
let Some(options) = service_worker_registration_options(scope, &args) else {
return;
};
let Some(resolver) = v8::PromiseResolver::new(scope) else {
return;
};
@@ -13437,7 +13437,7 @@ async fn navigator_service_worker_shim_controls_window_fetch() {
let expected_fetch_url = format!("{base_url}/app/api/data.txt");
let expected_out_of_scope_url = format!("{base_url}/outside/data.txt");
let expected_result = format!(
r#"{{"containerType":"object","controllerIsNull":true,"readyType":"object","serviceWorkerConstructorType":"function","registrationConstructorType":"function","registerType":"function","registerName":"register","registerLength":1,"getRegistrationType":"function","getRegistrationName":"getRegistration","getRegistrationLength":1,"getRegistrationsType":"function","getRegistrationsName":"getRegistrations","getRegistrationsLength":0,"removeEventListenerType":"function","controllerChangeHandlerType":"object","controllerChangeLog":["listener:controllerchange:true","handler:controllerchange:true"],"registration":true,"registrations":"true|0","afterRegisterController":true,"controllerStable":true,"controllerMatchesActive":true,"controllerBrand":true,"registrationBrand":true,"registrationTag":"[object ServiceWorkerRegistration]","installingBrand":false,"installingScriptURL":null,"installingState":null,"waitingIsNull":true,"activeIsNull":false,"activeBrand":true,"activeScriptURL":"{expected_worker_url}","activeState":"activated","registrationScope":"{expected_scope}","readyMatchesRegister":true,"responseStatusText":"Handled by worker","responseText":"sw:/app/api/data.txt","dataResponseStatusText":"OK","dataResponseContentType":"text/plain","dataResponseText":"data-url","outOfScopeResult":"resolved:Handled by worker:sw:/outside/data.txt","firstUnregister":true,"secondUnregister":false,"controllerRetained":true,"controllerStateAfterUnregister":"activated","registrationRemoved":true,"afterUnregisterText":"sw:/app/after-unregister.txt"}}"#
r#"{{"containerType":"object","controllerIsNull":true,"readyType":"object","serviceWorkerConstructorType":"function","registrationConstructorType":"function","registerType":"function","registerName":"register","registerLength":1,"getRegistrationType":"function","getRegistrationName":"getRegistration","getRegistrationLength":0,"getRegistrationsType":"function","getRegistrationsName":"getRegistrations","getRegistrationsLength":0,"removeEventListenerType":"function","controllerChangeHandlerType":"object","controllerChangeLog":["listener:controllerchange:true","handler:controllerchange:true"],"registration":true,"registrations":"true|0","afterRegisterController":true,"controllerStable":true,"controllerMatchesActive":true,"controllerBrand":true,"registrationBrand":true,"registrationTag":"[object ServiceWorkerRegistration]","installingBrand":false,"installingScriptURL":null,"installingState":null,"waitingIsNull":true,"activeIsNull":false,"activeBrand":true,"activeScriptURL":"{expected_worker_url}","activeState":"activated","registrationScope":"{expected_scope}","readyMatchesRegister":true,"responseStatusText":"Handled by worker","responseText":"sw:/app/api/data.txt","dataResponseStatusText":"OK","dataResponseContentType":"text/plain","dataResponseText":"data-url","outOfScopeResult":"resolved:Handled by worker:sw:/outside/data.txt","firstUnregister":true,"secondUnregister":false,"controllerRetained":true,"controllerStateAfterUnregister":"activated","registrationRemoved":true,"afterUnregisterText":"sw:/app/after-unregister.txt"}}"#
);
assert_eq!(result, expected_result);
@@ -23984,14 +23984,6 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules()
() => "resolved",
error => error && error.name
);
const synchronousErrorName = callback => {
try {
callback();
return "none";
} catch (error) {
return error && error.name;
}
};
globalThis.__serviceWorkerUrlArgumentProbe = { state: "pending" };
(async () => {
const registration = await sw.register("/worker.js", { scope: "null" });
@@ -24005,17 +23997,17 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules()
const nullScope = await rejectionName(
sw.register("/resources/worker.js", { scope: null })
);
const nullType = synchronousErrorName(
() => sw.register("/worker.js", { type: null })
const nullType = await rejectionName(
sw.register("/worker.js", { type: null })
);
const nullUpdateViaCache = synchronousErrorName(
() => sw.register("/worker.js", { updateViaCache: null })
const nullUpdateViaCache = await rejectionName(
sw.register("/worker.js", { updateViaCache: null })
);
const primitiveOptions = synchronousErrorName(
() => sw.register("/worker.js", 1)
const primitiveOptions = await rejectionName(
sw.register("/worker.js", 1)
);
const symbolClient = synchronousErrorName(
() => sw.getRegistration(Symbol("client"))
const symbolClient = await rejectionName(
sw.getRegistration(Symbol("client"))
);
const unregistered = await registration.unregister();
globalThis.__serviceWorkerUrlArgumentProbe = {
@@ -24060,6 +24052,60 @@ async fn navigator_service_worker_url_arguments_follow_webidl_and_origin_rules()
.expect("service worker URL argument script server should finish");
}
#[test]
fn navigator_service_worker_argument_errors_reject_with_the_original_exception() {
let mut vm = new_storage_test_vm("https://service-worker-webidl.test/");
vm.eval(
r#"
(() => {
const sw = navigator.serviceWorker;
const marker = {sentinel: true};
globalThis.serviceWorkerArgumentFailures = [];
globalThis.serviceWorkerArgumentRejections = 0;
function check(callback, expected) {
const promise = callback();
if (!(promise instanceof Promise)) serviceWorkerArgumentFailures.push('not a Promise');
promise.then(
() => serviceWorkerArgumentFailures.push('resolved'),
error => {
serviceWorkerArgumentRejections++;
if (expected === marker ? error !== marker : !(error instanceof TypeError)) {
serviceWorkerArgumentFailures.push('wrong rejection reason');
}
}
);
}
check(() => sw.register(), TypeError);
check(() => sw.register(Symbol('script')), TypeError);
check(() => sw.register('https://[', {scope: Symbol('scope')}), TypeError);
check(() => sw.getRegistration({toString() { throw marker; }}), marker);
const steps = ['script', 'scope-get', 'scope-string', 'type-get', 'type-string', 'cache-get', 'cache-string'];
for (let index = 0; index < steps.length; index++) {
const log = [];
const step = name => { log.push(name); if (name === steps[index]) throw marker; };
check(() => sw.register(
{toString() { step('script'); return 'https://['; }},
{
get scope() { step('scope-get'); return {toString() { step('scope-string'); return './'; }}; },
get type() { step('type-get'); return {toString() { step('type-string'); return 'classic'; }}; },
get updateViaCache() { step('cache-get'); return {toString() { step('cache-string'); return 'imports'; }}; }
}
), marker);
if (JSON.stringify(log) !== JSON.stringify(steps.slice(0, index + 1))) {
serviceWorkerArgumentFailures.push('conversion order: ' + log);
}
}
})()
"#,
)
.expect("ServiceWorkerContainer conversion errors should return Promises");
assert_eq!(
vm.eval("JSON.stringify([serviceWorkerArgumentRejections, serviceWorkerArgumentFailures])")
.unwrap(),
"[11,[]]"
);
}
#[test]
fn window_event_source_constructor_exposes_connecting_instance() {
let mut vm = new_storage_test_vm("https://example.com/");
@@ -2909,27 +2909,19 @@ fn service_worker_register_gates_script_url_before_url_resolution() {
let mut vm = new_storage_test_vm("https://service-worker-register-trusted-types.test/");
vm.set_response_content_security_policies(&["require-trusted-types-for 'script'".to_owned()]);
let result = vm
.eval(
vm.eval(
r#"
(() => {
const errorName = callback => {
try {
callback();
return "none";
} catch (error) {
return error && error.name;
}
};
const errorName = promise => promise.then(() => 'resolved', error => error && error.name);
const policy = trustedTypes.createPolicy("service-worker-register", {
createHTML: value => value,
createScriptURL: value => value
});
const blockedString = errorName(() => navigator.serviceWorker.register("worker.js"));
const blockedWrongType = errorName(() =>
const blockedString = errorName(navigator.serviceWorker.register("worker.js"));
const blockedWrongType = errorName(
navigator.serviceWorker.register(policy.createHTML("worker.js"))
);
const missing = errorName(() => navigator.serviceWorker.register());
const missing = errorName(navigator.serviceWorker.register());
const trustedPromise = navigator.serviceWorker.register(
policy.createScriptURL("http://[")
@@ -2946,21 +2938,162 @@ fn service_worker_register_gates_script_url_before_url_resolution() {
const defaultPromise = navigator.serviceWorker.register("worker.potato");
defaultPromise.catch(() => {});
return JSON.stringify({
blockedString,
blockedWrongType,
missing,
trustedPromise: trustedPromise instanceof Promise,
defaultPromise: defaultPromise instanceof Promise,
defaultCalls
Promise.all([blockedString, blockedWrongType, missing]).then(([blockedString, blockedWrongType, missing]) => {
globalThis.serviceWorkerTrustedUrlResult = {
blockedString,
blockedWrongType,
missing,
trustedPromise: trustedPromise instanceof Promise,
defaultPromise: defaultPromise instanceof Promise,
defaultCalls
};
});
})()
"#,
)
.expect("ServiceWorkerContainer.register TrustedScriptURL probe should evaluate");
let result = vm
.eval("JSON.stringify(globalThis.serviceWorkerTrustedUrlResult)")
.expect("ServiceWorkerContainer.register errors should reject their Promises");
assert_eq!(
result,
r#"{"blockedString":"TypeError","blockedWrongType":"TypeError","missing":"TypeError","trustedPromise":true,"defaultPromise":true,"defaultCalls":[["worker.potato","TrustedScriptURL","ServiceWorkerContainer register"]]}"#
);
}
#[test]
fn service_worker_register_converts_options_before_the_default_policy() {
let mut vm = new_storage_test_vm("https://service-worker-register-options.test/");
vm.set_response_content_security_policies(&["require-trusted-types-for 'script'".to_owned()]);
vm.eval(
r#"
(async () => {
const sw = navigator.serviceWorker;
const marker = {sentinel: true};
const rows = [];
async function probe(label, callback, log) {
const promise = callback();
log.push('returned');
try {
await promise;
rows.push([label, 'fulfilled']);
} catch (error) {
rows.push([label, promise instanceof Promise, error === marker ? 'sentinel' : error.name, [...log]]);
}
}
const options = log => ({
get scope() { log.push('scope'); return './'; },
get type() { log.push('type'); return 'classic'; },
get updateViaCache() { log.push('cache'); return 'imports'; }
});
let log = [];
await probe('blocked-after-options', () => sw.register(
{toString() { log.push('script'); return 'worker.js'; }}, options(log)
), log);
log = [];
await probe('options-exception-before-policy', () => sw.register('worker.js', {
get scope() { log.push('scope'); throw marker; }
}), log);
log = [];
await probe('script-exception', () => sw.register(
{toString() { log.push('script'); throw marker; }}, options(log)
), log);
const policy = trustedTypes.createPolicy('registration-tests', {createScriptURL: v => v});
const trusted = policy.createScriptURL('https://[');
trusted.toString = () => { throw marker; };
log = [];
await probe('trusted-input', () => sw.register(trusted, options(log)), log);
let defaultThrows = false;
let policyLog = [];
trustedTypes.createPolicy('default', {
createScriptURL(value, type, sink) {
policyLog.push(['policy', value, type, sink]);
if (defaultThrows) throw marker;
return 'https://[';
}
});
log = []; policyLog = log;
await probe('default-after-options', () => sw.register(
{toString() { log.push('script'); return 'worker\ud800.js'; }}, options(log)
), log);
log = []; policyLog = log; defaultThrows = true;
await probe('default-exception', () => sw.register('worker.js', options(log)), log);
log = []; policyLog = log;
await probe('invalid-options-before-default', () => sw.register('worker.js', {
get type() { log.push('type'); return null; }
}), log);
return rows;
})().then(rows => { globalThis.serviceWorkerOptionsResult = rows; });
"#,
)
.expect("ServiceWorkerContainer.register argument and policy probes should evaluate");
let result = vm
.eval("JSON.stringify(globalThis.serviceWorkerOptionsResult)")
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([
[
"blocked-after-options",
true,
"TypeError",
["script", "scope", "type", "cache", "returned"]
],
[
"options-exception-before-policy",
true,
"sentinel",
["scope", "returned"]
],
["script-exception", true, "sentinel", ["script", "returned"]],
[
"trusted-input",
true,
"TypeError",
["scope", "type", "cache", "returned"]
],
[
"default-after-options",
true,
"TypeError",
[
"script",
"scope",
"type",
"cache",
[
"policy",
"worker�.js",
"TrustedScriptURL",
"ServiceWorkerContainer register"
],
"returned"
]
],
[
"default-exception",
true,
"sentinel",
[
"scope",
"type",
"cache",
[
"policy",
"worker.js",
"TrustedScriptURL",
"ServiceWorkerContainer register"
],
"returned"
]
],
[
"invalid-options-before-default",
true,
"TypeError",
["type", "returned"]
]
])
);
}
@@ -190,3 +190,101 @@ if (parentGetLoaded.call(face) !== face.loaded) realmFailures.push('cross-realm
"[2,[]]"
);
}
#[test]
fn webidl_receiver_service_worker_operations_reject_before_argument_conversion() {
let mut vm = new_storage_test_vm("https://service-worker-receiver.test/");
vm.eval(
r#"
(() => {
const sw = navigator.serviceWorker;
const register = sw.register;
const getRegistration = sw.getRegistration;
const getRegistrations = sw.getRegistrations;
globalThis.serviceWorkerReceiverFailures = [];
globalThis.serviceWorkerReceiverRejections = 0;
let conversions = 0;
const script = {toString() { conversions++; return 'https://['; }};
const options = {get scope() { conversions++; return './'; }};
function check(promise) {
if (!(promise instanceof Promise)) serviceWorkerReceiverFailures.push('not a Promise');
promise.then(
() => serviceWorkerReceiverFailures.push('resolved'),
error => {
serviceWorkerReceiverRejections++;
if (!(error instanceof TypeError)) serviceWorkerReceiverFailures.push('wrong error');
}
);
}
for (const receiver of [{}, Object.create(sw), new Proxy(sw, {}), null]) {
check(register.call(receiver, script, options));
check(getRegistration.call(receiver, script));
check(getRegistrations.call(receiver));
}
if (conversions !== 0) serviceWorkerReceiverFailures.push('converted invalid receiver arguments');
const prototype = Object.getPrototypeOf(sw);
Object.setPrototypeOf(sw, null);
check(register.call(sw, script));
Object.setPrototypeOf(sw, prototype);
if (conversions !== 1) serviceWorkerReceiverFailures.push('lost native receiver identity');
if (register.length !== 1 || getRegistration.length !== 0 || getRegistrations.length !== 0) {
serviceWorkerReceiverFailures.push('operation length');
}
})()
"#,
)
.expect("ServiceWorkerContainer receiver checks should return rejected Promises");
assert_eq!(
vm.eval("JSON.stringify([serviceWorkerReceiverRejections, serviceWorkerReceiverFailures])")
.unwrap(),
"[13,[]]"
);
}
#[test]
fn webidl_receiver_service_worker_promises_use_the_callee_realm() {
let mut vm = new_storage_test_vm("https://service-worker-receiver.test/");
vm.eval(
r#"
(() => {
const html = document.appendChild(document.createElement('html'));
html.appendChild(document.createElement('body'));
const child = document.body.appendChild(document.createElement('iframe')).contentWindow;
const parentSw = navigator.serviceWorker;
const childSw = child.navigator.serviceWorker;
const marker = {sentinel: true};
const throwingScript = {toString() { throw marker; }};
globalThis.serviceWorkerRealmFailures = [];
globalThis.serviceWorkerRealmRejections = 0;
for (const [callback, P, E, expected] of [
[() => childSw.register.call(parentSw, Symbol('script')), child.Promise, child.TypeError, null],
[() => parentSw.register.call(childSw, Symbol('script')), Promise, TypeError, null],
[() => childSw.getRegistration.call(parentSw, Symbol('client')), child.Promise, child.TypeError, null],
[() => childSw.register.call({}), child.Promise, child.TypeError, null],
[() => childSw.register.call(parentSw, throwingScript), child.Promise, child.TypeError, marker],
[() => parentSw.register.call(childSw, throwingScript), Promise, TypeError, marker]
]) {
const promise = callback();
const otherP = P === Promise ? child.Promise : Promise;
const otherE = E === TypeError ? child.TypeError : TypeError;
if (!(promise instanceof P) || promise instanceof otherP) serviceWorkerRealmFailures.push('Promise realm');
promise.then(
() => serviceWorkerRealmFailures.push('resolved'),
error => {
serviceWorkerRealmRejections++;
if (expected === marker ? error !== marker : (!(error instanceof E) || error instanceof otherE)) {
serviceWorkerRealmFailures.push('rejection identity or realm');
}
}
);
}
})()
"#,
)
.expect("ServiceWorkerContainer cross-realm operations should return Promises");
assert_eq!(
vm.eval("JSON.stringify([serviceWorkerRealmRejections, serviceWorkerRealmFailures])")
.unwrap(),
"[6,[]]"
);
}