From bd6f8d7ea4fe30936c4fa039cb7c1c3b1820ce99 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 4 Oct 2026 05:26:24 +0800 Subject: [PATCH] fix(dnd): enforce drag data store modes and event lifetime --- moli-protocol/src/domains/input/tests.rs | 12 +- moli-renderer-v8/src/context_bootstrap.rs | 7 +- .../assets/prototype_bindings.rs | 6 +- .../src/context_bootstrap/file_api.rs | 8 +- .../file_api/data_transfer.rs | 213 ++++++++++++++-- .../file_api/data_transfer/drag_store.rs | 98 ++++++++ .../file_api/data_transfer_string.rs | 6 +- moli-renderer-v8/src/native_bridge/element.rs | 4 +- .../src/native_bridge/element/events.rs | 12 +- .../element/events/constructors.rs | 21 -- moli-renderer-v8/src/script_vm.rs | 2 +- .../src/script_vm/input_dispatch.rs | 99 ++++---- moli-renderer-v8/src/script_vm/input_drag.rs | 80 ++++-- .../tests/browser_api/drag_data_modes.js | 123 +++++++++ .../tests/browser_api/drag_data_modes.rs | 234 ++++++++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 1 + .../browser_api/native_event_construction.rs | 3 +- .../tests/dom_xhr/dom/files_and_transfer.rs | 21 +- .../wpt/ported/fileapi/drag-cancel-basic.html | 7 +- .../ported/fileapi/drag-lifecycle-basic.html | 16 +- .../fileapi/drag-mixed-payload-basic.html | 134 +++++----- 21 files changed, 876 insertions(+), 231 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/file_api/data_transfer/drag_store.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.js create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.rs diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index 6568ba86de..8195ef2809 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -1888,10 +1888,13 @@ async fn coordinate_drag_event_completes_through_pending_layout_dispatch() { @@ -1933,7 +1936,12 @@ async fn coordinate_drag_event_completes_through_pending_layout_dispatch() { assert_eq!(messages, vec![json!({ "id": 4107, "result": {} })]); assert_eq!( evaluate_string(&mut ctx, "JSON.stringify(window.__dragPending)").await, - r#"["dragenter|true|drag-pending"]"# + r#"["dragenter|true||text/plain|string"]"# + ); + assert_eq!( + evaluate_string(&mut ctx, "JSON.stringify([__savedDragTransfer.items.length,__savedDragTransfer.types.length,__savedDragTransfer.getData('text/plain')])").await, + r#"[0,0,""]"#, + "the saved event view must be disabled when the protocol command completes" ); } diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index ed48cc51fd..7941a543a1 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -332,9 +332,10 @@ pub(crate) use self::file_api::{ DirectoryReaderCallbackTaskEffect, FileEntryFileCallbackTask, FileEntryFileCallbackTaskEffect, }; pub(crate) use self::file_api::{ - allowed_drag_drop_effect, apply_drag_modifier_drop_effect, build_clipboard_data_transfer, - build_data_transfer_object, clipboard_data_transfer_contents, disable_clipboard_data_transfer, - initialize_native_drag_data_transfer, prepare_drag_drop_effect, set_drag_drop_effect, + DragDataStore, allowed_drag_drop_effect, apply_drag_modifier_drop_effect, + build_clipboard_data_transfer, clipboard_data_transfer_contents, + disable_clipboard_data_transfer, initialize_native_drag_data_transfer, + prepare_drag_drop_effect, set_drag_drop_effect, }; pub(crate) use self::file_api::{ build_file_list_object, build_file_object, file_list_files_from_object, diff --git a/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs b/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs index 82370dcd6a..dadd947d4a 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs @@ -207,7 +207,7 @@ struct WorkerTemplateMethodsDeclaration { } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransfer, enumerable)] +#[webapi(interface = web_api_interfaces::DataTransfer, enumerable, receiver)] struct DataTransferTemplateMethodsDeclaration { #[webapi( method = "getData", @@ -232,7 +232,7 @@ struct DataTransferTemplateMethodsDeclaration { } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransferItem, enumerable)] +#[webapi(interface = web_api_interfaces::DataTransferItem, enumerable, receiver)] struct DataTransferItemTemplateMethodsDeclaration { #[webapi( method = "getAsFile", @@ -257,7 +257,7 @@ struct DataTransferItemTemplateMethodsDeclaration { } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransferItemList, enumerable)] +#[webapi(interface = web_api_interfaces::DataTransferItemList, enumerable, receiver)] struct DataTransferItemListTemplateMethodsDeclaration { #[webapi( intrinsic_data_property = v8::Intrinsic::ArrayProtoValues, diff --git a/moli-renderer-v8/src/context_bootstrap/file_api.rs b/moli-renderer-v8/src/context_bootstrap/file_api.rs index a84fbc4eb9..800c660521 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api.rs @@ -12,10 +12,10 @@ mod install; pub(super) use data_transfer::data_transfer_constructor_callback; pub(crate) use data_transfer::{ - allowed_drag_drop_effect, apply_drag_modifier_drop_effect, build_clipboard_data_transfer, - build_data_transfer_object, clipboard_data_transfer_contents, disable_clipboard_data_transfer, - initialize_native_drag_data_transfer, is_branded_data_transfer_object, - prepare_drag_drop_effect, set_drag_drop_effect, + DragDataStore, allowed_drag_drop_effect, apply_drag_modifier_drop_effect, + build_clipboard_data_transfer, clipboard_data_transfer_contents, + disable_clipboard_data_transfer, initialize_native_drag_data_transfer, + is_branded_data_transfer_object, prepare_drag_drop_effect, set_drag_drop_effect, }; pub(crate) use data_transfer::{ data_transfer_clear_data_callback, data_transfer_get_data_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs index ced8c9a513..ee712350e3 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer.rs @@ -14,10 +14,12 @@ use moli_file_api::data_transfer::{ use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; mod clipboard; +mod drag_store; pub(crate) use clipboard::{ build_clipboard_data_transfer, clipboard_data_transfer_contents, disable_clipboard_data_transfer, }; +pub(crate) use drag_store::DragDataStore; const DATA_TRANSFER_FILES_SLOT: &str = "__lmDataTransferFiles"; const DATA_TRANSFER_ITEMS_SLOT: &str = "__lmDataTransferItems"; @@ -26,14 +28,18 @@ const DATA_TRANSFER_DROP_EFFECT_SLOT: &str = "__lmDataTransferDropEffect"; const DATA_TRANSFER_EFFECT_ALLOWED_SLOT: &str = "__lmDataTransferEffectAllowed"; const DATA_TRANSFER_MODE_SLOT: &str = "__lmDataTransferMode"; const DATA_TRANSFER_CLIPBOARD_SLOT: &str = "__lmDataTransferClipboard"; +const DATA_TRANSFER_STORE_SLOT: &str = "__lmDataTransferStore"; const DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT: &str = "__lmDataTransferItemArray"; const DATA_TRANSFER_ITEM_LIST_OWNER_SLOT: &str = "__lmDataTransferOwner"; +const DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT: &str = "__lmDataTransferItemViews"; const DATA_TRANSFER_ITEM_LIST_INDEXED_LENGTH_SLOT: &str = "__lmDataTransferItemListIndexedLength"; const DATA_TRANSFER_ITEM_KIND_SLOT: &str = "__lmDataTransferItemKind"; const DATA_TRANSFER_ITEM_TYPE_SLOT: &str = "__lmDataTransferItemType"; const DATA_TRANSFER_ITEM_FILE_SLOT: &str = "__lmDataTransferItemFile"; const DATA_TRANSFER_ITEM_ENTRY_SLOT: &str = "__lmDataTransferItemEntry"; const DATA_TRANSFER_ITEM_STRING_SLOT: &str = "__lmDataTransferItemString"; +const DATA_TRANSFER_ITEM_SOURCE_SLOT: &str = "__lmDataTransferItemSource"; +const DATA_TRANSFER_ITEM_OWNER_SLOT: &str = "__lmDataTransferItemOwner"; const FILE_SYSTEM_ENTRY_FILESYSTEM_SLOT: &str = "__lmFileSystemEntryFilesystem"; const FILE_SYSTEM_ENTRY_FULL_PATH_SLOT: &str = "__lmFileSystemEntryFullPath"; const FILE_SYSTEM_ENTRY_IS_DIRECTORY_SLOT: &str = "__lmFileSystemEntryIsDirectory"; @@ -99,10 +105,18 @@ struct DataTransferShellDeclaration { } #[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] enum DataTransferMode { - ReadWrite, - ReadOnly, - Disabled, + ReadWrite = 0, + ReadOnly = 1, + Disabled = 2, + Protected = 3, +} + +impl DataTransferMode { + fn can_read(self) -> bool { + matches!(self, Self::ReadWrite | Self::ReadOnly) + } } fn data_transfer_mode<'s>( @@ -112,6 +126,7 @@ fn data_transfer_mode<'s>( match private_number_property(scope, object, DATA_TRANSFER_MODE_SLOT) { Some(1.0) => DataTransferMode::ReadOnly, Some(2.0) => DataTransferMode::Disabled, + Some(3.0) => DataTransferMode::Protected, _ => DataTransferMode::ReadWrite, } } @@ -125,7 +140,7 @@ fn item_list_is_writable<'s>( } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransfer)] +#[webapi(interface = web_api_interfaces::DataTransfer, receiver)] struct DataTransferPrototypeAccessorsDeclaration { #[webapi(accessor_property, getter = data_transfer_files_getter, enumerable)] files: (), @@ -159,6 +174,8 @@ struct DataTransferItemListObjectDeclaration<'s> { items: Vec>, #[webapi(slot = DATA_TRANSFER_ITEM_LIST_OWNER_SLOT)] owner: v8::Local<'s, v8::Object>, + #[webapi(slot = DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT)] + views: v8::Local<'s, v8::Map>, #[webapi( slot = DATA_TRANSFER_ITEM_LIST_INDEXED_LENGTH_SLOT, constructor_default = 0.0 @@ -167,7 +184,7 @@ struct DataTransferItemListObjectDeclaration<'s> { } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransferItemList)] +#[webapi(interface = web_api_interfaces::DataTransferItemList, receiver)] struct DataTransferItemListPrototypeAccessorsDeclaration { #[webapi(accessor_property, getter = data_transfer_item_list_length_getter, enumerable)] length: (), @@ -208,8 +225,19 @@ struct DataTransferStringItemObjectDeclaration<'item_type, 'data> { data: &'data str, } +// Item payloads belong to the store; each item list publishes its own stable +// views whose access follows the associated DataTransfer's lifetime and mode. +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::DataTransferItem, require_prototype)] +struct DataTransferItemViewDeclaration<'s> { + #[webapi(slot = DATA_TRANSFER_ITEM_SOURCE_SLOT)] + source: v8::Local<'s, v8::Object>, + #[webapi(slot = DATA_TRANSFER_ITEM_OWNER_SLOT)] + owner: v8::Local<'s, v8::Object>, +} + #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::DataTransferItem)] +#[webapi(interface = web_api_interfaces::DataTransferItem, receiver)] struct DataTransferItemPrototypeAccessorsDeclaration { #[webapi(accessor_property, getter = data_transfer_item_kind_getter, enumerable)] kind: (), @@ -542,6 +570,63 @@ fn item_list_owner<'s>( get_private_object(scope, item_list, DATA_TRANSFER_ITEM_LIST_OWNER_SLOT) } +fn item_views<'s>( + scope: &mut v8::PinScope<'s, '_>, + item_list: v8::Local<'s, v8::Object>, +) -> Option> { + get_private_value(scope, item_list, DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + +fn data_transfer_item_view<'s>( + scope: &mut v8::PinScope<'s, '_>, + item_list: v8::Local<'s, v8::Object>, + source: v8::Local<'s, v8::Object>, +) -> Option> { + let views = item_views(scope, item_list)?; + if let Some(value) = views.get(scope, source.into()) + && let Ok(view) = v8::Local::::try_from(value) + { + return Some(view); + } + let owner = item_list_owner(scope, item_list)?; + let view = DataTransferItemViewDeclaration::new(source, owner) + .bind(scope) + .ok()?; + views.set(scope, source.into(), view.into())?; + Some(view) +} + +fn item_data_object<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, +) -> v8::Local<'s, v8::Object> { + get_private_object(scope, item, DATA_TRANSFER_ITEM_SOURCE_SLOT).unwrap_or(item) +} + +fn item_is_associated<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, +) -> bool { + if let Some(owner) = get_private_object(scope, item, DATA_TRANSFER_ITEM_OWNER_SLOT) { + data_transfer_mode(scope, owner) != DataTransferMode::Disabled + && get_private_object(scope, item, DATA_TRANSFER_ITEM_SOURCE_SLOT).is_some() + } else { + // Canonical records never escape the store; removed records have empty metadata. + private_string_property(scope, item, DATA_TRANSFER_ITEM_KIND_SLOT) + .is_some_and(|kind| !kind.is_empty()) + } +} + +pub(super) fn item_is_readable<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, +) -> bool { + item_is_associated(scope, item) + && get_private_object(scope, item, DATA_TRANSFER_ITEM_OWNER_SLOT) + .is_none_or(|owner| data_transfer_mode(scope, owner).can_read()) +} + // Every write to the canonical item array runs inside DataTransferItemStore::mutate. The // outcome controls the single observer notification and, consequently, the cached `types` // identity required by Web IDL's CachedAttribute semantics. @@ -581,6 +666,21 @@ impl<'s> DataTransferItemStore<'s> { DataTransferItemListMutation::Changed(value) => (value, true), }; if changed { + // Native event views share the backing item array. Array replacements + // must also reach the session before another event opens its view. + if let Some(owner) = item_list_owner(scope, self.item_list) + && let Some(backing) = get_private_object(scope, owner, DATA_TRANSFER_STORE_SLOT) + && let Some(backing_items) = Self::for_owner(scope, backing) + && let Some(array) = item_list_array(scope, self.item_list) + { + set_private_value( + scope, + backing_items.item_list, + DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT, + array.into(), + ); + data_transfer_item_list_did_change(scope, backing_items.item_list); + } data_transfer_item_list_did_change(scope, self.item_list); } Some(value) @@ -623,14 +723,16 @@ impl<'s> DataTransferItemStore<'s> { scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option> { - self.mutate(scope, |scope, item_array| { - if append_item(scope, item_array, item).is_some() { - DataTransferItemListMutation::Changed(Some(item)) - } else { - DataTransferItemListMutation::Unchanged(None) - } - }) - .flatten() + let added = self + .mutate(scope, |scope, item_array| { + if append_item(scope, item_array, item).is_some() { + DataTransferItemListMutation::Changed(Some(item)) + } else { + DataTransferItemListMutation::Unchanged(None) + } + }) + .flatten()?; + data_transfer_item_view(scope, self.item_list, added) } fn set_string_data(self, scope: &mut v8::PinScope<'s, '_>, mime_type: &str, data: &str) { @@ -751,35 +853,46 @@ pub(super) fn item_kind<'s>( scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option { - private_string_property(scope, item, DATA_TRANSFER_ITEM_KIND_SLOT) + if !item_is_associated(scope, item) { + return Some(String::new()); + } + let source = item_data_object(scope, item); + private_string_property(scope, source, DATA_TRANSFER_ITEM_KIND_SLOT) } fn item_type<'s>( scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option { - private_string_property(scope, item, DATA_TRANSFER_ITEM_TYPE_SLOT) + if !item_is_associated(scope, item) { + return Some(String::new()); + } + let source = item_data_object(scope, item); + private_string_property(scope, source, DATA_TRANSFER_ITEM_TYPE_SLOT) } pub(super) fn item_string_value<'s>( scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option { - private_string_property(scope, item, DATA_TRANSFER_ITEM_STRING_SLOT) + let source = item_data_object(scope, item); + private_string_property(scope, source, DATA_TRANSFER_ITEM_STRING_SLOT) } fn item_file_object<'s>( scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option> { - get_private_object(scope, item, DATA_TRANSFER_ITEM_FILE_SLOT) + let source = item_data_object(scope, item); + get_private_object(scope, source, DATA_TRANSFER_ITEM_FILE_SLOT) } fn item_entry_object<'s>( scope: &mut v8::PinScope<'s, '_>, item: v8::Local<'s, v8::Object>, ) -> Option> { - get_private_object(scope, item, DATA_TRANSFER_ITEM_ENTRY_SLOT) + let source = item_data_object(scope, item); + get_private_object(scope, source, DATA_TRANSFER_ITEM_ENTRY_SLOT) } fn item_summary<'s>( @@ -882,7 +995,8 @@ fn build_data_transfer_item_list_object<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, ) -> Option> { - DataTransferItemListObjectDeclaration::new(owner) + let views = v8::Map::new(scope); + DataTransferItemListObjectDeclaration::new(owner, views) .bind(scope) .ok() } @@ -1078,6 +1192,39 @@ fn data_transfer_item_list_did_change<'s>( DATA_TRANSFER_TYPES_SLOT, v8::undefined(scope).into(), ); + let mode = data_transfer_mode(scope, owner); + // Removed items and retired event views must not retain payload records in + // the identity cache. Disassociation never clears the shared store itself. + if let Some(views) = item_views(scope, item_list) { + let entries = views.as_array(scope); + let null = v8::null(scope); + for index in (0..entries.length()).step_by(2) { + let Some(key) = entries.get_index(scope, index) else { + continue; + }; + let Ok(source) = v8::Local::::try_from(key) else { + continue; + }; + if mode != DataTransferMode::Disabled && item_is_associated(scope, source) { + continue; + } + if let Some(value) = entries.get_index(scope, index + 1) + && let Ok(view) = v8::Local::::try_from(value) + { + set_private_value(scope, view, DATA_TRANSFER_ITEM_SOURCE_SLOT, null.into()); + } + let _ = views.delete(scope, key); + } + } + if mode == DataTransferMode::Disabled { + let empty = v8::Array::new(scope, 0); + set_private_value( + scope, + item_list, + DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT, + empty.into(), + ); + } let Some(item_array) = item_list_array(scope, item_list) else { return; }; @@ -1099,8 +1246,11 @@ fn data_transfer_item_list_did_change<'s>( let Ok(item) = v8::Local::::try_from(value) else { continue; }; - let _ = item_list.set_index(scope, index, item.into()); - if item_kind(scope, item).as_deref() == Some("file") + if let Some(view) = data_transfer_item_view(scope, item_list, item) { + let _ = item_list.set_index(scope, index, view.into()); + } + if mode.can_read() + && item_kind(scope, item).as_deref() == Some("file") && let Some(file) = item_file_object(scope, item) { files.push(file); @@ -1198,8 +1348,7 @@ fn data_transfer_drop_effect_setter<'s>( return; } }; - if data_transfer_mode(scope, args.this()) != DataTransferMode::Disabled - && !private_bool_property(scope, args.this(), DATA_TRANSFER_CLIPBOARD_SLOT).unwrap_or(false) + if !private_bool_property(scope, args.this(), DATA_TRANSFER_CLIPBOARD_SLOT).unwrap_or(false) && valid_drop_effect(&value) { set_private_string(scope, args.this(), DATA_TRANSFER_DROP_EFFECT_SLOT, &value); @@ -1251,7 +1400,7 @@ fn data_transfer_item_kind_getter<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let value = private_string_property(scope, args.this(), DATA_TRANSFER_ITEM_KIND_SLOT) + let value = item_kind(scope, args.this()) .and_then(|value| v8_string(scope, &value).map(Into::into)) .unwrap_or_else(|| v8str(scope, "").into()); rv.set(value); @@ -1262,7 +1411,7 @@ fn data_transfer_item_type_getter<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let value = private_string_property(scope, args.this(), DATA_TRANSFER_ITEM_TYPE_SLOT) + let value = item_type(scope, args.this()) .and_then(|value| v8_string(scope, &value).map(Into::into)) .unwrap_or_else(|| v8str(scope, "").into()); rv.set(value); @@ -1340,6 +1489,10 @@ pub(crate) fn data_transfer_get_data_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; + if !data_transfer_mode(scope, args.this()).can_read() { + rv.set(v8str(scope, "").into()); + return; + } let normalized_type = normalize_drag_data_type(&parsed.format); let Some(item_list) = get_private_object(scope, args.this(), DATA_TRANSFER_ITEMS_SLOT) else { rv.set(v8str(scope, "").into()); @@ -1532,7 +1685,9 @@ pub(crate) fn data_transfer_item_get_as_file_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - if let Some(file) = item_file_object(scope, args.this()) { + if item_is_readable(scope, args.this()) + && let Some(file) = item_file_object(scope, args.this()) + { rv.set(file.into()); } else { rv.set(v8::null(scope).into()); @@ -1544,6 +1699,10 @@ pub(crate) fn data_transfer_item_webkit_get_as_entry_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { + if !item_is_readable(scope, args.this()) { + rv.set(v8::null(scope).into()); + return; + } if let Some(entry) = item_entry_object(scope, args.this()) { rv.set(entry.into()); return; diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer/drag_store.rs b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer/drag_store.rs new file mode 100644 index 0000000000..b8eb88dec2 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer/drag_store.rs @@ -0,0 +1,98 @@ +use super::*; + +/// The session's backing transfer is never exposed to script. Native events +/// publish separate DataTransfer/item views with event-local access rights. +pub(crate) struct DragDataStore { + backing: v8::Global, +} + +impl DragDataStore { + pub(crate) fn new(scope: &mut v8::PinScope<'_, '_>, data: &RendererDragData) -> Option { + let backing = build_data_transfer_object(scope, data)?; + Some(Self { + backing: v8::Global::new(scope, backing), + }) + } + + pub(crate) fn backing_transfer<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + ) -> v8::Local<'s, v8::Object> { + v8::Local::new(scope, &self.backing) + } + + pub(crate) fn open_event<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + event_name: &str, + ) -> Option> { + let backing = self.backing_transfer(scope); + let transfer = DataTransferShellDeclaration::default().bind(scope).ok()?; + let items = initialize_data_transfer_object(scope, transfer)?; + let backing_items = DataTransferItemStore::for_owner(scope, backing)?; + let array = item_list_array(scope, backing_items.item_list)?; + set_private_value( + scope, + items, + DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT, + array.into(), + ); + set_private_value(scope, transfer, DATA_TRANSFER_STORE_SLOT, backing.into()); + let mode = match event_name { + "dragstart" => DataTransferMode::ReadWrite, + "drop" => DataTransferMode::ReadOnly, + _ => DataTransferMode::Protected, + }; + set_private_number( + scope, + transfer, + DATA_TRANSFER_MODE_SLOT, + f64::from(mode as u8), + ); + for slot in [ + DATA_TRANSFER_DROP_EFFECT_SLOT, + DATA_TRANSFER_EFFECT_ALLOWED_SLOT, + ] { + if let Some(value) = get_private_value(scope, backing, slot) { + set_private_value(scope, transfer, slot, value); + } + } + data_transfer_item_list_did_change(scope, items); + Some(DragDataTransferEvent { backing, transfer }) + } +} + +pub(crate) struct DragDataTransferEvent<'s> { + backing: v8::Local<'s, v8::Object>, + transfer: v8::Local<'s, v8::Object>, +} + +impl<'s> DragDataTransferEvent<'s> { + pub(crate) fn object(&self) -> v8::Local<'s, v8::Object> { + self.transfer + } + + /// Preserve the negotiated effects before invalidating every live view. + /// Payloads stay with the backing store for later events and UA defaults. + pub(crate) fn finish(self, scope: &mut v8::PinScope<'s, '_>) { + for slot in [ + DATA_TRANSFER_DROP_EFFECT_SLOT, + DATA_TRANSFER_EFFECT_ALLOWED_SLOT, + ] { + if let Some(value) = get_private_value(scope, self.transfer, slot) { + set_private_value(scope, self.backing, slot, value); + } + } + set_private_number( + scope, + self.transfer, + DATA_TRANSFER_MODE_SLOT, + f64::from(DataTransferMode::Disabled as u8), + ); + let null = v8::null(scope); + set_private_value(scope, self.transfer, DATA_TRANSFER_STORE_SLOT, null.into()); + if let Some(items) = DataTransferItemStore::for_owner(scope, self.transfer) { + data_transfer_item_list_did_change(scope, items.item_list); + } + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer_string.rs b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer_string.rs index 5f29c14cc4..a4d293ee77 100644 --- a/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer_string.rs +++ b/moli-renderer-v8/src/context_bootstrap/file_api/data_transfer_string.rs @@ -20,7 +20,7 @@ use crate::{ }; use moli_webidl_callback::WebIdlCallbackFunction; -use super::data_transfer::{item_kind, item_string_value}; +use super::data_transfer::{item_is_readable, item_kind, item_string_value}; #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "DataTransferItem.getAsString")] @@ -112,7 +112,9 @@ pub(crate) fn data_transfer_item_get_as_string_callback<'s>( let Some(callback) = parsed.callback else { return; }; - if item_kind(scope, args.this()).as_deref() != Some("string") { + if !item_is_readable(scope, args.this()) + || item_kind(scope, args.this()).as_deref() != Some("string") + { return; } let Some(value) = item_string_value(scope, args.this()) else { diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 9b3ccf6ac6..f44d07154d 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -285,8 +285,8 @@ pub(crate) use events::construct_focus_event; pub(crate) use events::{ NodePublicEventDispatchOutcome, TextEditInputType, TouchEventPoint, construct_activation_pointer_event, construct_clipboard_event, construct_command_event, - construct_drag_event, construct_drag_event_with_related_target, construct_form_data_event, - construct_input_event, construct_interest_event, construct_keyboard_event, + construct_drag_event_with_related_target, construct_form_data_event, construct_input_event, + construct_interest_event, construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, diff --git a/moli-renderer-v8/src/native_bridge/element/events.rs b/moli-renderer-v8/src/native_bridge/element/events.rs index 7fb351700d..5c6d6c2d68 100644 --- a/moli-renderer-v8/src/native_bridge/element/events.rs +++ b/moli-renderer-v8/src/native_bridge/element/events.rs @@ -12,12 +12,12 @@ pub(super) use self::constructors::construct_click_event; pub(crate) use self::constructors::construct_focus_event; pub(crate) use self::constructors::{ TextEditInputType, TouchEventPoint, construct_activation_pointer_event, - construct_clipboard_event, construct_command_event, construct_drag_event, - construct_drag_event_with_related_target, construct_form_data_event, construct_input_event, - construct_interest_event, construct_keyboard_event, - construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, - construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, - construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, + construct_clipboard_event, construct_command_event, construct_drag_event_with_related_target, + construct_form_data_event, construct_input_event, construct_interest_event, + construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, + construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, + construct_pointer_event, construct_pointer_event_with_modifiers, + construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_simple_event_for_target, construct_submit_event, construct_toggle_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, diff --git a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs index 2adf6d95a3..292623266c 100644 --- a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs +++ b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs @@ -698,27 +698,6 @@ pub(crate) fn construct_pointer_event_with_related_target_and_modifiers<'s>( construct_event(scope, "PointerEvent", event_type, init) } -pub(crate) fn construct_drag_event<'s>( - scope: &mut v8::PinScope<'s, '_>, - event_type: &str, - x: f64, - y: f64, - buttons: i32, - data_transfer: v8::Local<'s, v8::Value>, - modifiers: u8, -) -> Option> { - construct_drag_event_with_related_target( - scope, - event_type, - x, - y, - buttons, - data_transfer, - modifiers, - None, - ) -} - pub(crate) fn construct_drag_event_with_related_target<'s>( scope: &mut v8::PinScope<'s, '_>, event_type: &str, diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 284a15cae1..289a5f44e6 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -1185,7 +1185,7 @@ pub(super) struct ActiveTouchPoint { } pub(super) struct ActiveDragSession { - pub data_transfer: v8::Global, + pub data_store: crate::context_bootstrap::DragDataStore, pub drop_allowed: bool, native: Option, target: Option, diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 6d86dc4fe6..1e3aeadf7c 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -22,10 +22,10 @@ use crate::native_bridge::element::{ activate_handle_after_pointer_release, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files, captured_frame_input_surface_hit_test, construct_activation_pointer_event, - construct_drag_event, construct_keyboard_event, - construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, - construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, - construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, + construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, + construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, + construct_pointer_event, construct_pointer_event_with_modifiers, + construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, contenteditable_editing_host, dispatch_public_event, drag_start_frame_input_surface_hit_test, @@ -1220,26 +1220,24 @@ impl ScriptVm { directories: Vec::new(), drag_operations_mask: 1, }; - if let Some(data_transfer) = - crate::context_bootstrap::build_data_transfer_object(scope, &empty_drag_data) + if let Some(data_store) = + crate::context_bootstrap::DragDataStore::new(scope, &empty_drag_data) { - crate::context_bootstrap::initialize_native_drag_data_transfer( - scope, - data_transfer, - ); - if let Some(event) = construct_drag_event( + let backing = data_store.backing_transfer(scope); + crate::context_bootstrap::initialize_native_drag_data_transfer(scope, backing); + if super::input_drag::fire_drag_event( scope, + runtime_ptr, + drag_start_handle, "dragstart", - f64::from(drag_position.x), - f64::from(drag_position.y), + drag_position, buttons, - data_transfer.into(), modifiers, - ) && dispatch_public_event(scope, runtime_ptr, drag_start_handle, event) - .allows_default() - { + &data_store, + None, + ) { *active_drag_session = Some(ActiveDragSession { - data_transfer: v8::Global::new(scope, data_transfer), + data_store, drop_allowed: false, native: Some(super::input_drag::NativeDragSource { handle: drag_start_handle, @@ -2009,16 +2007,19 @@ impl ScriptVm { data: RendererDragData, modifiers: u8, ) -> Result { - let Some(handle) = observable_input_hit_test( + let position = moli_layout::LayoutPoint::new(x as f32, y as f32); + let Some(hit) = observable_input_hit_test( &self._context_host.borrow(), self.document_runtime.document_handle(), - moli_layout::LayoutPoint::new(x as f32, y as f32), + position, )? - .map(|hit| hit.handle) else { + else { return Ok(input_dispatch_outcome(false)); }; + let handle = hit.handle; - let incoming_drag_data_empty = data.items.is_empty() && data.files.is_empty(); + let incoming_drag_data_empty = + data.items.is_empty() && data.files.is_empty() && data.directories.is_empty(); let replace_active_drag_data_transfer = event_name == "dragenter" && (!incoming_drag_data_empty || self.active_drag_session.is_none()); let clear_active_drag_data_transfer = event_name == "drop"; @@ -2030,48 +2031,38 @@ impl ScriptVm { // remains exclusively borrowed, so the field pointer stays valid for the // duration of this callback and is not aliased elsewhere. let active_drag_session = unsafe { &mut *active_drag_session }; - let data_transfer = - if replace_active_drag_data_transfer || active_drag_session.is_none() { - let Some(data_transfer) = - crate::context_bootstrap::build_data_transfer_object(scope, &data) - else { - return Ok(input_dispatch_outcome(false)); - }; - *active_drag_session = Some(ActiveDragSession { - data_transfer: v8::Global::new(scope, data_transfer), - drop_allowed: false, - native: None, - target: None, - }); - data_transfer - } else { - v8::Local::new( - scope, - &active_drag_session - .as_ref() - .expect("active drag session should exist when reusing DataTransfer") - .data_transfer, - ) + if replace_active_drag_data_transfer || active_drag_session.is_none() { + let Some(data_store) = crate::context_bootstrap::DragDataStore::new(scope, &data) + else { + return Ok(input_dispatch_outcome(false)); }; + *active_drag_session = Some(ActiveDragSession { + data_store, + drop_allowed: false, + native: None, + target: None, + }); + } + let session = active_drag_session + .as_ref() + .expect("active drag session exists"); + let data_transfer = session.data_store.backing_transfer(scope); crate::context_bootstrap::apply_drag_modifier_drop_effect( scope, data_transfer, modifiers, ); - let data_transfer_value: v8::Local<'_, v8::Value> = data_transfer.into(); - let allows_default = if let Some(event) = construct_drag_event( + let allows_default = super::input_drag::fire_drag_event( scope, + runtime_ptr, + handle, event_name, - x, - y, + hit.root_to_frame.map_point(position), buttons, - data_transfer_value, modifiers, - ) { - dispatch_public_event(scope, runtime_ptr, handle, event).allows_default() - } else { - false - }; + &session.data_store, + None, + ); if event_name == "dragover" && let Some(session) = active_drag_session.as_mut() { diff --git a/moli-renderer-v8/src/script_vm/input_drag.rs b/moli-renderer-v8/src/script_vm/input_drag.rs index 10098dc41c..b534dcbcce 100644 --- a/moli-renderer-v8/src/script_vm/input_drag.rs +++ b/moli-renderer-v8/src/script_vm/input_drag.rs @@ -6,7 +6,7 @@ use super::input_dispatch::{ }; use super::{ActiveDragSession, ScriptVm, input_dispatch_outcome}; use crate::context_bootstrap::{ - allowed_drag_drop_effect, prepare_drag_drop_effect, set_drag_drop_effect, + DragDataStore, allowed_drag_drop_effect, prepare_drag_drop_effect, set_drag_drop_effect, }; use crate::document_runtime::DomHandle; use crate::native_bridge::JsContextHost; @@ -77,27 +77,67 @@ fn dispatch_drag( position: LayoutPoint, buttons: i32, modifiers: u8, - transfer: &v8::Global, + store: &DragDataStore, related: Option, ) -> bool { - let transfer = v8::Local::new(scope, transfer); + let point = target.root_to_frame.map_point(position); + fire_drag_event( + scope, + runtime, + target.handle, + event_name, + point, + buttons, + modifiers, + store, + related, + ) +} + +/// A native drag event owns one temporary view of the session's data store. +/// Construct it in the target Document's realm, then retire it after dispatch. +pub(super) fn fire_drag_event( + scope: &mut v8::PinScope<'_, '_>, + runtime: *mut JsContextHost, + target: DomHandle, + event_name: &str, + position: LayoutPoint, + buttons: i32, + modifiers: u8, + store: &DragDataStore, + related: Option, +) -> bool { + let host = unsafe { &mut *runtime }; + let Some(context) = host + .owner_dispatch_scope_for_node(target) + .and_then(|target| { + let owner = host.current_window_execution_context_owner(target)?; + host.window_execution_context(scope, owner, target) + .map(|(_, context)| context) + }) + else { + return false; + }; + let scope = &mut v8::ContextScope::new(scope, context); + let Some(view) = store.open_event(scope, event_name) else { + return false; + }; let related = related .and_then(|handle| node_wrapper_from_handle(scope, handle)) .map(Into::into); - let point = target.root_to_frame.map_point(position); - construct_drag_event_with_related_target( + let allows_default = construct_drag_event_with_related_target( scope, event_name, - f64::from(point.x), - f64::from(point.y), + f64::from(position.x), + f64::from(position.y), buttons, - transfer.into(), + view.object().into(), modifiers, related, ) - .is_some_and(|event| { - dispatch_public_event(scope, runtime, target.handle, event).allows_default() - }) + .is_some_and(|event| dispatch_public_event(scope, runtime, target, event).allows_default()); + view.finish(scope); + allows_default } fn end_native_drag( @@ -112,7 +152,7 @@ fn end_native_drag( let Some(source) = session.native else { return; }; - let transfer = v8::Local::new(scope, &session.data_transfer); + let transfer = session.data_store.backing_transfer(scope); let mut effect = if allow_drop && session.drop_allowed { allowed_drag_drop_effect(scope, transfer) } else { @@ -129,7 +169,7 @@ fn end_native_drag( position, buttons, modifiers, - &session.data_transfer, + &session.data_store, None, ); if allows_default && !perform_drop_default_action(scope, runtime, target.handle, transfer) { @@ -147,7 +187,7 @@ fn end_native_drag( position, buttons, modifiers, - &session.data_transfer, + &session.data_store, None, ); } @@ -163,7 +203,7 @@ fn end_native_drag( position, 0, modifiers, - &session.data_transfer, + &session.data_store, None, ); } @@ -224,7 +264,7 @@ impl ScriptVm { ); finished = true; } else if event_name == "mousemove" { - let transfer = v8::Local::new(scope, &session.data_transfer); + let transfer = session.data_store.backing_transfer(scope); set_drag_drop_effect(scope, transfer, "none"); if !dispatch_drag( scope, @@ -237,7 +277,7 @@ impl ScriptVm { position, buttons, modifiers, - &session.data_transfer, + &session.data_store, None, ) { end_native_drag(scope, runtime, &session, position, 0, modifiers, false); @@ -257,7 +297,7 @@ impl ScriptVm { position, buttons, modifiers, - &session.data_transfer, + &session.data_store, previous.map(|h| h.handle), ); } @@ -271,7 +311,7 @@ impl ScriptVm { position, buttons, modifiers, - &session.data_transfer, + &session.data_store, hit.map(|h| h.handle), ); } @@ -287,7 +327,7 @@ impl ScriptVm { position, buttons, modifiers, - &session.data_transfer, + &session.data_store, None, ) && allowed_drag_drop_effect(scope, transfer) != "none" } else { diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.js b/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.js new file mode 100644 index 0000000000..05caa6b58e --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.js @@ -0,0 +1,123 @@ +globalThis.__installDragDataModeProbe = function(mode, capture, modifiers) { + document.body.innerHTML = '
source
A
B
'; + const source = document.getElementById('source'); + const file = new File(['private-file-contents'], 'private.txt', {type:'text/plain'}); + const rows = [], transfers = [], callbacks = [], microtasks = [], trace = []; + const expectedTypes = ['text/plain', 'text/html', 'application/test', 'Files']; + const checks = []; + const check = (name, actual, expected) => checks.push({name, actual, expected, pass:JSON.stringify(actual)===JSON.stringify(expected)}); + const exceptionName = action => { try { action(); return 'none'; } catch (error) { return error.name; } }; + let starts = 0, drops = 0; + function retain(dt, event) { + const row = {dt, event, items:dt.items, files:dt.files, types:dt.types, string:dt.items[0], file:dt.items[3]}; + transfers.push(row); + return row; + } + function disabled(row, label) { + const prefix = label + ':' + row.event + ':' + transfers.indexOf(row); + const dt = row.dt; + check(prefix+':empty getData', dt.getData('text/plain'), ''); + check(prefix+':empty types', Array.from(dt.types), []); + check(prefix+':new frozen types snapshot', dt.types !== row.types && Object.isFrozen(dt.types), true); + check(prefix+':saved types unchanged', Array.from(row.types), expectedTypes); + check(prefix+':items same object', dt.items === row.items, true); + check(prefix+':items empty', row.items.length, 0); + check(prefix+':indexed item absent', row.items[0] === undefined, true); + check(prefix+':files same object', dt.files === row.files, true); + check(prefix+':saved files empty', row.files.length, 0); + if (row.string) { + check(prefix+':saved string metadata disabled', [row.string.kind,row.string.type], ['','']); + row.string.getAsString(value => callbacks.push({phase:'disabled',value})); + } + if (row.file) { + check(prefix+':saved file metadata disabled', [row.file.kind,row.file.type], ['','']); + check(prefix+':saved file unavailable', row.file.getAsFile(), null); + check(prefix+':saved entry unavailable', row.file.webkitGetAsEntry(), null); + } + check(prefix+':add blocked', row.items.add('no','application/blocked'), null); + check(prefix+':remove disabled throws', exceptionName(()=>row.items.remove(999)), 'InvalidStateError'); + const allowed = dt.effectAllowed; + dt.effectAllowed = 'none'; + check(prefix+':effectAllowed immutable', dt.effectAllowed, allowed); + dt.setData('text/plain','escaped'); + dt.clearData(); + row.items.clear(); + check(prefix+':store cannot be repopulated', [dt.getData('text/plain'),row.items.length,dt.types.length], ['',0,0]); + } + for (const type of ['dragstart','drag','dragenter','dragover','dragleave','drop','dragend']) { + document.addEventListener(type, event => { + const dt = event.dataTransfer; + const prefix = type+':'+rows.length; + check(prefix+':fresh event DataTransfer', transfers.every(row=>row.dt!==dt), true); + for (const row of transfers) disabled(row, prefix+':previous'); + if (type==='dragstart') { + starts++; + dt.setData('text/plain','payload'); + dt.setData('text/html','payload'); + dt.items.add('extra','application/test'); + const added = dt.items.add(file); + check(prefix+':added file is indexed view', added===dt.items[3], true); + dt.effectAllowed='all'; + } + const row = retain(dt,type); + check(prefix+':same items object', dt.items===dt.items, true); + check(prefix+':same files object', dt.files===dt.files, true); + check(prefix+':stable frozen types', dt.types===row.types && Object.isFrozen(row.types), true); + check(prefix+':metadata enumerable', Array.from(dt.types), expectedTypes); + check(prefix+':indexed metadata', Array.from(dt.items, item=>[item.kind,item.type]), [['string','text/plain'],['string','text/html'],['string','application/test'],['file','text/plain']]); + const readable = type==='dragstart' || type==='drop'; + check(prefix+':payload read mode', dt.getData('text/plain'), readable?'payload':''); + check(prefix+':file list read mode', dt.files.length, readable?1:0); + if (row.file) { + check(prefix+':file item read mode', readable?row.file.getAsFile()===file:row.file.getAsFile()===null, true); + check(prefix+':entry read mode', !!row.file.webkitGetAsEntry(), readable); + } + if (row.string) { + row.string.getAsString(value=>callbacks.push({phase:type,value})); + check(prefix+':callback conversion before mode', exceptionName(()=>row.string.getAsString({})), 'TypeError'); + } + let conversions=0; + check(prefix+':format conversion still runs', dt.getData({toString(){conversions++;return 'text/plain'}}), readable?'payload':''); + check(prefix+':format conversion count', conversions, 1); + const marker={}; + let caught; + try { dt.getData({toString(){throw marker;}}); } catch (error) {caught=error;} + check(prefix+':format conversion exception propagates', caught===marker, true); + Promise.resolve().then(()=>microtasks.push({phase:type,data:dt.getData('text/plain'),items:dt.items.length})); + if (type!=='dragstart') { + const before=Array.from(dt.types), allowed=dt.effectAllowed; + dt.setData('text/plain','tampered'); + dt.clearData('text/html'); + check(prefix+':string add blocked', dt.items.add('no','application/blocked'), null); + check(prefix+':file add blocked', dt.items.add(file), null); + check(prefix+':remove readonly/protected throws', exceptionName(()=>dt.items.remove(999)), 'InvalidStateError'); + dt.items.clear(); + dt.effectAllowed='none'; + check(prefix+':readonly/protected mutations ignored', Array.from(dt.types), before); + check(prefix+':effectAllowed cannot change', dt.effectAllowed, allowed); + check(prefix+':data remains unmodified', dt.getData('text/plain'), readable?'payload':''); + } + if (type==='dragenter' || type==='dragover') { + dt.dropEffect='copy'; + check(prefix+':dropEffect writable', dt.dropEffect, 'copy'); + if (mode==='drop') event.preventDefault(); + } + if (type==='drop') {drops++;event.preventDefault();} + if (type==='dragstart' && mode==='cancel') event.preventDefault(); + rows.push({entryAvailable:!!row.file?.webkitGetAsEntry(),type,data:dt.getData('text/plain'),items:dt.items.length,files:dt.files.length,types:Array.from(dt.types),dropEffect:dt.dropEffect,effectAllowed:dt.effectAllowed}); + trace.push(type); + }); + } + if (capture) source.addEventListener('pointerdown', event=>source.setPointerCapture(event.pointerId)); + return { + afterDispatch(label) {for(const row of transfers) disabled(row,label);}, + finish() { + for(const row of transfers) disabled(row,'finish'); + check('one native dragstart', starts, 1); + check('drop iff accepted', drops, mode==='drop'?1:0); + check('only readable events schedule string callbacks', callbacks.filter(row=>!['dragstart','drop'].includes(row.phase)), []); + check('captured callback payload survives disassociation', callbacks.filter(row=>['dragstart','drop'].includes(row.phase)).map(row=>row.value), mode==='drop'?['payload','payload']:['payload']); + return {mode,capture,modifiers,checks,rows,callbacks,microtasks,trace,passed:checks.filter(c=>c.pass).length,total:checks.length,complete:checks.every(c=>c.pass)}; + } + }; +}; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.rs new file mode 100644 index 0000000000..d754bd16f8 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/drag_data_modes.rs @@ -0,0 +1,234 @@ +use super::*; + +fn mode_input(vm: &mut ScriptVm, pointer_type: &str, input: (&str, f64, i32, u8)) { + let (event, x, buttons, modifiers) = input; + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + x, + 80.0, + event, + if event == "mousemove" { -1 } else { 0 }, + Some(buttons), + i32::from(event != "mousemove"), + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties { + pointer_id: 17, + pointer_type: pointer_type.to_owned(), + pressure: if buttons == 0 { 0.0 } else { 0.5 }, + ..Default::default() + }, + modifiers, + ) + .unwrap(); +} + +#[tokio::test] +async fn native_drag_data_modes_invalidate_views_and_preserve_queued_strings() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + for pointer in ["mouse", "pen"] { + for capture in [false, true] { + for mode in ["cancel", "drop", "reject", "escape"] { + for modifiers in [0, 8, 15] { + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://drag-data-modes.test/", + &loader, + ); + vm.eval("if (!document.documentElement) document.appendChild(document.createElement('html')); if (!document.body) document.documentElement.appendChild(document.createElement('body')); 'ready'").unwrap(); + vm.eval(&format!("{}; globalThis.__dragResults=__installDragDataModeProbe('{mode}',{capture},{modifiers}); 'ready'", include_str!("drag_data_modes.js"))).unwrap(); + vm.publish_layout_for_test().unwrap(); + for input in [ + ("mousemove", 80.0, 0, modifiers), + ("mousedown", 80.0, 1, modifiers), + ("mousemove", 100.0, 1, modifiers), + ] { + mode_input(&mut vm, pointer, input); + } + if mode == "escape" { + vm.dispatch_key_event("keydown", "Escape", "Escape", "", 0, false, false) + .unwrap(); + } else { + for x in [320.0, 520.0] { + mode_input(&mut vm, pointer, ("mousemove", x, 1, modifiers ^ 8)); + } + } + vm.eval("__dragResults.afterDispatch('before-release'); 'ready'") + .unwrap(); + mode_input( + &mut vm, + pointer, + ( + "mouseup", + if mode == "escape" { 100.0 } else { 520.0 }, + 0, + modifiers ^ 8, + ), + ); + vm.eval("__dragResults.afterDispatch('after-release'); 'ready'") + .unwrap(); + let mut tasks = 0; + while vm + .run_one_user_interaction_executor_turn(&loader) + .await + .unwrap() + { + tasks += 1; + assert!( + tasks <= 2, + "protected/disabled items must not schedule callbacks" + ); + } + assert_eq!(tasks, if mode == "drop" { 2 } else { 1 }); + assert_eq!(vm.eval("globalThis.__dragFinished=__dragResults.finish(); __dragFinished.complete").unwrap(), "true", "{pointer}, {mode}, capture {capture}, modifiers {modifiers}: {}", vm.eval("JSON.stringify(__dragFinished.checks.filter(c=>!c.pass))").unwrap()); + assert_eq!(vm.eval("__dragFinished.microtasks.length===__dragFinished.rows.length && __dragFinished.microtasks.every(row=>row.items===4 && row.data===(['dragstart','drop'].includes(row.phase)?'payload':''))").unwrap(), "true", "listener cleanup must run while the event view is still associated"); + } + } + } + } +} + +#[test] +fn constructed_transfer_remains_writable_after_synthetic_event_dispatch() { + let mut vm = new_storage_test_vm("https://synthetic-transfer.test/"); + assert_eq!( + vm.eval( + r#" + globalThis.dt=new DataTransfer(); const item=dt.items.add('before','text/plain'); + document.dispatchEvent(new DragEvent('drop',{dataTransfer:dt})); + dt.setData('text/plain','after'); + JSON.stringify([dt.getData('text/plain'),dt.items[0]===item,item.kind,dt.items.length]) + "# + ) + .unwrap(), + r#"["after",true,"string",1]"# + ); +} + +#[test] +fn transfer_receiver_validation_precedes_argument_conversion() { + let mut vm = new_storage_test_vm("https://transfer-receiver.test/"); + assert_eq!(vm.eval(r#" + const dt=new DataTransfer(); dt.setData('text/plain','value'); + const classes=[DataTransfer,DataTransferItemList,DataTransferItem]; + const objects=[dt,dt.items,dt.items[0]]; + let conversions=0; const format={toString(){conversions++; return 'text/plain';}}; + const failures=[]; + for(let i=0;iproto.getData.call(receiver,format):i===1?()=>proto.remove.call(receiver,{valueOf(){conversions++;return 0;}}):()=>proto.getAsString.call(receiver,{}); + try {method(); failures.push(false);} catch(e) {failures.push(e instanceof TypeError);} + } + } + JSON.stringify([failures.every(Boolean),conversions]) + "#).unwrap(), "[true,0]"); +} + +#[test] +fn external_drag_views_hide_payload_until_drop_and_keep_text_default_action() { + use crate::runtime::{RendererDragData, RendererDragDataItem}; + let mut vm = new_rendered_test_vm( + "https://external-drag.test/", + "", + ); + vm.eval(r#" + globalThis.transfers=[]; globalThis.observations=[]; + for (const type of ['dragenter','dragover','drop']) target.addEventListener(type,event=>{ + const dt=event.dataTransfer; + observations.push([type,dt.getData('text/plain'),dt.items.length,Array.from(dt.types)]); + dt.setData('text/plain','tampered'); dt.clearData(); + observations.push([dt.getData('text/plain'),transfers.every(old=>old.items.length===0 && old.getData('text/plain')==='')]); + transfers.push(dt); + }); 'ready' + "#).unwrap(); + let data = RendererDragData { + items: vec![RendererDragDataItem { + mime_type: "text/plain".to_owned(), + data: "external".to_owned(), + title: None, + base_url: None, + }], + files: vec![], + directories: vec![], + drag_operations_mask: 1, + }; + for event in ["dragenter", "dragover", "drop"] { + vm.dispatch_drag_event_at_point(10.0, 10.0, event, data.clone(), 0) + .unwrap(); + } + assert_eq!(vm.eval("JSON.stringify([observations,target.value,transfers.every(dt=>dt.items.length===0 && dt.types.length===0 && dt.getData('text/plain')==='')])").unwrap(), r#"[[["dragenter","",1,["text/plain"]],["",true],["dragover","",1,["text/plain"]],["",true],["drop","external",1,["text/plain"]],["external",true]],"external",true]"#); +} + +#[test] +fn external_file_drop_uses_backing_store_after_event_view_is_disabled() { + use crate::runtime::{RendererDragData, RendererDraggedFile}; + let mut vm = new_rendered_test_vm( + "https://external-file-drag.test/", + "", + ); + vm.eval(r#" + globalThis.saved=null;globalThis.payload=null;globalThis.events=[]; + target.addEventListener('drop',event=>{ + saved=event.dataTransfer; payload=saved.items[0]; + events.push([saved.files.length,payload.getAsFile()===saved.files[0],payload.getAsFile().name]); + saved.items.clear(); saved.setData('text/plain','tampered'); + }); + target.addEventListener('input',()=>events.push(['input',saved.files.length,saved.items.length])); + 'ready' + "#).unwrap(); + let data = RendererDragData { + items: vec![], + files: vec![RendererDraggedFile { + bytes: b"contents".to_vec(), + mime_type: "text/plain".to_owned(), + name: "upload.txt".to_owned(), + last_modified: 7.0, + }], + directories: vec![], + drag_operations_mask: 1, + }; + vm.dispatch_drag_event_at_point(10.0, 10.0, "drop", data, 0) + .unwrap(); + assert_eq!(vm.eval("JSON.stringify([events,target.files[0].name,target.files[0].size,saved.files.length,payload.kind,payload.getAsFile()])").unwrap(),r#"[[[1,true,"upload.txt"],["input",0,0]],"upload.txt",8,0,"",null]"#); +} + +#[test] +fn native_drag_event_views_use_each_target_documents_realm() { + for origin in ["parent", "child"] { + let mut vm = new_rendered_test_vm( + "https://drag-realms.test/", + "
", + ); + vm.eval(&format!(r#" + globalThis.child=frame.contentWindow; + child.document.body.innerHTML='
'; + const source={source};source.draggable=true; + globalThis.rows=[];globalThis.saved=[]; + for(const [win,name] of [[window,'parent'],[child,'child']]) {{ + for(const type of ['dragstart','dragenter','dragover','drop','dragend']) win.document.addEventListener(type,event=>{{ + const dt=event.dataTransfer; + if(type==='dragstart') {{dt.setData('text/plain','realm-payload');dt.effectAllowed='copy';}} + rows.push([name,type,event instanceof win.DragEvent,dt instanceof win.DataTransfer,dt.items instanceof win.DataTransferItemList,dt.items[0] instanceof win.DataTransferItem,event.view===win,saved.every(old=>old.items.length===0 && old.types.length===0),dt.getData('text/plain')]); + saved.push(dt); + if(type==='dragenter'||type==='dragover') {{dt.dropEffect='copy';event.preventDefault();}} + if(type==='drop') event.preventDefault(); + }}); + }} 'ready' + "#,source=if origin=="parent" {"outside"} else {"child.document.getElementById('inside')"})).unwrap(); + vm.publish_layout_for_test().unwrap(); + let (start, end) = if origin == "parent" { + (80.0, 320.0) + } else { + (320.0, 80.0) + }; + for input in [ + ("mousemove", start, 0, 0), + ("mousedown", start, 1, 0), + ("mousemove", start + 20.0, 1, 0), + ("mousemove", end, 1, 0), + ("mouseup", end, 0, 0), + ] { + mode_input(&mut vm, "mouse", input); + } + assert_eq!(vm.eval("rows.every(row=>row.slice(2,8).every(Boolean) && row[8]===(['dragstart','drop'].includes(row[1])?'realm-payload':'')) && rows.filter(row=>row[1]==='drop').length===1 && saved.every(dt=>dt.items.length===0)").unwrap(),"true","{origin}: {}",vm.eval("JSON.stringify(rows)").unwrap()); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 291d7858d1..ba6797d2f3 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -26,6 +26,7 @@ mod document_domain_setter; mod document_open_navigation_abort; mod document_open_origin; mod dom_event_worlds; +mod drag_data_modes; mod drag_init; mod drag_lifecycle; mod error_event_init; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/native_event_construction.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/native_event_construction.rs index 1e34a39a64..d8d1ffa5ea 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/native_event_construction.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/native_event_construction.rs @@ -93,7 +93,7 @@ fn native_event_producers_bypass_author_constructors_dictionaries_and_indexed_se 0, ), element::construct_pointer_event(scope, "pointerdown", 11.0, 12.0, 0, 1, &pointer), - element::construct_drag_event( + element::construct_drag_event_with_related_target( scope, "dragstart", 11.0, @@ -101,6 +101,7 @@ fn native_event_producers_bypass_author_constructors_dictionaries_and_indexed_se 1, transfer.into(), 0, + None, ), element::construct_wheel_event(scope, "wheel", 11.0, 12.0, 4.0, 5.0, 0, 1, 0), element::construct_touch_event_with_points( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/files_and_transfer.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/files_and_transfer.rs index db2da88836..e91eebd3c2 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/files_and_transfer.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/files_and_transfer.rs @@ -503,6 +503,9 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() { entry.__lmFileSystemEntryIsFile = false; entry.__lmFileSystemEntryIsDirectory = true; + const receiverError = action => { + try { action(); return "no error"; } catch (error) { return error.name; } + }; return JSON.stringify({ ownNamesBefore, descriptors, @@ -521,14 +524,14 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() { entry.isDirectory ].join('|'), fake: [ - DataTransfer.prototype.getData.call(fakeDataTransfer, 'text/plain'), - fakeDataTransfer.types === undefined ? 'undefined' : fakeDataTransfer.types.join(','), - fakeDataTransfer.dropEffect, - fakeDataTransfer.effectAllowed, - fakeList.length, - fakeItem.kind, - fakeItem.type, - fakeItem.getAsFile(), + receiverError(() => DataTransfer.prototype.getData.call(fakeDataTransfer, 'text/plain')), + receiverError(() => fakeDataTransfer.types === undefined ? 'undefined' : fakeDataTransfer.types.join(',')), + receiverError(() => fakeDataTransfer.dropEffect), + receiverError(() => fakeDataTransfer.effectAllowed), + receiverError(() => fakeList.length), + receiverError(() => fakeItem.kind), + receiverError(() => fakeItem.type), + receiverError(() => fakeItem.getAsFile()), entryNameGetter.call(fakeEntry), entryPathGetter.call(fakeEntry) ].map(value => value === null ? 'null' : String(value)).join('|') @@ -540,7 +543,7 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() { assert_eq!( result, - r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[]},"descriptors":{"dataTransfer":["files:function:get files:0:undefined:none:none:true:true","items:function:get items:0:undefined:none:none:true:true","types:function:get types:0:undefined:none:none:true:true","dropEffect:function:get dropEffect:0:function:set dropEffect:1:true:true","effectAllowed:function:get effectAllowed:0:function:set effectAllowed:1:true:true"],"itemList":["length:function:get length:0:undefined:none:none:true:true"],"item":["kind:function:get kind:0:undefined:none:none:true:true","type:function:get type:0:undefined:none:none:true:true"],"entry":["filesystem:function:get filesystem:0:undefined:none:none:true:true","fullPath:function:get fullPath:0:undefined:none:none:true:true","isDirectory:function:get isDirectory:0:undefined:none:none:true:true","isFile:function:get isFile:0:undefined:none:none:true:true","name:function:get name:0:undefined:none:none:true:true"]},"real":"alpha|text/plain,Files|none|none|2|file|text/plain|note.txt|note.txt|/note.txt|true|false","fake":"|undefined|none|uninitialized|0|||null||"}"# + r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[]},"descriptors":{"dataTransfer":["files:function:get files:0:undefined:none:none:true:true","items:function:get items:0:undefined:none:none:true:true","types:function:get types:0:undefined:none:none:true:true","dropEffect:function:get dropEffect:0:function:set dropEffect:1:true:true","effectAllowed:function:get effectAllowed:0:function:set effectAllowed:1:true:true"],"itemList":["length:function:get length:0:undefined:none:none:true:true"],"item":["kind:function:get kind:0:undefined:none:none:true:true","type:function:get type:0:undefined:none:none:true:true"],"entry":["filesystem:function:get filesystem:0:undefined:none:none:true:true","fullPath:function:get fullPath:0:undefined:none:none:true:true","isDirectory:function:get isDirectory:0:undefined:none:none:true:true","isFile:function:get isFile:0:undefined:none:none:true:true","name:function:get name:0:undefined:none:none:true:true"]},"real":"alpha|text/plain,Files|none|none|2|file|text/plain|note.txt|note.txt|/note.txt|true|false","fake":"TypeError|TypeError|TypeError|TypeError|TypeError|TypeError|TypeError|TypeError||"}"# ); } diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-cancel-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-cancel-basic.html index b5b5273afc..919e5ba0bb 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-cancel-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-cancel-basic.html @@ -47,11 +47,14 @@ const dropped = new Promise(function (resolve) { promise_test(async function () { await dropped; + assert_equals(firstDataTransfer.items.length, 0, "saved dragenter view is disabled"); + assert_equals(firstDataTransfer.types.length, 0, "cancel does not reactivate the old view"); + assert_equals(firstDataTransfer.getData("text"), "", "old payload stays inaccessible"); assert_array_equals( events, [ - "dragenter:initial:true", - "dragover:mutated-before-cancel:true", + "dragenter::true", + "dragover::false", "drop:fresh-after-cancel:false", ], "dragCancel should clear the active DataTransfer without dispatching a DOM event", diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html index 41060bf6e0..99d473dbe0 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-lifecycle-basic.html @@ -71,16 +71,18 @@ const dropped = new Promise(function (resolve) { promise_test(async function () { await dropped; + assert_equals(firstDataTransfer.items.length, 0, "saved event item list is disabled"); + assert_equals(firstDataTransfer.getData("text"), "", "saved event cannot read the store"); assert_array_equals( events, [ - "dragenter:true:initial-text:copy:copyMove", - "dragover-before:true:initial-text", - "dragover-after:mutated-in-over:move", - "drop:true:mutated-in-over:move:hello world", - "input:hello mutated-in-over:21:21", + "dragenter:true::copy:copyMove", + "dragover-before:false:", + "dragover-after::move", + "drop:false:initial-text:move:hello world", + "input:hello initial-text:18:18", ], - "drag lifecycle should reuse live DataTransfer and run text drop default action", + "drag lifecycle should use temporary protected views and preserve data for the text drop default action", ); -}, "Input.dispatchDragEvent drives live DataTransfer reuse and text input default drop"); +}, "Input.dispatchDragEvent protects event views and preserves text input default drop"); diff --git a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-mixed-payload-basic.html b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-mixed-payload-basic.html index 82b74b58a1..3ae641f7b3 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-mixed-payload-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/fileapi/drag-mixed-payload-basic.html @@ -6,90 +6,90 @@