fix(clipboard): share byte snapshots across page realms

Store validated clipboard representations in the BrowserContext instead of retaining page-owned V8 objects. Reconstruct fresh items and blobs on reads, commit all representations together, and recheck write permission after pending data settles. Cover child realms, document teardown, independent contexts, and rejected writes.

Source: 331945eaf9
This commit is contained in:
ldm0
2026-09-17 01:15:10 +08:00
committed by Donough Liu
parent 4f7c4ffc0b
commit cff5927983
5 changed files with 804 additions and 104 deletions
@@ -2,14 +2,12 @@ use super::super::{SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, SIMPLE_EVENT_TARGE
use crate::web_api_interfaces;
use crate::{
blob,
util::{get_private_value, set_private_value, throw_type_error, v8_string},
util::{context_host_ptr_from_context_slot, get_private_value, throw_type_error, v8_string},
webidl,
};
use anyhow::{Result, anyhow};
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
const CLIPBOARD_ITEMS_SLOT: &str = "__moliClipboardItems";
const CLIPBOARD_TEXT_SLOT: &str = "__moliClipboardText";
const CLIPBOARD_EVENT_LISTENERS_SLOT: &str = "__moliClipboardEventListeners";
const CLIPBOARD_ITEM_DATA_SLOT: &str = "__moliClipboardItemData";
@@ -25,12 +23,6 @@ struct ClipboardObjectDeclaration {
#[webapi(slot = SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)]
ordered_handlers: (),
#[webapi(slot = CLIPBOARD_ITEMS_SLOT, init = "undefined")]
items: (),
#[webapi(slot = CLIPBOARD_TEXT_SLOT, init = "")]
text: (),
}
#[derive(Default, WebApiFunctionTemplate)]
@@ -558,6 +550,9 @@ fn clipboard_read_callback<'s>(
return;
}
};
let Some(runtime) = clipboard_browser_context(scope, args.this()) else {
return;
};
if let Some(formats) = parsed.formats.unsanitized {
let invalid = formats.len() > 1 || formats.iter().any(|format| format != "text/html");
if invalid {
@@ -570,7 +565,14 @@ fn clipboard_read_callback<'s>(
return;
}
}
let items = copy_clipboard_items(scope, args.this());
if reject_denied_clipboard_access(scope, args.this(), "clipboard-read", &mut rv) {
return;
}
let Some(items) = copy_clipboard_items(scope, &runtime) else {
let reason = type_error_value(scope, "Failed to read clipboard data.");
set_rejected_promise(scope, &mut rv, reason);
return;
};
set_resolved_promise(scope, &mut rv, items.into());
}
@@ -584,23 +586,17 @@ fn clipboard_read_text_callback<'s>(
set_rejected_promise(scope, &mut rv, reason);
return;
}
if let Some(text) =
get_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT).filter(|value| value.is_string())
{
set_resolved_promise(scope, &mut rv, text);
let Some(runtime) = clipboard_browser_context(scope, args.this()) else {
return;
};
if reject_denied_clipboard_access(scope, args.this(), "clipboard-read", &mut rv) {
return;
}
let text_promise = first_clipboard_item(scope, args.this())
.and_then(|item| clipboard_item_data_promise(scope, item, "text/plain"))
.and_then(|promise| {
let converter = v8::Function::builder(clipboard_blob_to_text_callback).build(scope)?;
promise.then(scope, converter)
});
if let Some(promise) = text_promise {
rv.set(promise.into());
} else {
let empty = v8::String::empty(scope);
set_resolved_promise(scope, &mut rv, empty.into());
let bytes = runtime
.clipboard_data_for_type("text/plain")
.unwrap_or_default();
if let Some(text) = v8_string(scope, &String::from_utf8_lossy(&bytes)) {
set_resolved_promise(scope, &mut rv, text.into());
}
}
@@ -621,6 +617,9 @@ fn clipboard_write_callback<'s>(
return;
}
};
if clipboard_browser_context(scope, args.this()).is_none() {
return;
}
if parsed.data.len() != 1 {
let reason = super::super::new_dom_exception_value(
scope,
@@ -630,15 +629,22 @@ fn clipboard_write_callback<'s>(
set_rejected_promise(scope, &mut rv, reason);
return;
}
if reject_denied_clipboard_access(scope, args.this(), "clipboard-write", &mut rv) {
return;
}
let item = parsed.data[0];
let Some(validation) = clipboard_item_write_validation_promise(scope, item) else {
let reason = type_error_value(scope, "Failed to validate ClipboardItem data.");
set_rejected_promise(scope, &mut rv, reason);
return;
};
let callback_data = v8::Array::new(scope, 2);
let Some(resolver) = v8::PromiseResolver::new(scope) else {
return;
};
let callback_data = v8::Array::new(scope, 3);
let _ = callback_data.set_index(scope, 0, args.this().into());
let _ = callback_data.set_index(scope, 1, item.into());
let _ = callback_data.set_index(scope, 2, resolver.into());
let Some(store_callback) = v8::Function::builder(clipboard_store_validated_item_callback)
.data(callback_data.into())
.build(scope)
@@ -647,12 +653,23 @@ fn clipboard_write_callback<'s>(
set_rejected_promise(scope, &mut rv, reason);
return;
};
let Some(promise) = validation.then(scope, store_callback) else {
let Some(reject_callback) = v8::Function::builder(clipboard_write_rejected_callback)
.data(callback_data.into())
.build(scope)
else {
let reason = type_error_value(scope, "Failed to write ClipboardItem data.");
set_rejected_promise(scope, &mut rv, reason);
return;
};
rv.set(promise.into());
if validation
.then2(scope, store_callback, reject_callback)
.is_none()
{
let reason = type_error_value(scope, "Failed to write ClipboardItem data.");
set_rejected_promise(scope, &mut rv, reason);
return;
}
rv.set(resolver.get_promise(scope).into());
}
fn clipboard_write_text_callback<'s>(
@@ -672,40 +689,14 @@ fn clipboard_write_text_callback<'s>(
return;
}
};
let Some(text) = v8_string(scope, &parsed.data) else {
rv.set_undefined();
let Some(runtime) = clipboard_browser_context(scope, args.this()) else {
return;
};
let Some(promise) = resolved_promise(scope, text.into()) else {
rv.set_undefined();
if reject_denied_clipboard_access(scope, args.this(), "clipboard-write", &mut rv) {
return;
};
let Some(item) = build_clipboard_item(scope, vec![("text/plain".to_owned(), promise)]) else {
rv.set_undefined();
return;
};
store_clipboard_item(scope, args.this(), item);
set_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT, text.into());
set_resolved_promise(scope, &mut rv, v8::undefined(scope).into());
}
fn clipboard_blob_to_text_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Ok(blob) = v8::Local::<v8::Object>::try_from(args.get(0)) else {
throw_type_error(scope, "Clipboard text data is not a Blob.");
return;
};
let Some(bytes) = blob::blob_bytes_from_object(scope, blob) else {
throw_type_error(scope, "Clipboard text data is not a Blob.");
return;
};
let text = String::from_utf8_lossy(&bytes);
if let Some(value) = v8_string(scope, &text) {
rv.set(value.into());
}
runtime.set_clipboard_data(vec![("text/plain".to_owned(), parsed.data.into_bytes())]);
set_resolved_promise(scope, &mut rv, v8::undefined(scope).into());
}
fn clipboard_item_write_validation_promise<'s>(
@@ -723,7 +714,11 @@ fn clipboard_item_write_validation_promise<'s>(
custom_format_count += 1;
}
let raw = clipboard_item_raw_data_promise(scope, item, &mime_type)?;
entries.push((key, raw));
let normalized = clipboard_item_data_promise(scope, item, &mime_type)?;
let validation_data = v8::Array::new(scope, 2);
let _ = validation_data.set_index(scope, 0, key);
let _ = validation_data.set_index(scope, 1, normalized.into());
entries.push((validation_data, raw));
}
if custom_format_count > 100 {
let reason = super::super::new_dom_exception_value(
@@ -748,9 +743,9 @@ fn clipboard_item_write_validation_promise<'s>(
);
let _ = state.set_index(scope, 2, v8::Boolean::new(scope, false).into());
for (key, raw) in entries {
for (validation_data, raw) in entries {
let validator = v8::Function::builder(clipboard_validate_write_entry_callback)
.data(key)
.data(validation_data.into())
.build(scope)?;
let validated = raw.then(scope, validator)?;
let on_fulfilled = v8::Function::builder(clipboard_validation_fulfilled_callback)
@@ -767,11 +762,15 @@ fn clipboard_item_write_validation_promise<'s>(
fn clipboard_validate_write_entry_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let mime_type = args
.data()
.to_string(scope)
let Ok(data) = v8::Local::<v8::Array>::try_from(args.data()) else {
throw_type_error(scope, "Failed to validate ClipboardItem data.");
return;
};
let mime_type = data
.get_index(scope, 0)
.and_then(|value| value.to_string(scope))
.map(|value| value.to_rust_string_lossy(scope))
.unwrap_or_default();
if !clipboard_item_type_supported(&mime_type) {
@@ -782,6 +781,11 @@ fn clipboard_validate_write_entry_callback<'s>(
);
return;
}
// Await the representation's normalization as well as its raw type
// validation before committing a byte snapshot to the shared clipboard.
if let Some(normalized) = data.get_index(scope, 1) {
rv.set(normalized);
}
let value = args.get(0);
let Ok(object) = v8::Local::<v8::Object>::try_from(value) else {
if mime_type == "image/png" {
@@ -875,27 +879,62 @@ fn clipboard_store_validated_item_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let Ok(data) = v8::Local::<v8::Array>::try_from(args.data()) else {
throw_type_error(scope, "Failed to write ClipboardItem data.");
let Some((clipboard, item, resolver)) = clipboard_write_callback_state(scope, args.data())
else {
return;
};
let clipboard = data
.get_index(scope, 0)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok());
let item = data
.get_index(scope, 1)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok());
let (Some(clipboard), Some(item)) = (clipboard, item) else {
throw_type_error(scope, "Failed to write ClipboardItem data.");
// Like Chromium's ScriptPromiseResolver, do not settle a write after its
// realm is retired. Each live write still commits independently.
let Some(runtime) = clipboard_browser_context(scope, clipboard) else {
return;
};
store_clipboard_item(scope, clipboard, item);
set_private_value(
scope,
clipboard,
CLIPBOARD_TEXT_SLOT,
v8::undefined(scope).into(),
);
if clipboard_permission_denied(scope, clipboard, "clipboard-write") {
let reason = super::super::new_dom_exception_value(
scope,
"Clipboard write permission denied.",
"NotAllowedError",
);
let _ = resolver.reject(scope, reason);
return;
}
let Some(data) = clipboard_item_bytes(scope, item) else {
let reason = type_error_value(scope, "Failed to read validated ClipboardItem data.");
let _ = resolver.reject(scope, reason);
return;
};
runtime.set_clipboard_data(data);
let _ = resolver.resolve(scope, v8::undefined(scope).into());
}
fn clipboard_write_rejected_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some((clipboard, _item, resolver)) = clipboard_write_callback_state(scope, args.data())
else {
return;
};
if clipboard_browser_context(scope, clipboard).is_some() {
let _ = resolver.reject(scope, args.get(0));
}
}
fn clipboard_write_callback_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
) -> Option<(
v8::Local<'s, v8::Object>,
v8::Local<'s, v8::Object>,
v8::Local<'s, v8::PromiseResolver>,
)> {
let data = v8::Local::<v8::Array>::try_from(value).ok()?;
let clipboard = v8::Local::<v8::Object>::try_from(data.get_index(scope, 0)?).ok()?;
let item = v8::Local::<v8::Object>::try_from(data.get_index(scope, 1)?).ok()?;
let resolver = v8::Local::<v8::Object>::try_from(data.get_index(scope, 2)?).ok()?;
// This callback data is created only by clipboard_write_callback.
let resolver = unsafe { v8::Local::<v8::PromiseResolver>::cast_unchecked(resolver) };
Some((clipboard, item, resolver))
}
fn throw_dom_exception(scope: &mut v8::PinScope<'_, '_>, message: &str, name: &str) {
@@ -903,37 +942,104 @@ fn throw_dom_exception(scope: &mut v8::PinScope<'_, '_>, message: &str, name: &s
scope.throw_exception(exception);
}
fn store_clipboard_item<'s>(
fn clipboard_permission_denied<'s>(
scope: &mut v8::PinScope<'s, '_>,
clipboard: v8::Local<'s, v8::Object>,
item: v8::Local<'s, v8::Object>,
) {
let items = v8::Array::new(scope, 1);
let _ = items.set_index(scope, 0, item.into());
let _ = items.set_integrity_level(scope, v8::IntegrityLevel::Frozen);
set_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT, items.into());
permission: &str,
) -> bool {
let Some(context) = clipboard.get_creation_context(scope) else {
return true;
};
let Some(host_ptr) = context_host_ptr_from_context_slot(context) else {
return true;
};
let scope = &mut v8::ContextScope::new(scope, context);
let host = unsafe { &mut *host_ptr };
let embedding_origin = moli_url::origin_ascii_serialization(host.document_url());
let Some(requesting_origin) = host
.current_runtime_window_execution_context_identity(scope)
.and_then(|identity| host.storage_context_for_window_execution_context_identity(identity))
.map(|context| context.storage_key().serialized_storage_key())
.and_then(|key| moli_storage_key::deserialize_serialized_storage_key(&key))
.map(|key| key.origin().to_owned())
else {
return true;
};
host.permission_state_for_origins(permission, &requesting_origin, &embedding_origin) == "denied"
}
fn reject_denied_clipboard_access<'s>(
scope: &mut v8::PinScope<'s, '_>,
clipboard: v8::Local<'s, v8::Object>,
permission: &str,
rv: &mut v8::ReturnValue<'_, v8::Value>,
) -> bool {
if !clipboard_permission_denied(scope, clipboard, permission) {
return false;
}
let reason = super::super::new_dom_exception_value(
scope,
"Clipboard permission denied.",
"NotAllowedError",
);
set_rejected_promise(scope, rv, reason);
true
}
fn clipboard_browser_context<'s>(
scope: &mut v8::PinScope<'s, '_>,
clipboard: v8::Local<'s, v8::Object>,
) -> Option<crate::runtime::RendererBrowserContextRuntime> {
let context = clipboard.get_creation_context(scope)?;
let host_ptr = context_host_ptr_from_context_slot(context)?;
let scope = &mut v8::ContextScope::new(scope, context);
let host = unsafe { &*host_ptr };
// Blink's CreateForRead/Write entry points return an empty Promise for an
// invalid ScriptState. Check the receiver realm after WebIDL conversion,
// which can itself detach the document through author code.
host.current_runtime_window_execution_context_identity(scope)?;
Some(host.browser_context_runtime())
}
fn copy_clipboard_items<'s>(
scope: &mut v8::PinScope<'s, '_>,
clipboard: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Array> {
let item = first_clipboard_item(scope, clipboard);
let result = v8::Array::new(scope, i32::from(item.is_some()));
if let Some(item) = item {
let _ = result.set_index(scope, 0, item.into());
runtime: &crate::runtime::RendererBrowserContextRuntime,
) -> Option<v8::Local<'s, v8::Array>> {
let data = runtime.clipboard_data();
let result = v8::Array::new(scope, i32::from(!data.is_empty()));
if !data.is_empty() {
let mut entries = Vec::with_capacity(data.len());
for (mime_type, bytes) in data {
let blob = blob::build_blob_object(scope, bytes, mime_type.clone())?;
let promise = resolved_promise(scope, blob.into())?;
entries.push((mime_type, promise));
}
let item = build_clipboard_item(scope, entries)?;
result.set_index(scope, 0, item.into())?;
}
result
Some(result)
}
fn first_clipboard_item<'s>(
fn clipboard_item_bytes<'s>(
scope: &mut v8::PinScope<'s, '_>,
clipboard: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
get_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT)
.and_then(|value| v8::Local::<v8::Array>::try_from(value).ok())
.and_then(|items| items.get_index(scope, 0))
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
item: v8::Local<'s, v8::Object>,
) -> Option<Vec<(String, Vec<u8>)>> {
let types = get_private_value(scope, item, CLIPBOARD_ITEM_TYPES_SLOT)
.and_then(|value| v8::Local::<v8::Array>::try_from(value).ok())?;
let mut data = Vec::with_capacity(types.length() as usize);
for index in 0..types.length() {
let mime_type = types
.get_index(scope, index)?
.to_string(scope)?
.to_rust_string_lossy(scope);
let promise = clipboard_item_data_promise(scope, item, &mime_type)?;
if promise.state() != v8::PromiseState::Fulfilled {
return None;
}
let blob = v8::Local::<v8::Object>::try_from(promise.result(scope)).ok()?;
data.push((mime_type, blob::blob_bytes_from_object(scope, blob)?));
}
Some(data)
}
fn clipboard_item_data_promise<'s>(
@@ -171,6 +171,9 @@ pub(crate) struct RendererStoragePartitionIdentity {
#[derive(Debug)]
struct RendererBrowserContextRuntimeInner {
id: super::RendererBrowserContextRuntimeId,
// Keep clipboard representations independent of any page's V8 objects so
// other pages and replacement documents can read them in their own realm.
clipboard_data: Mutex<Vec<(String, Vec<u8>)>>,
message_port_registry: crate::message_port_runtime::SharedMessagePortRegistry,
broadcast_channel_registry: crate::broadcast_channel_runtime::SharedBroadcastChannelRegistry,
browser_resource_runtime: crate::network::BrowserResourceRuntimeBinding,
@@ -340,6 +343,23 @@ impl Default for RendererBrowserContextRuntimeOwner {
}
impl RendererBrowserContextRuntime {
pub(crate) fn clipboard_data(&self) -> Vec<(String, Vec<u8>)> {
self.inner.clipboard_data.lock().clone()
}
pub(crate) fn clipboard_data_for_type(&self, mime_type: &str) -> Option<Vec<u8>> {
self.inner
.clipboard_data
.lock()
.iter()
.find(|(candidate, _)| candidate == mime_type)
.map(|(_, bytes)| bytes.clone())
}
pub(crate) fn set_clipboard_data(&self, data: Vec<(String, Vec<u8>)>) {
*self.inner.clipboard_data.lock() = data;
}
// A browser-context runtime is only valid while its thread-affine owner is
// retained, so construction returns that owner rather than a bare handle.
#[allow(clippy::new_ret_no_self)]
@@ -528,6 +548,7 @@ impl RendererBrowserContextRuntime {
Self {
inner: Arc::new(RendererBrowserContextRuntimeInner {
id,
clipboard_data: Mutex::new(Vec::new()),
message_port_registry,
broadcast_channel_registry,
browser_resource_runtime: browser_resource_runtime.clone(),
@@ -993,6 +1014,27 @@ mod tests {
assert!(runtime.shares_state_with(&clone));
}
#[test]
fn clipboard_data_for_type_returns_an_independent_copy_of_the_matching_bytes() {
let runtime = RendererBrowserContextRuntime::new();
runtime.set_clipboard_data(vec![
("image/png".to_owned(), vec![0; 1024]),
("text/plain".to_owned(), b"hello".to_vec()),
]);
let mut text = runtime.clipboard_data_for_type("text/plain").unwrap();
assert_eq!(text, b"hello");
text[0] = b'j';
assert_eq!(
runtime.clipboard_data_for_type("text/plain").unwrap(),
b"hello"
);
assert!(runtime.clipboard_data_for_type("text/html").is_none());
runtime.set_clipboard_data(Vec::new());
assert!(runtime.clipboard_data_for_type("text/plain").is_none());
}
#[test]
fn worker_services_stay_deferred_until_first_use() {
let runtime = RendererBrowserContextRuntime::new();
@@ -0,0 +1,551 @@
use super::*;
const CLIPBOARD_SNAPSHOT_PROBE: &str = r#"
(async () => {
const clipboard = navigator.clipboard;
const source = new Blob(['hello 世界 🦊'], {type: 'text/plain'});
const item = new ClipboardItem({'text/plain': source});
await clipboard.write([item]);
const first = (await clipboard.read())[0];
const second = (await clipboard.read())[0];
const blob = await first.getType('text/plain');
if (first === item || first === second || blob === source) return 'reused author objects';
if (!(first instanceof ClipboardItem) || !(blob instanceof Blob)) return 'wrong realm';
if (first.types.join(',') !== 'text/plain' || await blob.text() !== 'hello 世界 🦊') return 'bytes changed';
Object.defineProperty(item, 'getType', {get() {throw Error('author getType');}});
Object.defineProperty(source, 'text', {get() {throw Error('author text');}});
if (await clipboard.readText() !== 'hello 世界 🦊') return 'read author properties';
await clipboard.writeText('replacement');
const replacement = (await clipboard.read())[0];
if (replacement === first || await (await replacement.getType('text/plain')).text() !== 'replacement') {
return 'replacement failed';
}
return 'ok';
})()
"#;
fn assert_clipboard_probe(vm: &mut ScriptVm, script: &str) {
vm.eval(&format!(
"globalThis.__clipboardStorageResult = 'pending'; ({script}).then(\
result => globalThis.__clipboardStorageResult = result, \
error => globalThis.__clipboardStorageResult = String(error));"
))
.expect("clipboard probe should evaluate");
vm.eval("0").expect("clipboard promises should drain");
assert_eq!(
vm.eval("globalThis.__clipboardStorageResult")
.expect("clipboard result should evaluate"),
"ok"
);
}
#[test]
fn clipboard_storage_reads_new_items_and_blobs_from_byte_snapshots() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(&mut vm, CLIPBOARD_SNAPSHOT_PROBE);
}
#[test]
fn clipboard_storage_detached_handles_cannot_access_shared_data() {
let mut vm = new_parsed_test_vm(
"https://clipboard-storage.test/",
"<!doctype html><body></body>",
);
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const frame = document.createElement('iframe');
document.body.append(frame);
const stale = frame.contentWindow.navigator.clipboard;
const item = new frame.contentWindow.ClipboardItem({'text/plain': 'stale item'});
await navigator.clipboard.writeText('good');
frame.remove();
const operations = {
read: () => stale.read(),
readText: () => stale.readText(),
write: () => stale.write([item]),
writeText: () => stale.writeText('stale text')
};
for (const [name, operation] of Object.entries(operations)) {
if (operation() !== undefined) return name + ' returned a Promise for a detached realm';
}
if (await navigator.clipboard.readText() !== 'good') return 'detached handle changed shared data';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_detached_pending_writes_do_not_commit_or_settle() {
let mut vm = new_parsed_test_vm(
"https://clipboard-storage.test/",
"<!doctype html><body></body>",
);
vm.eval(
r#"
navigator.clipboard.writeText('good');
globalThis.clipboardWriteOutcomes = [];
for (const reject of [false, true]) {
const frame = document.createElement('iframe');
document.body.append(frame);
let settle;
const data = new Promise((resolve, fail) => settle = reject ? fail : resolve);
const item = new frame.contentWindow.ClipboardItem({'text/plain': data});
const index = clipboardWriteOutcomes.push('pending') - 1;
frame.contentWindow.navigator.clipboard.write([item]).then(
() => clipboardWriteOutcomes[index] = 'resolved',
error => clipboardWriteOutcomes[index] = error.name
);
frame.remove();
settle(reject ? Error('retired representation') : 'stale text');
}
"#,
)
.expect("detached writes should not throw synchronously");
// eval drains microtasks, including representation validation and commit.
assert_eq!(
vm.eval("clipboardWriteOutcomes.join(',')")
.expect("detached write outcomes"),
"pending,pending"
);
assert_clipboard_probe(
&mut vm,
r#"
(async () => (await navigator.clipboard.readText()) === 'good' ? 'ok' : 'detached write committed')()
"#,
);
}
#[test]
fn clipboard_storage_rechecks_realm_after_argument_conversion() {
let mut vm = new_parsed_test_vm(
"https://clipboard-storage.test/",
"<!doctype html><body></body>",
);
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
await navigator.clipboard.writeText('good');
for (const method of ['writeText', 'write', 'read']) {
const frame = document.createElement('iframe');
document.body.append(frame);
const clipboard = frame.contentWindow.navigator.clipboard;
const item = new frame.contentWindow.ClipboardItem({'text/plain': 'stale item'});
const argumentsByMethod = {
writeText: {toString() {frame.remove(); return 'stale text';}},
write: {[Symbol.iterator]: function* () {yield item; frame.remove();}},
read: {get unsanitized() {frame.remove(); return ['invalid-format'];}}
};
if (clipboard[method](argumentsByMethod[method]) !== undefined) return method + ' used a retired realm';
}
if (await navigator.clipboard.readText() !== 'good') return 'argument conversion allowed a stale commit';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_read_text_selects_plain_text_representation() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const clipboard = navigator.clipboard;
await clipboard.write([new ClipboardItem({'text/html': '<b>text</b>', 'text/plain': 'text'})]);
if (await clipboard.readText() !== 'text') return 'wrong representation';
await clipboard.write([new ClipboardItem({'text/html': '<b>html only</b>'})]);
if (await clipboard.readText() !== '') return 'missing plain text was not empty';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_waits_for_all_representations_and_preserves_data_after_rejection() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const clipboard = navigator.clipboard;
await clipboard.writeText('before');
let resolve;
const pending = new Promise(done => resolve = done);
const write = clipboard.write([new ClipboardItem({
'text/plain': Promise.resolve('after'), 'text/html': pending
})]);
if (await clipboard.readText() !== 'before') return 'partially committed write';
resolve(new Blob(['<b>after</b>'], {type: 'text/html'}));
await write;
const item = (await clipboard.read())[0];
if (item.types.join(',') !== 'text/plain,text/html') return 'representation order';
if (await clipboard.readText() !== 'after' ||
await (await item.getType('text/html')).text() !== '<b>after</b>') return 'representation bytes';
try {
await clipboard.write([new ClipboardItem({
'text/plain': 'bad replacement',
'text/html': new Blob(['wrong type'], {type: 'text/plain'})
})]);
return 'accepted mismatched representation';
} catch (error) {
if (error.name !== 'NotAllowedError') return 'wrong rejection';
}
if (await clipboard.readText() !== 'after') return 'failed write replaced clipboard';
const sentinel = Error('rejected representation');
let reject;
const rejection = new Promise((_, fail) => reject = fail);
const failedWrite = clipboard.write([new ClipboardItem({'text/plain': rejection})]);
reject(sentinel);
try {await failedWrite; return 'accepted rejected representation';}
catch (error) {if (error !== sentinel) return 'replaced rejection reason';}
if (await clipboard.readText() !== 'after') return 'rejection cleared clipboard';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_overlapping_writes_commit_independently() {
// Mirror Chromium's independent ClipboardPromise commits, including older
// calls that finish last.
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const clipboard = navigator.clipboard;
for (const method of ['writeText', 'write']) {
let resolve;
let result = 'pending';
const older = clipboard.write([new ClipboardItem({
'text/plain': new Promise(done => resolve = done)
})]).then(() => result = 'resolved', error => result = error.name);
if (method === 'writeText') await clipboard.writeText('newer');
else await clipboard.write([new ClipboardItem({'text/plain': 'newer'})]);
if (result !== 'pending') return method + ' settled an independent write';
if (await clipboard.readText() !== 'newer') return method + ' did not commit';
resolve('older');
await older;
if (result !== 'resolved') return 'older write rejected after ' + method;
if (await clipboard.readText() !== 'older') return 'last completed write did not commit';
}
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_failed_newer_write_does_not_cancel_pending_write() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const clipboard = navigator.clipboard;
await clipboard.writeText('initial');
let resolve;
let result = 'pending';
const older = clipboard.write([new ClipboardItem({
'text/plain': new Promise(done => resolve = done)
})]).then(() => result = 'resolved', error => result = error.name);
const sentinel = Error('newer representation rejected');
try {
await clipboard.write([new ClipboardItem({'text/plain': Promise.reject(sentinel)})]);
return 'accepted rejected representation';
} catch (error) {
if (error !== sentinel) return 'replaced rejection reason';
}
if (result !== 'pending') return 'failed newer write settled an independent write';
if (await clipboard.readText() !== 'initial') return 'failed write changed clipboard';
resolve('older');
await older;
if (result !== 'resolved') return 'older write rejected after failed newer write';
if (await clipboard.readText() !== 'older') return 'older write did not commit';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_overlapping_child_realm_writes_commit_independently() {
let mut vm = new_parsed_test_vm(
"https://clipboard-storage.test/",
"<!doctype html><body></body>",
);
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const frame = document.createElement('iframe');
document.body.append(frame);
const child = frame.contentWindow;
for (const [olderRealm, newerRealm] of [[window, child], [child, window]]) {
let resolve;
let result = 'pending';
const older = olderRealm.navigator.clipboard.write([new olderRealm.ClipboardItem({
'text/plain': new olderRealm.Promise(done => resolve = done)
})]).then(() => result = 'resolved', error => result = error.name);
await newerRealm.navigator.clipboard.writeText('newer realm');
if (result !== 'pending') return 'cross-realm write settled an independent write';
if (await olderRealm.navigator.clipboard.readText() !== 'newer realm') return 'newer realm did not commit';
resolve('older realm');
await older;
if (result !== 'resolved') return 'older realm write rejected';
if (await newerRealm.navigator.clipboard.readText() !== 'older realm') return 'older realm did not commit';
}
return 'ok';
})()
"#,
);
}
const CLIPBOARD_FRAME_PROBE: &str = r#"
(async () => {
const frame = document.createElement('iframe');
const loaded = new Promise(resolve => frame.onload = resolve);
document.body.append(frame);
await loaded;
const child = frame.contentWindow;
frame.focus();
await navigator.clipboard.writeText('parent → child');
if (await child.navigator.clipboard.readText() !== 'parent → child') return 'parent write invisible';
const childItem = (await child.navigator.clipboard.read())[0];
const childBlob = await childItem.getType('text/plain');
if (!(childItem instanceof child.ClipboardItem) || !(childBlob instanceof child.Blob)) return 'child realm';
await child.navigator.clipboard.write([new child.ClipboardItem({
'text/plain': new child.Blob(['child → parent'], {type: 'text/plain'})
})]);
if (await navigator.clipboard.readText() !== 'child → parent') return 'child write invisible';
const parentItem = (await navigator.clipboard.read())[0];
if (!(parentItem instanceof ClipboardItem) || !((await parentItem.getType('text/plain')) instanceof Blob)) {
return 'parent realm';
}
document.body.tabIndex = 0;
document.body.focus();
frame.remove();
if (await navigator.clipboard.readText() !== 'child → parent') return 'frame removal cleared clipboard';
return 'ok';
})()
"#;
#[test]
fn clipboard_storage_is_shared_with_child_realms() {
let mut vm = new_parsed_test_vm(
"https://clipboard-storage.test/",
"<!doctype html><body></body>",
);
// The synchronous VM fixture does not run the frame's later load task.
let script = CLIPBOARD_FRAME_PROBE.replace(" await loaded;", " void loaded;");
assert_clipboard_probe(&mut vm, &script);
}
fn clipboard_permission(
name: &str,
setting: &str,
) -> crate::protocol_types::PermissionOverrideRegistration {
crate::protocol_types::PermissionOverrideRegistration {
permission: serde_json::json!({"name": name}),
setting: setting.to_owned(),
origin: Some("https://clipboard-storage.test".to_owned()),
embedded_origin: None,
}
}
#[test]
fn clipboard_storage_respects_independent_read_and_write_permission_denials() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
vm.set_permission_overrides(&[clipboard_permission("clipboard-read", "denied")]);
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
await navigator.clipboard.writeText('write still allowed');
for (const operation of ['read', 'readText']) {
try {await navigator.clipboard[operation](); return 'allowed denied ' + operation;}
catch (error) {if (error.name !== 'NotAllowedError') return 'read error';}
}
return 'ok';
})()
"#,
);
vm.set_permission_overrides(&[clipboard_permission("clipboard-write", "denied")]);
assert_clipboard_probe(
&mut vm,
r#"
(async () => {
const writes = [
() => navigator.clipboard.writeText('denied replacement'),
() => navigator.clipboard.write([new ClipboardItem({'text/plain': 'denied replacement'})])
];
for (const write of writes) {
try {await write(); return 'allowed denied write';}
catch (error) {if (error.name !== 'NotAllowedError') return 'write error';}
}
if (await navigator.clipboard.readText() !== 'write still allowed') return 'denied write changed data';
const item = (await navigator.clipboard.read())[0];
if (await (await item.getType('text/plain')).text() !== 'write still allowed') return 'read denied by write permission';
return 'ok';
})()
"#,
);
}
#[test]
fn clipboard_storage_rechecks_write_permission_after_pending_data_settles() {
let mut vm = new_storage_test_vm("https://clipboard-storage.test/");
vm.eval(
r#"
navigator.clipboard.writeText('before revocation');
const pending = new Promise(resolve => globalThis.__clipboardResolve = resolve);
navigator.clipboard.write([new ClipboardItem({'text/plain': pending})]).then(
() => globalThis.__clipboardWriteResult = 'accepted',
error => globalThis.__clipboardWriteResult = error.name
);
"#,
)
.expect("pending clipboard write should start");
vm.set_permission_overrides(&[clipboard_permission("clipboard-write", "denied")]);
vm.eval("__clipboardResolve('after revocation')")
.expect("clipboard data should settle");
assert_eq!(
vm.eval("__clipboardWriteResult").expect("write result"),
"NotAllowedError"
);
assert_clipboard_probe(
&mut vm,
r#"
(async () => (await navigator.clipboard.readText()) === 'before revocation' ? 'ok' : 'revoked write committed')()
"#,
);
}
#[tokio::test(flavor = "current_thread")]
async fn clipboard_storage_overlapping_page_writes_commit_independently() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default())
.expect("clipboard test loader");
let owner = crate::runtime::RendererBrowserContextRuntime::new();
let new_page = || {
crate::runtime::PageVmTaskExecutorTestHarness::new_with_browser_context_runtime(
url::Url::parse("https://clipboard-storage.test/").expect("clipboard URL"),
&loader,
owner.handle(),
)
};
let mut older_page = new_page();
older_page
.eval(
r#"
navigator.clipboard.writeText('initial');
const pending = new Promise(resolve => globalThis.__clipboardResolve = resolve);
globalThis.__clipboardWriteResult = 'pending';
navigator.clipboard.write([new ClipboardItem({'text/plain': pending})]).then(
() => globalThis.__clipboardWriteResult = 'resolved',
error => globalThis.__clipboardWriteResult = error.name
);
"#,
)
.expect("older page clipboard write should start");
let mut newer_page = new_page();
assert_clipboard_probe(
&mut newer_page,
r#"
(async () => {
await navigator.clipboard.writeText('newer page');
if (await navigator.clipboard.readText() !== 'newer page') return 'newer page did not commit';
return 'ok';
})()
"#,
);
assert_eq!(
older_page
.eval("globalThis.__clipboardWriteResult")
.expect("older write should remain pending"),
"pending"
);
older_page
.eval("__clipboardResolve('older page')")
.expect("older page clipboard data should settle");
assert_eq!(
older_page
.eval("globalThis.__clipboardWriteResult")
.expect("older write result"),
"resolved"
);
assert_clipboard_probe(
&mut newer_page,
r#"
(async () => (await navigator.clipboard.readText()) === 'older page' ? 'ok' : 'older page did not commit')()
"#,
);
}
#[tokio::test(flavor = "current_thread")]
async fn clipboard_storage_survives_page_teardown_and_keeps_browser_contexts_separate() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default())
.expect("clipboard test loader");
let owner = crate::runtime::RendererBrowserContextRuntime::new();
let new_page = || {
crate::runtime::PageVmTaskExecutorTestHarness::new_with_browser_context_runtime(
url::Url::parse("https://clipboard-storage.test/").expect("clipboard URL"),
&loader,
owner.handle(),
)
};
{
let mut page = new_page();
assert_clipboard_probe(
&mut page,
r#"
(async () => {
await navigator.clipboard.write([new ClipboardItem({
'text/plain': new Blob(['survives teardown'], {type: 'text/plain'}),
'image/png': new Blob([Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAJklEQVR4nGJg+M9ANTRq1qhZo2aNmjVq1qhZo2ZhIAAAAAD//wMA1XBuro0zsokAAAAASUVORK5CYII='), ch => ch.charCodeAt(0))], {type: 'image/png'})
})]);
return 'ok';
})()
"#,
);
}
let mut replacement = new_page();
assert_clipboard_probe(
&mut replacement,
r#"
(async () => {
if (await navigator.clipboard.readText() !== 'survives teardown') return 'lost page data';
const item = (await navigator.clipboard.read())[0];
const blob = await item.getType('image/png');
if (!(item instanceof ClipboardItem) || !(blob instanceof Blob)) return 'retired realm';
const expected = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAJklEQVR4nGJg+M9ANTRq1qhZo2aNmjVq1qhZo2ZhIAAAAAD//wMA1XBuro0zsokAAAAASUVORK5CYII='), ch => ch.charCodeAt(0));
if (Array.from(new Uint8Array(await blob.arrayBuffer())).join(',') !== expected.join(',')) return 'binary data';
return 'ok';
})()
"#,
);
let mut separate = new_storage_test_vm("https://clipboard-storage.test/");
assert_clipboard_probe(
&mut separate,
r#"
(async () => {
if (await navigator.clipboard.readText() !== '' || (await navigator.clipboard.read()).length !== 0) {
return 'another browser context clipboard';
}
return 'ok';
})()
"#,
);
}
@@ -8703,7 +8703,7 @@ fn async_clipboard_interfaces_are_branded_and_round_trip_text_data() {
assert_eq!(
vm.eval("JSON.stringify(globalThis.__asyncClipboardProbe)")
.expect("Async Clipboard promise result should evaluate"),
r#"{"state":"done","initialBlob":[true,"text/plain","hello"],"writtenText":"hello","read":[1,true,"opaque",false],"textWrite":[1,true,"text/plain","next"],"rejections":["TypeError","TypeError","TypeError","NotAllowedError","NotAllowedError","TypeError","NotFoundError","TypeError","NotAllowedError","NotAllowedError","TypeError"]}"#
r#"{"state":"done","initialBlob":[true,"text/plain","hello"],"writtenText":"hello","read":[1,false,"opaque",false],"textWrite":[1,true,"text/plain","next"],"rejections":["TypeError","TypeError","TypeError","NotAllowedError","NotAllowedError","TypeError","NotFoundError","TypeError","NotAllowedError","NotAllowedError","TypeError"]}"#
);
}
@@ -2,6 +2,7 @@ use super::*;
mod broadcast_channel;
mod chrome;
mod clipboard_storage;
mod console;
mod crypto_misc;
mod crypto_subtle_aes;