Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions moli-protocol/src/domains/runtime/tests/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,88 @@ async fn runtime_console_api_called_preserves_basic_argument_shapes() {
assert_eq!(args[11]["unserializableValue"], json!("1n"));
}

#[tokio::test(flavor = "multi_thread")]
async fn runtime_console_capture_does_not_run_page_hooks_and_keeps_remote_objects() {
let mut ctx = TestContext::new();
with_loaded_document_async(&mut ctx, "<!doctype html><body></body>").await;

// Exercise both states: reporting must be safe even before an Inspector
// Runtime session exists; enabling it must retain real remote object ids.
for (index, enabled) in [false, true].into_iter().enumerate() {
if enabled {
enable_runtime_and_take_execution_context_id_async(&mut ctx, 206_850).await;
}
ctx.sent.clear();
let command_id = 206_851 + index as u64;
ctx.process_async(json!({
"id": command_id,
"method": "Runtime.evaluate",
"params": {"expression": r#"
(() => {
const hits = [];
const object = {
answer: 42,
get getter() { hits.push('getter'); return 1; },
toJSON() { hits.push('toJSON'); return {}; },
[Symbol.toPrimitive]() { hits.push('toPrimitive'); return 'object'; }
};
const proxy = new Proxy({}, {
get() { hits.push('Proxy.get'); },
ownKeys() { hits.push('Proxy.ownKeys'); return []; },
getOwnPropertyDescriptor() { hits.push('Proxy.descriptor'); }
});
const error = new Error('console');
Object.defineProperty(error, 'stack', {get() { hits.push('stack'); return 'stack'; }});
console.log('safe-capture', object, proxy, error);
return JSON.stringify(hits);
})()
"#}
})).await;
let response = take_response_by_id(&mut ctx, command_id);
assert_eq!(
response["result"]["result"]["value"],
json!("[]"),
"{response}"
);
if !enabled {
continue;
}

wait_until_message(&mut ctx, None, "safe console object", |message| {
message["method"] == json!("Runtime.consoleAPICalled")
&& message["params"]["args"][0]["value"] == json!("safe-capture")
})
.await;
let object_id = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Runtime.consoleAPICalled")
&& message["params"]["args"][0]["value"] == json!("safe-capture")
})
.unwrap()["params"]["args"][1]["objectId"]
.as_str()
.expect("native object id")
.to_owned();
ctx.process_async(json!({
"id": 206_853,
"method": "Runtime.getProperties",
"params": {"objectId": object_id, "ownProperties": true}
}))
.await;
let properties = take_response_by_id(&mut ctx, 206_853);
let properties = properties["result"]["result"]
.as_array()
.expect("inspectable object");
assert!(properties.iter().any(|property| {
property["name"] == json!("answer") && property["value"]["value"] == json!(42)
}));
assert!(properties.iter().any(|property| {
property["name"] == json!("getter") && property["get"]["type"] == json!("function")
}));
}
}

