From d2d046d4442fc38bf60ee92df80331d8ab74c8b0 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 23 Sep 2026 03:02:18 +0800 Subject: [PATCH] fix(indexeddb): use intrinsic templates for factory creation --- .../context_bootstrap/indexed_db/runtime.rs | 18 ++++--- .../src/script_vm/tests/indexed_db.rs | 25 ++++++++++ .../src/worker/thread/tests/lazy_storage.rs | 40 ++++++++++++++++ .../tests/fixtures/indexeddb-first-use.js | 48 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/indexeddb-first-use.js diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/runtime.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/runtime.rs index f1af72387..667040a7b 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/runtime.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context_bootstrap::exposed_interfaces::build_intrinsic_interface_instance; use crate::util::{get_private_object, get_private_value, set_private_value}; use crate::web_api_interfaces; use moli_webapi_declare::WebApiObject; @@ -14,7 +15,7 @@ const INDEXED_DB_READWRITE_TRANSACTION_QUEUE_FIELD: &str = "moli.IndexedDb.runtime.readwriteTransactionQueue"; #[derive(Default, WebApiObject)] -#[webapi(interface = web_api_interfaces::IDBFactory, require_prototype)] +#[webapi(interface = web_api_interfaces::IDBFactory)] struct IndexedDbFactoryRuntimeDeclaration { #[webapi(slot = INDEXED_DB_EVENT_LISTENERS_SLOT, init = "null_object")] event_listeners: (), @@ -306,15 +307,12 @@ pub(in crate::context_bootstrap::indexed_db) fn indexed_db_factory_storage_scope fn build_indexed_db_factory_object<'s>( scope: &mut v8::PinScope<'s, '_>, ) -> Option> { - // The factory is script-visible and must receive IDBFactory.prototype; - // only the private owner state around it is null-prototype. - let factory = IndexedDbFactoryRuntimeDeclaration { - event_listeners: (), - } - .bind(scope) - .ok()?; - let factory_proto = global_constructor_prototype(scope, "IDBFactory")?; - let _ = factory.set_prototype(scope, factory_proto.into()); + // Materialize the realm's intrinsic before constructing the factory, without + // reading an author-replaced public IDBFactory binding on first use. + let factory = build_intrinsic_interface_instance(scope, "IDBFactory").ok()?; + IndexedDbFactoryRuntimeDeclaration::default() + .initialize(scope, factory) + .ok()?; let listeners = new_null_prototype_object(scope); set_indexed_db_slot_value( scope, diff --git a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs index 1267dd503..b91e0fff3 100644 --- a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs +++ b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs @@ -1,6 +1,31 @@ use super::*; use moli_url::origin_ascii_serialization; +#[test] +fn indexed_db_first_use_ignores_public_constructor_overrides() { + let fixture = include_str!("../../../tests/fixtures/indexeddb-first-use.js"); + for mode in ["number", "function", "getter", "delete"] { + let mut vm = new_storage_page_task_executor_test_vm("https://indexeddb-first-use.test/"); + assert_eq!( + vm.lazy_constructor_materialization_count_for_test("IDBFactory") + .expect("initial IDBFactory materialization count"), + 0, + "{mode}: IDBFactory must remain lazy before the probe" + ); + assert_eq!( + vm.eval(&format!("({fixture})({mode:?})")) + .unwrap_or_else(|error| panic!("{mode}: first IndexedDB access failed: {error}")), + "ok" + ); + assert_eq!( + vm.lazy_constructor_materialization_count_for_test("IDBFactory") + .expect("final IDBFactory materialization count"), + 1, + "{mode}: first IndexedDB access must materialize the intrinsic exactly once" + ); + } +} + #[test] fn indexed_db_runtime_state_is_created_on_first_use_without_window_slots() { let mut vm = new_storage_page_task_executor_test_vm("https://indexeddb-lazy-runtime.test/"); diff --git a/moli-renderer-v8/src/worker/thread/tests/lazy_storage.rs b/moli-renderer-v8/src/worker/thread/tests/lazy_storage.rs index 916fc4440..8e083e732 100644 --- a/moli-renderer-v8/src/worker/thread/tests/lazy_storage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/lazy_storage.rs @@ -1,4 +1,44 @@ use super::*; +use crate::worker::WorkerGlobalKind; + +#[tokio::test] +async fn worker_indexed_db_first_use_ignores_public_constructor_overrides() { + ensure_v8(); + let script_url = url::Url::parse("https://indexeddb-first-use.test/worker.js").unwrap(); + let fixture = include_str!("../../../../tests/fixtures/indexeddb-first-use.js"); + for kind in [ + WorkerGlobalKind::Dedicated { + name: String::new(), + }, + WorkerGlobalKind::Shared { + name: String::new(), + storage_key: moli_storage_key::MoliStorageKey::first_party_from_url(&script_url, None), + }, + WorkerGlobalKind::Service { + registration_id: ServiceWorkerRegistrationId::from_u64_for_test(1), + version_id: ServiceWorkerVersionId::from_u64_for_test(1), + scope_url: url::Url::parse("https://indexeddb-first-use.test/").unwrap(), + }, + ] { + for mode in ["number", "function", "getter", "delete"] { + let (bootstrap_tx, mut bootstrap_rx) = tokio::sync::mpsc::unbounded_channel(); + let handle = spawn_test_worker_with_options( + WorkerSpawnOptions::new(format!("({fixture})({mode:?})"), script_url.to_string()) + .with_global_kind(kind.clone()) + .with_bootstrap_completion_sender(bootstrap_tx), + ); + let bootstrap = timeout(TIMEOUT, bootstrap_rx.recv()).await; + handle.terminate_and_join(); + bootstrap + .expect("timed out waiting for IndexedDB first-use checks") + .expect("worker bootstrap channel closed") + .result + .unwrap_or_else(|error| { + panic!("{kind:?}, {mode}: first IndexedDB access failed: {error:?}") + }); + } + } +} async fn lazy_diagnostics( handle: &WorkerHandle, diff --git a/moli-renderer-v8/tests/fixtures/indexeddb-first-use.js b/moli-renderer-v8/tests/fixtures/indexeddb-first-use.js new file mode 100644 index 000000000..f2c9dc403 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/indexeddb-first-use.js @@ -0,0 +1,48 @@ +(mode => { + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + let constructorReads = 0; + const replacement = mode === 'function' ? function Fake() {} : 123; + const poison = () => { + constructorReads++; + throw new Error('public IDBFactory getter must not run'); + }; + // Do not read IDBFactory, its descriptor, or indexedDB before the override: + // doing so could materialize the intrinsic and hide a first-use regression. + if (mode === 'getter') { + Object.defineProperty(globalThis, 'IDBFactory', { + get: poison, + configurable: false + }); + } else if (mode === 'delete') { + check(delete globalThis.IDBFactory, 'constructor deletion'); + } else { + globalThis.IDBFactory = replacement; + } + + const factory = globalThis.indexedDB; + check(typeof factory === 'object' && factory !== null, 'factory exists'); + const prototype = Object.getPrototypeOf(factory); + const intrinsic = prototype.constructor; + check(intrinsic !== replacement && intrinsic.name === 'IDBFactory', 'intrinsic constructor'); + check(prototype === intrinsic.prototype, 'intrinsic prototype'); + check(factory instanceof intrinsic, 'factory instance'); + check(Object.prototype.toString.call(factory) === '[object IDBFactory]', 'factory tag'); + check(factory.cmp(1, 2) === -1 && factory.cmp(2, 2) === 0, 'native cmp works'); + for (const name of ['open', 'deleteDatabase', 'databases']) { + check(typeof factory[name] === 'function', name + ' method'); + } + check(globalThis.indexedDB === factory, 'SameObject'); + check(constructorReads === 0, 'no public constructor reads'); + + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'IDBFactory'); + if (mode === 'getter') { + check(descriptor.get === poison && !descriptor.configurable, 'getter preserved'); + } else if (mode === 'delete') { + check(descriptor === undefined, 'deleted constructor stays absent'); + } else { + check(descriptor.value === replacement, 'replacement preserved'); + } + return 'ok'; +})