From 5ac8bf4db349386efd3ebb5d7ece447f859daa4f Mon Sep 17 00:00:00 2001 From: Taym Haddadi Date: Tue, 21 Apr 2026 15:41:58 +0200 Subject: [PATCH] Implement StorageManager API (#43976) Add the Storage Standard WebIDL for NavigatorStorage and StorageManager, wire navigator.storage on Window and Worker, and implement persisted(), persist(), and estimate(). Testing: covered by WP test. part of #39100 fixes #39101 --------- Signed-off-by: Taym Haddadi --- components/config/prefs.rs | 3 + components/script/dom/mod.rs | 1 + components/script/dom/navigator.rs | 9 + components/script/dom/permissions.rs | 25 +- components/script/dom/storagemanager.rs | 279 +++++++++++++++++ .../script/dom/workers/workernavigator.rs | 9 + components/script/task_manager.rs | 1 + components/script/task_source.rs | 3 + .../script_bindings/codegen/Bindings.conf | 8 +- .../webidls/StorageManager.webidl | 29 ++ components/shared/storage/client_storage.rs | 65 +++- components/shared/storage/lib.rs | 35 ++- components/storage/client_storage.rs | 296 +++++++++++++++--- components/storage/tests/client_storage.rs | 76 ++++- ports/servoshell/prefs.rs | 1 + python/tidy/tidy.py | 1 + tests/wpt/mozilla/meta/MANIFEST.json | 2 +- .../tests/mozilla/interfaces.https.html | 1 + 18 files changed, 788 insertions(+), 56 deletions(-) create mode 100644 components/script/dom/storagemanager.rs create mode 100644 components/script_bindings/webidls/StorageManager.webidl diff --git a/components/config/prefs.rs b/components/config/prefs.rs index c5682aea2ff..88573f34e13 100644 --- a/components/config/prefs.rs +++ b/components/config/prefs.rs @@ -176,6 +176,8 @@ pub struct Preferences { // feature: Sanitizer API | #43948 | Web/API/HTML_Sanitizer_API pub dom_sanitizer_enabled: bool, pub dom_script_asynch: bool, + // feature: Storage API | #43976 | Web/API/Storage_API + pub dom_storage_manager_api_enabled: bool, // feature: ServiceWorker | #36538 | Web/API/Service_Worker_API pub dom_serviceworker_enabled: bool, pub dom_serviceworker_timeout_seconds: i64, @@ -392,6 +394,7 @@ impl Preferences { dom_resize_observer_enabled: true, dom_sanitizer_enabled: false, dom_script_asynch: true, + dom_storage_manager_api_enabled: false, dom_serviceworker_enabled: false, dom_serviceworker_timeout_seconds: 60, dom_servo_helpers_enabled: false, diff --git a/components/script/dom/mod.rs b/components/script/dom/mod.rs index 4bf0b638eb3..9326ae505c3 100644 --- a/components/script/dom/mod.rs +++ b/components/script/dom/mod.rs @@ -350,6 +350,7 @@ pub(crate) mod servoparser; pub(crate) mod shadowroot; pub(crate) mod staticrange; pub(crate) mod storage; +pub(crate) mod storagemanager; pub(crate) mod stream; pub(crate) use self::stream::*; pub(crate) mod svg; diff --git a/components/script/dom/navigator.rs b/components/script/dom/navigator.rs index d74ce859b79..4922014fc61 100644 --- a/components/script/dom/navigator.rs +++ b/components/script/dom/navigator.rs @@ -56,6 +56,7 @@ use crate::dom::permissions::Permissions; use crate::dom::pluginarray::PluginArray; use crate::dom::serviceworkercontainer::ServiceWorkerContainer; use crate::dom::servointernals::ServoInternals; +use crate::dom::storagemanager::StorageManager; use crate::dom::types::UserActivation; use crate::dom::wakelock::WakeLock; #[cfg(feature = "webgpu")] @@ -127,6 +128,7 @@ pub(crate) struct Navigator { permissions: MutNullableDom, mediasession: MutNullableDom, clipboard: MutNullableDom, + storage: MutNullableDom, #[cfg(feature = "webgpu")] gpu: MutNullableDom, /// @@ -155,6 +157,7 @@ impl Navigator { permissions: Default::default(), mediasession: Default::default(), clipboard: Default::default(), + storage: Default::default(), #[cfg(feature = "webgpu")] gpu: Default::default(), #[cfg(feature = "gamepad")] @@ -478,6 +481,12 @@ impl NavigatorMethods for Navigator { .or_init(|| Clipboard::new(cx, &self.global())) } + /// + fn Storage(&self, cx: &mut js::context::JSContext) -> DomRoot { + self.storage + .or_init(|| StorageManager::new(&self.global(), CanGc::from_cx(cx))) + } + /// fn SendBeacon(&self, url: USVString, data: Option, can_gc: CanGc) -> Fallible { let global = self.global(); diff --git a/components/script/dom/permissions.rs b/components/script/dom/permissions.rs index 76c9435e7de..dbd43c68157 100644 --- a/components/script/dom/permissions.rs +++ b/components/script/dom/permissions.rs @@ -276,14 +276,9 @@ impl PermissionAlgorithm for Permissions { match status.State() { // Step 3. PermissionState::Prompt => { - // https://w3c.github.io/permissions/#request-permission-to-use (Step 3 - 4) let permission_name = status.get_query(); let globalscope = GlobalScope::current().expect("No current global object"); - let state = prompt_user_from_embedder(permission_name, &globalscope); - globalscope - .permission_state_invocation_results() - .borrow_mut() - .insert(permission_name, state); + request_permission_to_use(permission_name, &globalscope); }, // Step 2. @@ -354,6 +349,24 @@ pub(crate) fn descriptor_permission_state( PermissionState::Prompt } +/// +pub(crate) fn request_permission_to_use( + name: PermissionName, + global_scope: &GlobalScope, +) -> PermissionState { + let state = descriptor_permission_state(name, Some(global_scope)); + if state != PermissionState::Prompt { + return state; + } + + let state = prompt_user_from_embedder(name, global_scope); + global_scope + .permission_state_invocation_results() + .borrow_mut() + .insert(name, state); + descriptor_permission_state(name, Some(global_scope)) +} + fn prompt_user_from_embedder(name: PermissionName, global_scope: &GlobalScope) -> PermissionState { let Some(webview_id) = global_scope.webview_id() else { warn!("Requesting permissions from non-webview-associated global scope"); diff --git a/components/script/dom/storagemanager.rs b/components/script/dom/storagemanager.rs new file mode 100644 index 00000000000..739ae9267fa --- /dev/null +++ b/components/script/dom/storagemanager.rs @@ -0,0 +1,279 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use std::rc::Rc; + +use dom_struct::dom_struct; +use servo_base::generic_channel::GenericCallback; + +use crate::dom::bindings::codegen::Bindings::PermissionStatusBinding::{ + PermissionName, PermissionState, +}; +use crate::dom::bindings::codegen::Bindings::StorageManagerBinding::{ + StorageEstimate, StorageManagerMethods, +}; +use crate::dom::bindings::error::Error; +use crate::dom::bindings::refcounted::TrustedPromise; +use crate::dom::bindings::reflector::{DomGlobal, Reflector, reflect_dom_object}; +use crate::dom::bindings::root::DomRoot; +use crate::dom::globalscope::GlobalScope; +use crate::dom::permissions::request_permission_to_use; +use crate::dom::promise::Promise; +use crate::realms::InRealm; +use crate::script_runtime::CanGc; +use crate::task_source::SendableTaskSource; + +#[dom_struct] +pub(crate) struct StorageManager { + reflector_: Reflector, +} + +impl StorageManager { + fn new_inherited() -> StorageManager { + StorageManager { + reflector_: Reflector::new(), + } + } + + pub(crate) fn new(global: &GlobalScope, can_gc: CanGc) -> DomRoot { + reflect_dom_object(Box::new(StorageManager::new_inherited()), global, can_gc) + } + + fn origin_cannot_obtain_local_storage_shelf(&self) -> bool { + !self.global().origin().is_tuple() + } + + fn type_error_from_string(message: String) -> Error { + let message = std::ffi::CString::new(message) + .unwrap_or_else(|_| c"Storage operation failed".to_owned()); + Error::Type(message) + } +} + +struct StorageManagerBooleanResponseHandler { + trusted_promise: Option, + task_source: SendableTaskSource, +} + +impl StorageManagerBooleanResponseHandler { + fn new(trusted_promise: TrustedPromise, task_source: SendableTaskSource) -> Self { + Self { + trusted_promise: Some(trusted_promise), + task_source, + } + } + + fn handle(&mut self, result: Result) { + let Some(trusted_promise) = self.trusted_promise.take() else { + error!("StorageManager callback called twice."); + return; + }; + + self.task_source + .queue(task!(storage_manager_boolean_response: move |cx| { + let promise = trusted_promise.root(); + match result { + Ok(value) => promise.resolve_native(&value, CanGc::from_cx(cx)), + Err(message) => promise.reject_error( + StorageManager::type_error_from_string(message), + CanGc::from_cx(cx), + ), + } + })); + } +} + +struct StorageManagerEstimateResponseHandler { + trusted_promise: Option, + task_source: SendableTaskSource, +} + +impl StorageManagerEstimateResponseHandler { + fn new(trusted_promise: TrustedPromise, task_source: SendableTaskSource) -> Self { + Self { + trusted_promise: Some(trusted_promise), + task_source, + } + } + + fn handle(&mut self, result: Result<(u64, u64), String>) { + let Some(trusted_promise) = self.trusted_promise.take() else { + error!("StorageManager callback called twice."); + return; + }; + + self.task_source + .queue(task!(storage_manager_estimate_response: move |cx| { + let promise = trusted_promise.root(); + match result { + Ok((usage, quota)) => { + let mut estimate = StorageEstimate::empty(); + estimate.usage = Some(usage); + estimate.quota = Some(quota); + promise.resolve_native(&estimate, CanGc::from_cx(cx)); + }, + Err(message) => { + promise.reject_error( + StorageManager::type_error_from_string(message), + CanGc::from_cx(cx), + ); + }, + } + })); + } +} + +impl StorageManagerMethods for StorageManager { + /// + fn Persisted(&self, comp: InRealm, can_gc: CanGc) -> Rc { + // Step 1. Let promise be a new promise. + let promise = Promise::new_in_current_realm(comp, can_gc); + // Step 2. Let global be this’s relevant global object. + let global = self.global(); + + // Step 3. Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + // Step 4. If shelf is failure, then reject promise with a TypeError. + if self.origin_cannot_obtain_local_storage_shelf() { + promise.reject_error( + Error::Type(c"Storage is unavailable for opaque origins".to_owned()), + can_gc, + ); + return promise; + } + + // Step 5. Otherwise, run these steps in parallel: + // Step 5.1. Let persisted be true if shelf’s bucket map["default"]'s mode is "persistent"; + // otherwise false. + // It will be false when there’s an internal error. + // Step 5.2. Queue a storage task with global to resolve promise with persisted. + let mut handler = StorageManagerBooleanResponseHandler::new( + TrustedPromise::new(promise.clone()), + global.task_manager().storage_task_source().to_sendable(), + ); + let callback = GenericCallback::new(move |message| { + handler.handle(message.unwrap_or_else(|error| Err(error.to_string()))); + }) + .expect("Could not create StorageManager persisted callback"); + + if global + .storage_threads() + .persisted(global.origin().immutable().clone(), callback.clone()) + .is_err() + { + if let Err(error) = callback.send(Err("Failed to queue storage task".to_owned())) { + error!("Failed to deliver StorageManager persisted error: {error}"); + } + } + + // Step 6. Return promise. + promise + } + + /// + fn Persist(&self, comp: InRealm, can_gc: CanGc) -> Rc { + // Step 1. Let promise be a new promise. + let promise = Promise::new_in_current_realm(comp, can_gc); + // Step 2. Let global be this’s relevant global object. + let global = self.global(); + + // Step 3. Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + // Step 4. If shelf is failure, then reject promise with a TypeError. + if self.origin_cannot_obtain_local_storage_shelf() { + promise.reject_error( + Error::Type(c"Storage is unavailable for opaque origins".to_owned()), + can_gc, + ); + return promise; + } + + // Step 5. Otherwise, run these steps in parallel: + // Step 5.1. Let permission be the result of requesting permission to use + // "persistent-storage". + let permission = request_permission_to_use(PermissionName::Persistent_storage, &global); + + // Step 5.2. Let bucket be shelf’s bucket map["default"]. + // Step 5.3. Let persisted be true if bucket’s mode is "persistent"; otherwise false. + // It will be false when there’s an internal error. + // Step 5.4. If persisted is false and permission is "granted", then: + // Step 5.4.1. Set bucket’s mode to "persistent". + // Step 5.4.2. If there was no internal error, then set persisted to true. + // Step 5.5. Queue a storage task with global to resolve promise with persisted. + let mut handler = StorageManagerBooleanResponseHandler::new( + TrustedPromise::new(promise.clone()), + global.task_manager().storage_task_source().to_sendable(), + ); + let callback = GenericCallback::new(move |message| { + handler.handle(message.unwrap_or_else(|error| Err(error.to_string()))); + }) + .expect("Could not create StorageManager persist callback"); + + if global + .storage_threads() + .persist( + global.origin().immutable().clone(), + permission == PermissionState::Granted, + callback.clone(), + ) + .is_err() + { + if let Err(error) = callback.send(Err("Failed to queue storage task".to_owned())) { + error!("Failed to deliver StorageManager persist error: {error}"); + } + } + + // Step 6. Return promise. + promise + } + + /// + fn Estimate(&self, comp: InRealm, can_gc: CanGc) -> Rc { + // Step 1. Let promise be a new promise. + let promise = Promise::new_in_current_realm(comp, can_gc); + // Step 2. Let global be this’s relevant global object. + let global = self.global(); + + // Step 3. Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + // Step 4. If shelf is failure, then reject promise with a TypeError. + if self.origin_cannot_obtain_local_storage_shelf() { + promise.reject_error( + Error::Type(c"Storage is unavailable for opaque origins".to_owned()), + can_gc, + ); + return promise; + } + + // Step 5. Otherwise, run these steps in parallel: + // Step 5.1. Let usage be storage usage for shelf. + // Step 5.2. Let quota be storage quota for shelf. + // Step 5.3. Let dictionary be a new StorageEstimate dictionary whose usage member is usage and quota + // member is quota. + // Step 5.4. If there was an internal error while obtaining usage and quota, then queue a storage + // task with global to reject promise with a TypeError. + // Step 5.5. Otherwise, queue a storage task with global to resolve promise with dictionary. + let mut handler = StorageManagerEstimateResponseHandler::new( + TrustedPromise::new(promise.clone()), + global.task_manager().storage_task_source().to_sendable(), + ); + let callback = GenericCallback::new(move |message| { + handler.handle(message.unwrap_or_else(|error| Err(error.to_string()))); + }) + .expect("Could not create StorageManager estimate callback"); + + if global + .storage_threads() + .estimate(global.origin().immutable().clone(), callback.clone()) + .is_err() + { + if let Err(error) = callback.send(Err("Failed to queue storage task".to_owned())) { + error!("Failed to deliver StorageManager estimate error: {error}"); + } + } + + // Step 6. Return promise. + promise + } +} diff --git a/components/script/dom/workers/workernavigator.rs b/components/script/dom/workers/workernavigator.rs index 5b02624d17d..5c3c6db3d26 100644 --- a/components/script/dom/workers/workernavigator.rs +++ b/components/script/dom/workers/workernavigator.rs @@ -14,6 +14,7 @@ use crate::dom::bindings::utils::to_frozen_array; use crate::dom::navigator::hardware_concurrency; use crate::dom::navigatorinfo; use crate::dom::permissions::Permissions; +use crate::dom::storagemanager::StorageManager; #[cfg(feature = "webgpu")] use crate::dom::webgpu::gpu::GPU; use crate::dom::workerglobalscope::WorkerGlobalScope; @@ -24,6 +25,7 @@ use crate::script_runtime::{CanGc, JSContext}; pub(crate) struct WorkerNavigator { reflector_: Reflector, permissions: MutNullableDom, + storage: MutNullableDom, #[cfg(feature = "webgpu")] gpu: MutNullableDom, } @@ -33,6 +35,7 @@ impl WorkerNavigator { WorkerNavigator { reflector_: Reflector::new(), permissions: Default::default(), + storage: Default::default(), #[cfg(feature = "webgpu")] gpu: Default::default(), } @@ -115,6 +118,12 @@ impl WorkerNavigatorMethods for WorkerNavigator { .or_init(|| Permissions::new(&self.global(), CanGc::deprecated_note())) } + /// + fn Storage(&self, cx: &mut js::context::JSContext) -> DomRoot { + self.storage + .or_init(|| StorageManager::new(&self.global(), CanGc::from_cx(cx))) + } + // https://gpuweb.github.io/gpuweb/#dom-navigator-gpu #[cfg(feature = "webgpu")] fn Gpu(&self) -> DomRoot { diff --git a/components/script/task_manager.rs b/components/script/task_manager.rs index a4688f233b0..6f5947dae7c 100644 --- a/components/script/task_manager.rs +++ b/components/script/task_manager.rs @@ -162,6 +162,7 @@ impl TaskManager { intersection_observer_task_source, IntersectionObserver ); + task_source_functions!(self, storage_task_source, Storage); #[cfg(feature = "webgpu")] task_source_functions!(self, webgpu_task_source, WebGPU); } diff --git a/components/script/task_source.rs b/components/script/task_source.rs index 10c58001816..60dcad02ee3 100644 --- a/components/script/task_source.rs +++ b/components/script/task_source.rs @@ -52,6 +52,8 @@ pub(crate) enum TaskSourceName { Geolocation, /// IntersectionObserver, + /// + Storage, /// WebGPU, } @@ -85,6 +87,7 @@ impl From for ScriptThreadEventCategory { TaskSourceName::Timer => ScriptThreadEventCategory::TimerEvent, TaskSourceName::Gamepad => ScriptThreadEventCategory::InputEvent, TaskSourceName::IntersectionObserver => ScriptThreadEventCategory::ScriptEvent, + TaskSourceName::Storage => ScriptThreadEventCategory::ScriptEvent, TaskSourceName::WebGPU => ScriptThreadEventCategory::ScriptEvent, } } diff --git a/components/script_bindings/codegen/Bindings.conf b/components/script_bindings/codegen/Bindings.conf index f07bc1f348f..06f2246703c 100644 --- a/components/script_bindings/codegen/Bindings.conf +++ b/components/script_bindings/codegen/Bindings.conf @@ -710,7 +710,7 @@ DOMInterfaces = { 'Navigator': { 'inRealms': ['GetVRDisplays'], 'canGc': ['Languages', 'SendBeacon', 'UserActivation'], - 'cx': ['Clipboard', 'WakeLock'], + 'cx': ['Clipboard', 'Storage', 'WakeLock'], }, 'WakeLock': { @@ -829,6 +829,11 @@ DOMInterfaces = { 'weakReferenceable': True, }, +'StorageManager': { + 'inRealms': ['Persisted', 'Persist', 'Estimate'], + 'canGc': ['Persisted', 'Persist', 'Estimate'], +}, + 'SubtleCrypto': { 'realm': ['Encrypt', 'Decrypt', 'Sign', 'Verify', 'GenerateKey', 'DeriveKey', 'DeriveBits', 'Digest', 'ImportKey', 'ExportKey', 'WrapKey', 'UnwrapKey', 'EncapsulateKey', 'EncapsulateBits', 'DecapsulateKey', 'DecapsulateBits', 'GetPublicKey'], 'cx': ['Supports', 'Supports_'], @@ -1067,6 +1072,7 @@ DOMInterfaces = { 'WorkerNavigator': { 'canGc': ['Languages'], + 'cx': ['Storage'], }, } diff --git a/components/script_bindings/webidls/StorageManager.webidl b/components/script_bindings/webidls/StorageManager.webidl new file mode 100644 index 00000000000..508fb4423cd --- /dev/null +++ b/components/script_bindings/webidls/StorageManager.webidl @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +// https://storage.spec.whatwg.org/#api + +[SecureContext] +interface mixin NavigatorStorage { + [SameObject, Pref="dom_storage_manager_api_enabled"] readonly attribute StorageManager storage; +}; +Navigator includes NavigatorStorage; +WorkerNavigator includes NavigatorStorage; + +[SecureContext, Exposed=(Window,Worker)] +interface StorageManager { + [NewObject] + Promise persisted(); + + [Exposed=Window, NewObject] + Promise persist(); + + [NewObject] + Promise estimate(); +}; + +dictionary StorageEstimate { + unsigned long long usage; + unsigned long long quota; +}; diff --git a/components/shared/storage/client_storage.rs b/components/shared/storage/client_storage.rs index 1e8ec8cbdf1..6da3a5a7dab 100644 --- a/components/shared/storage/client_storage.rs +++ b/components/shared/storage/client_storage.rs @@ -3,9 +3,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use std::ops::{Deref, DerefMut}; use std::path::PathBuf; +use std::str::FromStr; use serde::{Deserialize, Serialize}; -use servo_base::generic_channel::{self, GenericReceiver, GenericSender}; +use servo_base::generic_channel::{ + self, GenericCallback, GenericReceiver, GenericSender, SendResult, +}; use servo_base::id::WebViewId; use servo_url::ImmutableOrigin; @@ -67,6 +70,37 @@ impl ClientStorageThreadHandle { self.sender.send(message).unwrap(); receiver } + + pub fn persisted( + &self, + origin: ImmutableOrigin, + sender: GenericCallback>, + ) -> SendResult { + self.sender + .send(ClientStorageThreadMessage::Persisted { origin, sender }) + } + + pub fn persist( + &self, + origin: ImmutableOrigin, + permission_granted: bool, + sender: GenericCallback>, + ) -> SendResult { + self.sender.send(ClientStorageThreadMessage::Persist { + origin, + permission_granted, + sender, + }) + } + + pub fn estimate( + &self, + origin: ImmutableOrigin, + sender: GenericCallback>, + ) -> SendResult { + self.sender + .send(ClientStorageThreadMessage::Estimate { origin, sender }) + } } impl From for GenericSender { @@ -106,7 +140,7 @@ impl StorageType { } /// -#[derive(Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub enum Mode { /// It is initially "best-effort". #[default] @@ -123,6 +157,19 @@ impl Mode { } } +impl FromStr for Mode { + type Err = (); + + /// + fn from_str(value: &str) -> Result { + match value { + "best-effort" => Ok(Mode::BestEffort), + "persistent" => Ok(Mode::Persistent), + _ => Err(()), + } + } +} + /// #[derive(Debug, Deserialize, Serialize)] pub enum StorageIdentifier { @@ -200,6 +247,18 @@ pub enum ClientStorageThreadMessage { name: String, sender: GenericSender>, }, - /// Send a reply when done cleaning up thread resources and then shut it down + Persisted { + origin: ImmutableOrigin, + sender: GenericCallback>, + }, + Persist { + origin: ImmutableOrigin, + permission_granted: bool, + sender: GenericCallback>, + }, + Estimate { + origin: ImmutableOrigin, + sender: GenericCallback>, + }, Exit(GenericSender<()>), } diff --git a/components/shared/storage/lib.rs b/components/shared/storage/lib.rs index 73c80c04709..623fe559eab 100644 --- a/components/shared/storage/lib.rs +++ b/components/shared/storage/lib.rs @@ -4,7 +4,8 @@ use malloc_size_of::malloc_size_of_is_0; use serde::{Deserialize, Serialize}; -use servo_base::generic_channel::{self, GenericSend, GenericSender, SendResult}; +use servo_base::generic_channel::{self, GenericCallback, GenericSend, GenericSender, SendResult}; +use servo_url::ImmutableOrigin; use crate::client_storage::ClientStorageThreadMessage; use crate::indexeddb::IndexedDBThreadMsg; @@ -34,6 +35,38 @@ impl StorageThreads { } } + pub fn persisted( + &self, + origin: ImmutableOrigin, + sender: GenericCallback>, + ) -> SendResult { + self.client_storage_thread + .send(ClientStorageThreadMessage::Persisted { origin, sender }) + } + + pub fn persist( + &self, + origin: ImmutableOrigin, + permission_granted: bool, + sender: GenericCallback>, + ) -> SendResult { + self.client_storage_thread + .send(ClientStorageThreadMessage::Persist { + origin, + permission_granted, + sender, + }) + } + + pub fn estimate( + &self, + origin: ImmutableOrigin, + sender: GenericCallback>, + ) -> SendResult { + self.client_storage_thread + .send(ClientStorageThreadMessage::Estimate { origin, sender }) + } + // TODO: Consider changing to `webstorage_sites` pub fn webstorage_origins(&self, storage_type: WebStorageType) -> Vec { let (sender, receiver) = generic_channel::channel().unwrap(); diff --git a/components/storage/client_storage.rs b/components/storage/client_storage.rs index b518ae01151..33525a081a2 100644 --- a/components/storage/client_storage.rs +++ b/components/storage/client_storage.rs @@ -3,7 +3,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use std::fmt::Debug; use std::path::PathBuf; -use std::thread; +use std::str::FromStr; +use std::{fs, thread}; use log::warn; use rusqlite::{Connection, Transaction}; @@ -16,6 +17,12 @@ use storage_traits::client_storage::{ }; use uuid::Uuid; +/// +/// The storage quota of a storage shelf is an implementation-defined conservative estimate of the +/// total amount of byttes it can hold. We use 10 GiB per shelf, matching Firefox's documented +/// limit (). +const STORAGE_SHELF_QUOTA_BYTES: u64 = 10 * 1024 * 1024 * 1024; + trait RegistryEngine { type Error: Debug; fn create_database( @@ -36,6 +43,13 @@ trait RegistryEngine { origin: ImmutableOrigin, sender: &GenericSender, ) -> Result>; + fn persisted(&mut self, origin: ImmutableOrigin) -> Result; + fn persist( + &mut self, + origin: ImmutableOrigin, + permission_granted: bool, + ) -> Result; + fn estimate(&mut self, origin: ImmutableOrigin) -> Result<(u64, u64), String>; } struct SqliteEngine { @@ -203,8 +217,8 @@ fn create_a_storage_bucket( storage_type: StorageType, tx: &Transaction, ) -> rusqlite::Result { - // Step 1: Let bucket be null. - // Step 2: If type is "local", then set bucket to a new local storage bucket. + // Step 1. Let bucket be null. + // Step 2. If type is "local", then set bucket to a new local storage bucket. let bucket_id: i64 = if let StorageType::Local = storage_type { tx.query_row( "INSERT INTO buckets (mode, shelf_id) VALUES (?1, ?2) @@ -214,9 +228,9 @@ fn create_a_storage_bucket( |row| row.get(0), )? } else { - // Step 3: Otherwise: - // Step 3.1: Assert: type is "session". - // Step 3.2: Set bucket to a new session storage bucket. + // Step 3. Otherwise: + // Step 3.1. Assert: type is "session". + // Step 3.2. Set bucket to a new session storage bucket. tx.query_row( "INSERT INTO buckets (shelf_id) VALUES (?1) ON CONFLICT(shelf_id) DO UPDATE SET shelf_id = excluded.shelf_id @@ -226,7 +240,7 @@ fn create_a_storage_bucket( )? }; - // Step 4: For each endpoint of registered storage endpoints whose types contain type, + // Step 4. For each endpoint of registered storage endpoints whose types contain type, // set bucket’s bottle map[endpoint’s identifier] to // a new storage bottle whose quota is endpoint’s quota. @@ -249,7 +263,7 @@ fn create_a_storage_bucket( )?; } - // Step 5: Return bucket. + // Step 5. Return bucket. Ok(bucket_id) } @@ -259,8 +273,10 @@ fn create_a_storage_shelf( origin: &ImmutableOrigin, storage_type: StorageType, tx: &Transaction, -) -> rusqlite::Result { - // Step 1: Let shelf be a new storage shelf. +) -> rusqlite::Result { + // To create a storage shelf, given a storage type type, run these steps: + // Step 1. Let shelf be a new storage shelf. + // Step 2. Set shelf’s bucket map["default"] to the result of running create a storage bucket with type. let shelf_id: i64 = tx.query_row( "INSERT INTO shelves (shed_id, origin) VALUES (?1, ?2) ON CONFLICT(shed_id, origin) DO UPDATE SET origin = excluded.origin @@ -269,9 +285,10 @@ fn create_a_storage_shelf( |row| row.get(0), )?; - // Step 2: Set shelf’s bucket map["default"] to the result of running create a storage bucket with type. - // Note: returning `shelf’s bucket map["default"]`, which is the `bucket_id`. - create_a_storage_bucket(shelf_id, storage_type, tx) + // Step 3. Return shelf. + Ok(StorageShelf { + default_bucket_id: create_a_storage_bucket(shelf_id, storage_type, tx)?, + }) } /// @@ -280,18 +297,124 @@ fn obtain_a_storage_shelf( origin: &ImmutableOrigin, storage_type: StorageType, tx: &Transaction, -) -> rusqlite::Result { - // Step 1: Let key be the result of running obtain a storage key with environment. - // Step 2: If key is failure, then return failure. +) -> rusqlite::Result { + create_a_storage_shelf(shed, origin, storage_type, tx) +} - // Step 3: If shed[key] does not exist, - // then set shed[key] to the result of running create a storage shelf with type. - // Note: method internally conditions on shed[key] not existing. - let bucket_id = create_a_storage_shelf(shed, origin, storage_type, tx)?; +/// +/// +/// A storage shelf exists for each storage key within a storage shed. It holds a bucket map, which +/// is a map of strings to storage buckets. +struct StorageShelf { + default_bucket_id: i64, +} - // Step 4: Return shed[key]. - // Note: returning `shed[key]["default"]`, which is `bucket_id`. - Ok(bucket_id) +/// +/// +/// To obtain a local storage shelf, given an environment settings object environment, return the +/// result of running obtain a storage shelf with the user agent’s storage shed, environment, and +/// "local". +fn obtain_a_local_storage_shelf( + origin: &ImmutableOrigin, + tx: &Transaction, +) -> Result { + if !origin.is_tuple() { + return Err("Storage is unavailable for opaque origins".to_owned()); + } + + let shed = + ensure_storage_shed(&StorageType::Local, None, tx).map_err(|error| error.to_string())?; + obtain_a_storage_shelf(shed, origin, StorageType::Local, tx).map_err(|error| error.to_string()) +} + +/// +/// +/// A local storage bucket has a mode, which is "best-effort" or "persistent". It is initially +/// "best-effort". +fn bucket_mode(bucket_id: i64, tx: &Transaction) -> rusqlite::Result { + let mode: String = tx.query_row( + "SELECT mode FROM buckets WHERE id = ?1;", + [bucket_id], + |row| row.get(0), + )?; + Ok(Mode::from_str(&mode).unwrap_or_default()) +} + +/// +/// +/// Set bucket’s mode to "persistent". +fn set_bucket_mode(bucket_id: i64, mode: Mode, tx: &Transaction) -> rusqlite::Result<()> { + tx.execute( + "UPDATE buckets SET mode = ?1, persisted = ?2 WHERE id = ?3;", + (mode.as_str(), matches!(mode, Mode::Persistent), bucket_id), + )?; + Ok(()) +} + +/// +/// +/// The storage usage of a storage shelf is an implementation-defined rough estimate of the amount +/// of bytes used by it. +/// +/// This cannot be an exact amount as user agents might, and are encouraged to, use deduplication, +/// compression, and other techniques that obscure exactly how much bytes a storage shelf uses. +fn storage_usage_for_bucket(bucket_id: i64, tx: &Transaction) -> Result { + let mut stmt = tx + .prepare( + "SELECT directories.path + FROM directories + JOIN databases ON directories.database_id = databases.id + JOIN bottles ON databases.bottle_id = bottles.id + WHERE bottles.bucket_id = ?1;", + ) + .map_err(|error| error.to_string())?; + + let rows = stmt + .query_map([bucket_id], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())?; + + let mut usage = 0_u64; + for path in rows { + usage += directory_size(&PathBuf::from(path.map_err(|error| error.to_string())?))?; + } + Ok(usage) +} + +/// +/// +/// The storage quota of a storage shelf is an implementation-defined conservative estimate of the +/// total amount of bytes it can hold. This amount should be less than the total storage space on +/// the device. It must not be a function of the available storage space on the device. +/// +/// User agents are strongly encouraged to consider navigation frequency, recency of visits, +/// bookmarking, and permission for "persistent-storage" when determining quotas. +/// +/// Directly or indirectly revealing available storage space can lead to fingerprinting and leaking +/// information outside the scope of the origin involved. +fn storage_quota_for_bucket(_bucket_id: i64, _tx: &Transaction) -> Result { + Ok(STORAGE_SHELF_QUOTA_BYTES) +} + +/// +/// +/// The storage usage of a storage shelf is an implementation-defined rough estimate of the amount +/// of bytes used by it. +fn directory_size(path: &PathBuf) -> Result { + let metadata = fs::metadata(path).map_err(|error| error.to_string())?; + if metadata.is_file() { + return Ok(metadata.len()); + } + + if !metadata.is_dir() { + return Ok(0); + } + + let mut size = 0_u64; + for entry in fs::read_dir(path).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + size += directory_size(&entry.path())?; + } + Ok(size) } impl RegistryEngine for SqliteEngine { @@ -393,18 +516,17 @@ impl RegistryEngine for SqliteEngine { ) -> Result> { let tx = self.connection.transaction()?; - // Step 1: Let shed be null. + // Step 1. Let shed be null. let shed_id: i64 = match storage_type { StorageType::Local => { - // Step 2: If type is "local", then set shed to the user agent’s storage shed. + // Step 2. If type is "local", then set shed to the user agent’s storage shed. ensure_storage_shed(&storage_type, None, &tx)? }, StorageType::Session => { - // Step 3: Otherwise: - // Step 3.1: Assert: type is "session". - // Step 3.2: Set shed to environment’s global object’s associated Document’s - // node navigable’s traversable navigable’s storage shed. - // Note: using the browsing context of the webview as the traversable navigable. + // Step 3. Otherwise: + // Step 3.1. Assert: type is "session". + // Step 3.2. Set shed to environment’s global object’s associated Document’s node + // navigable’s traversable navigable’s storage shed. ensure_storage_shed( &storage_type, Some(Into::::into(webview).to_string()), @@ -413,13 +535,13 @@ impl RegistryEngine for SqliteEngine { }, }; - // Step 4: Let shelf be the result of running obtain a storage shelf, - // with shed, environment, and type. - // Step 5: If shelf is failure, then return failure. - let bucket_id = obtain_a_storage_shelf(shed_id, &origin, storage_type, &tx)?; + // Step 4. Let shelf be the result of running obtain a storage shelf, with shed, + // environment, and type. + // Step 5. If shelf is failure, then return failure. + let shelf = obtain_a_storage_shelf(shed_id, &origin, storage_type, &tx)?; - // Step 6: Let bucket be shelf’s bucket map["default"]. - // Done above with `bucket_id`. + // Step 6. Let bucket be shelf’s bucket map["default"]. + let bucket_id = shelf.default_bucket_id; let bottle_id: i64 = tx.query_row( "SELECT id FROM bottles WHERE bucket_id = ?1 AND identifier = ?2;", @@ -429,19 +551,94 @@ impl RegistryEngine for SqliteEngine { tx.commit()?; - // Step 7: Let bottle be bucket’s bottle map[identifier]. - // Note: done with `bucket_id`. + // Step 7. Let bottle be bucket’s bottle map[identifier]. - // Step 8: Let proxyMap be a new storage proxy map whose backing map is bottle’s map. - // Step 9: Append proxyMap to bottle’s proxy map reference set. - // Note: not doing the reference set part for now, not sure what it is useful for. - - // Step 10: Return proxyMap. + // Step 8. Let proxyMap be a new storage proxy map whose backing map is bottle’s map. + // Step 9. Append proxyMap to bottle’s proxy map reference set. + // Step 10. Return proxyMap. Ok(StorageProxyMap { bottle_id, handle: ClientStorageThreadHandle::new(sender.clone()), }) } + + fn persisted(&mut self, origin: ImmutableOrigin) -> Result { + let tx = self + .connection + .transaction() + .map_err(|error| error.to_string())?; + + // + // Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + let shelf = obtain_a_local_storage_shelf(&origin, &tx)?; + + // Let persisted be true if shelf’s bucket map["default"]'s mode is "persistent"; + // otherwise false. + // It will be false when there’s an internal error. + let persisted = bucket_mode(shelf.default_bucket_id, &tx) + .is_ok_and(|mode| mode == Mode::Persistent) && + tx.commit().is_ok(); + + Ok(persisted) + } + + fn persist( + &mut self, + origin: ImmutableOrigin, + permission_granted: bool, + ) -> Result { + let tx = self + .connection + .transaction() + .map_err(|error| error.to_string())?; + + // + // Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + let shelf = obtain_a_local_storage_shelf(&origin, &tx)?; + + // Let bucket be shelf’s bucket map["default"]. + let bucket_id = shelf.default_bucket_id; + + // Let persisted be true if bucket’s mode is "persistent"; otherwise false. + // It will be false when there’s an internal error. + let mut persisted = bucket_mode(bucket_id, &tx).is_ok_and(|mode| mode == Mode::Persistent); + + // If persisted is false and permission is "granted", then: + // Set bucket’s mode to "persistent". + // If there was no internal error, then set persisted to true. + if !persisted && permission_granted { + persisted = set_bucket_mode(bucket_id, Mode::Persistent, &tx).is_ok(); + } + + if tx.commit().is_err() { + persisted = false; + } + + Ok(persisted) + } + + fn estimate(&mut self, origin: ImmutableOrigin) -> Result<(u64, u64), String> { + let tx = self + .connection + .transaction() + .map_err(|error| error.to_string())?; + + // + // Let shelf be the result of running obtain a local storage shelf with this’s relevant + // settings object. + let shelf = obtain_a_local_storage_shelf(&origin, &tx)?; + + // Let usage be storage usage for shelf. + let usage = storage_usage_for_bucket(shelf.default_bucket_id, &tx)?; + // Let quota be storage quota for shelf. + let quota = storage_quota_for_bucket(shelf.default_bucket_id, &tx)?; + + tx.commit().map_err(|error| error.to_string())?; + + Ok((usage, quota)) + } } pub trait ClientStorageThreadFactory { @@ -533,6 +730,19 @@ where let result = self.engine.delete_database(bottle_id, name); let _ = sender.send(result.map_err(|e| format!("{:?}", e))); }, + ClientStorageThreadMessage::Persisted { origin, sender } => { + let _ = sender.send(self.engine.persisted(origin)); + }, + ClientStorageThreadMessage::Persist { + origin, + permission_granted, + sender, + } => { + let _ = sender.send(self.engine.persist(origin, permission_granted)); + }, + ClientStorageThreadMessage::Estimate { origin, sender } => { + let _ = sender.send(self.engine.estimate(origin)); + }, ClientStorageThreadMessage::Exit(sender) => { let _ = sender.send(()); break; diff --git a/components/storage/tests/client_storage.rs b/components/storage/tests/client_storage.rs index a4833c33e10..02bb3fe193c 100644 --- a/components/storage/tests/client_storage.rs +++ b/components/storage/tests/client_storage.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use rusqlite::Connection; -use servo_base::generic_channel; +use servo_base::generic_channel::{self, GenericCallback}; use servo_base::id::{BrowsingContextId, PipelineNamespace, PipelineNamespaceId, WebViewId}; use servo_url::ServoUrl; use storage::ClientStorageThreadFactory; @@ -229,3 +229,77 @@ fn test_repeated_session_obtain_reuses_same_logical_rows() { assert_eq!(bucket_count, 1); assert_eq!(bottle_count, 1); } + +#[test] +fn test_local_persistence_and_estimate() { + install_test_namespace(); + let tmp_dir = tempfile::tempdir().unwrap(); + let handle: ClientStorageThreadHandle = + ClientStorageThreadFactory::new(Some(tmp_dir.path().to_path_buf())); + + let origin = ServoUrl::parse("https://example.com").unwrap().origin(); + let webview = WebViewId::new(servo_base::id::TEST_PAINTER_ID); + let storage_proxy_map = obtain_bottle_map( + &handle, + StorageType::Local, + webview, + StorageIdentifier::IndexedDB, + origin.clone(), + ); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persisted(origin.clone(), cb).unwrap(); + assert!(!rx.recv().unwrap().unwrap()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persist(origin.clone(), false, cb).unwrap(); + assert!(!rx.recv().unwrap().unwrap()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persisted(origin.clone(), cb).unwrap(); + assert!(!rx.recv().unwrap().unwrap()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persist(origin.clone(), true, cb).unwrap(); + assert!(rx.recv().unwrap().unwrap()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persisted(origin.clone(), cb).unwrap(); + assert!(rx.recv().unwrap().unwrap()); + + let path = handle + .create_database(storage_proxy_map.bottle_id, "estimate".to_string()) + .recv() + .unwrap() + .unwrap(); + let payload = vec![0x5a; 8192]; + std::fs::write(path.join("payload.bin"), &payload).unwrap(); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.estimate(origin, cb).unwrap(); + let (usage, quota) = rx.recv().unwrap().unwrap(); + assert!(usage >= payload.len() as u64); + assert!(quota > usage); +} + +#[test] +fn test_storage_manager_operations_fail_for_opaque_origins() { + install_test_namespace(); + let tmp_dir = tempfile::tempdir().unwrap(); + let handle: ClientStorageThreadHandle = + ClientStorageThreadFactory::new(Some(tmp_dir.path().to_path_buf())); + + let origin = ServoUrl::parse("data:text/plain,hello").unwrap().origin(); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persisted(origin.clone(), cb).unwrap(); + assert!(rx.recv().unwrap().is_err()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.persist(origin.clone(), true, cb).unwrap(); + assert!(rx.recv().unwrap().is_err()); + + let (cb, rx) = GenericCallback::new_blocking().unwrap(); + handle.estimate(origin, cb).unwrap(); + assert!(rx.recv().unwrap().is_err()); +} diff --git a/ports/servoshell/prefs.rs b/ports/servoshell/prefs.rs index ce295f9ce77..b3633945fe0 100644 --- a/ports/servoshell/prefs.rs +++ b/ports/servoshell/prefs.rs @@ -40,6 +40,7 @@ pub(crate) static EXPERIMENTAL_PREFS: &[&str] = &[ "dom_notification_enabled", "dom_offscreen_canvas_enabled", "dom_permissions_enabled", + "dom_storage_manager_api_enabled", "dom_webgl2_enabled", "dom_webgpu_enabled", "layout_columns_enabled", diff --git a/python/tidy/tidy.py b/python/tidy/tidy.py index 95ac968972d..a3b3c6dfdd3 100644 --- a/python/tidy/tidy.py +++ b/python/tidy/tidy.py @@ -126,6 +126,7 @@ WEBIDL_STANDARDS = [ b"//fetch.spec.whatwg.org", b"//html.spec.whatwg.org", b"//streams.spec.whatwg.org", + b"//storage.spec.whatwg.org", b"//url.spec.whatwg.org", b"//urlpattern.spec.whatwg.org", b"//xhr.spec.whatwg.org", diff --git a/tests/wpt/mozilla/meta/MANIFEST.json b/tests/wpt/mozilla/meta/MANIFEST.json index b0ae2d6f50c..c5478b8a9eb 100644 --- a/tests/wpt/mozilla/meta/MANIFEST.json +++ b/tests/wpt/mozilla/meta/MANIFEST.json @@ -14323,7 +14323,7 @@ ] ], "interfaces.https.html": [ - "090fde1d7537506349e9b47f6e7f75b12243525c", + "566deaac42e0f430fadcbd28bf4dba751c7ba39a", [ null, {} diff --git a/tests/wpt/mozilla/tests/mozilla/interfaces.https.html b/tests/wpt/mozilla/tests/mozilla/interfaces.https.html index 090fde1d753..566deaac42e 100644 --- a/tests/wpt/mozilla/tests/mozilla/interfaces.https.html +++ b/tests/wpt/mozilla/tests/mozilla/interfaces.https.html @@ -335,6 +335,7 @@ test_interfaces([ "StaticRange", "StereoPannerNode", "Storage", + "StorageManager", "StorageEvent", "StyleSheet", "StyleSheetList",