#[tokio::test(flavor = "multi_thread")]
async fn runtime_console_error_does_not_invoke_error_prepare_stack_trace() {
let mut ctx = TestContext::new();
Expand Down
119 changes: 69 additions & 50 deletions moli-renderer-v8/src/context_bootstrap/shared/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,16 @@ pub(in crate::context_bootstrap) fn append_console_message<'s>(
level: &str,
) {
let mut parts = Vec::with_capacity(args.length().max(0) as usize);
let mut arg_snapshot_values = Vec::with_capacity(parts.capacity());
for index in 0..args.length() {
let value = args.get(index);
let text = value
.to_string(scope)
.map(|value| value.to_rust_string_lossy(scope))
.unwrap_or_else(|| String::from("undefined"));
parts.push(text);
let snapshot = console_arg_remote_object_json(scope, args.get(index));
parts.push(console_arg_text(&snapshot));
arg_snapshot_values.push(snapshot);
}
let text = parts.join(" ");
let message = format!("{level}: {text}");
let stack = current_console_stack(scope);

let mut arg_snapshot_values = Vec::with_capacity(args.length().max(0) as usize);
for index in 0..args.length() {
let value = args.get(index);
arg_snapshot_values.push(console_arg_remote_object_json(scope, value));
}

if let Some(buffers) = current_console_message_buffers(scope) {
let mut buffers = buffers.borrow_mut();
buffers.messages.push(message.clone());
Expand Down Expand Up @@ -146,10 +138,9 @@ pub(crate) fn console_arg_remote_object_json(
return serde_json::json!({ "type": "number" });
}
if value.is_string() {
let value = value
.to_string(scope)
.map(|value| value.to_rust_string_lossy(scope))
.unwrap_or_default();
let value = v8::Local::<v8::String>::try_from(value)
.expect("string console argument")
.to_rust_string_lossy(scope);
return serde_json::json!({
"type": "string",
"value": value,
Expand All @@ -158,68 +149,96 @@ pub(crate) fn console_arg_remote_object_json(
if value.is_function() {
return serde_json::json!({
"type": "function",
"description": value_description(scope, value),
"description": console_value_description(scope, value),
});
}
if value.is_symbol() {
let symbol = v8::Local::<v8::Symbol>::try_from(value).expect("symbol console argument");
let description = v8::Local::<v8::String>::try_from(symbol.description(scope))
.map(|description| description.to_rust_string_lossy(scope))
.unwrap_or_default();
return serde_json::json!({
"type": "symbol",
"description": value_description(scope, value),
"description": format!("Symbol({description})"),
});
}
if value.is_big_int() {
let mut description = value_description(scope, value);
// ToString of a primitive BigInt cannot invoke author conversion hooks.
let mut description = value
.to_string(scope)
.map(|value| value.to_rust_string_lossy(scope))
.unwrap_or_default();
description.push('n');
return serde_json::json!({
"type": "bigint",
"unserializableValue": description,
});
}

// This is the renderer-owned reporting snapshot, not the Inspector's
// RemoteObject. The original V8 console still supplies inspectable objectIds
// to CDP. Never serialize/coerce objects here: getters, toJSON, conversion
// hooks and Proxy traps belong to the page, not to log bookkeeping.
let subtype = if value.is_proxy() {
Some("proxy")
} else if value.is_array() {
Some("array")
} else if value.is_native_error() {
Some("error")
} else if value.is_reg_exp() {
Some("regexp")
} else if value.is_date() {
Some("date")
} else if value.is_promise() {
Some("promise")
} else if value.is_map() {
Some("map")
} else if value.is_set() {
Some("set")
} else if value.is_typed_array() {
Some("typedarray")
} else if value.is_array_buffer() {
Some("arraybuffer")
} else {
None
};
let mut object = serde_json::json!({
"type": "object",
"description": value_description(scope, value),
"type": "object", "description": console_value_description(scope, value)
});
if let Some(serialized) = json_serializable_console_value(scope, value)
&& let Some(object) = object.as_object_mut()
{
object.insert("value".to_owned(), serialized);
}
if value.is_array()
&& let Some(object) = object.as_object_mut()
{
object.insert(
"subtype".to_owned(),
serde_json::Value::String("array".to_owned()),
);
if let Some(subtype) = subtype {
object["subtype"] = serde_json::json!(subtype);
}
object
}

fn json_serializable_console_value(
fn console_value_description(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Option<serde_json::Value> {
let json = {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let scope = try_catch.init();
let body = v8::json::stringify(&scope, value)?;
let body = body.to_rust_string_lossy(&scope);
if body == "undefined" {
return None;
}
body
};
serde_json::from_str(&json).ok()
}

fn value_description(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<'_, v8::Value>) -> String {
) -> String {
// V8 implements ToDetailString with NoSideEffectsToString under a
// no-script scope. Unlike ToString, this cannot call author conversion
// hooks, but still retains useful native Error/function descriptions.
value
.to_string(scope)
.to_detail_string(scope)
.map(|value| value.to_rust_string_lossy(scope))
.unwrap_or_default()
}

fn console_arg_text(snapshot: &serde_json::Value) -> String {
if let Some(value) = snapshot.get("value") {
return value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string());
}
snapshot
.get("description")
.or_else(|| snapshot.get("unserializableValue"))
.and_then(serde_json::Value::as_str)
.unwrap_or("undefined")
.to_owned()
}

fn record_runtime_observable_console_source_event(
scope: &mut v8::PinScope<'_, '_>,
message: String,
Expand Down
51 changes: 45 additions & 6 deletions moli-renderer-v8/src/context_bootstrap/window_events/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ fn call_original_console_method<'s>(
forwarded_args.push(args.get(index));
}
let suppress_page_stack_hook = forwarded_args.iter().any(|value| value.is_native_error())
&& error_prepare_stack_trace_is_function(scope);
&& error_prepare_stack_trace_has_page_hook(scope);
if suppress_page_stack_hook {
scope.set_prepare_stack_trace_callback(inspector_console_stack_without_page_hook);
}
Expand All @@ -163,13 +163,52 @@ fn call_original_console_method<'s>(
}
}

fn error_prepare_stack_trace_is_function(scope: &mut v8::PinScope<'_, '_>) -> bool {
fn error_prepare_stack_trace_has_page_hook(scope: &mut v8::PinScope<'_, '_>) -> bool {
let global = scope.get_current_context().global(scope);
global
.get(scope, v8str(scope, "Error").into())
let Some(descriptor) = own_property_descriptor(scope, global, "Error") else {
return false;
};
// Inspect descriptor data, never the property itself. Looking for a page
// hook must not invoke an Error/prepareStackTrace accessor or Proxy trap.
if own_descriptor_value(scope, descriptor, "get").is_some_and(|v| v.is_function()) {
return true;
}
let Some(error) = own_descriptor_value(scope, descriptor, "value")
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
else {
return false;
};
if error.is_proxy() {
return true;
}
let Some(descriptor) = own_property_descriptor(scope, error, "prepareStackTrace") else {
return false;
};
["value", "get"].into_iter().any(|key| {
own_descriptor_value(scope, descriptor, key).is_some_and(|value| value.is_function())
})
}

fn own_property_descriptor<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
name: &'static str,
) -> Option<v8::Local<'s, v8::Object>> {
object
.get_own_property_descriptor(scope, v8str(scope, name).into())
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
.and_then(|error| error.get(scope, v8str(scope, "prepareStackTrace").into()))
.is_some_and(|value| value.is_function())
}

fn own_descriptor_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
descriptor: v8::Local<'s, v8::Object>,
name: &'static str,
) -> Option<v8::Local<'s, v8::Value>> {
let key = v8str(scope, name);
if descriptor.has_own_property(scope, key.into()) != Some(true) {
return None;
}
descriptor.get(scope, key.into())
}

fn inspector_console_stack_without_page_hook<'s>(
Expand Down
Loading
Loading