fix(servo): bound live protocol metadata

This commit is contained in:
2026-07-09 23:32:17 -04:00
parent a6e3aeaf46
commit 83badaea81
13 changed files with 482 additions and 68 deletions
+25 -2
View File
@@ -10,11 +10,10 @@ use super::{
ServoLiveError, ServoLiveFrame, ServoLivePermissionGrant, ServoLiveError, ServoLiveFrame, ServoLivePermissionGrant,
wire::{ wire::{
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION, LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
MAX_RESPONSE_HEADER_BYTES,
}, },
}; };
const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
pub(super) struct ServoLiveIpc { pub(super) struct ServoLiveIpc {
requests: Option<Sender<IpcRequest>>, requests: Option<Sender<IpcRequest>>,
thread: Option<JoinHandle<()>>, thread: Option<JoinHandle<()>>,
@@ -257,6 +256,30 @@ mod tests {
)); ));
} }
#[test]
fn reply_accepts_the_exact_header_limit() -> Result<(), ServoLiveError> {
let mut header = br#"{"protocol_version":3,"error":null,"frame":null}"#.to_vec();
header.resize(MAX_RESPONSE_HEADER_BYTES - 1, b' ');
header.push(b'\n');
let reply = read_reply(&mut Cursor::new(header))?;
assert_eq!(reply.protocol_version, Some(3));
Ok(())
}
#[test]
fn reply_rejects_one_byte_over_the_header_limit() {
let mut header = br#"{"protocol_version":3,"error":null,"frame":null}"#.to_vec();
header.resize(MAX_RESPONSE_HEADER_BYTES, b' ');
header.push(b'\n');
assert!(matches!(
read_reply(&mut Cursor::new(header)),
Err(ServoLiveError::ResponseHeaderTooLarge { limit: MAX_RESPONSE_HEADER_BYTES })
));
}
#[test] #[test]
fn reply_parses_permission_consumption_without_a_frame() -> Result<(), ServoLiveError> { fn reply_parses_permission_consumption_without_a_frame() -> Result<(), ServoLiveError> {
let profile_id = ely_domain::ProfileId::new(); let profile_id = ely_domain::ProfileId::new();
@@ -5,6 +5,7 @@ use super::ServoLiveSitePermission;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
pub(super) const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
#[derive(Serialize)] #[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
+2 -2
View File
@@ -55,8 +55,8 @@ pub use profile::{Profile, ProfileKind, ProfileSyncPolicy};
pub use reading_list::{ReadingListEntry, ReadingProgress, ReadingProgressPercent}; pub use reading_list::{ReadingListEntry, ReadingProgress, ReadingProgressPercent};
pub use search::SearchEngine; pub use search::SearchEngine;
pub use site_permission::{ pub use site_permission::{
SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, MAX_SITE_ORIGIN_BYTES, SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent,
SitePermissionEntry, SitePermissionFeature, SitePermissionDecision, SitePermissionEntry, SitePermissionFeature,
}; };
pub use space::{ pub use space::{
ArchivePolicy, COLLAPSED_SIDEBAR_WIDTH_PX, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX, ArchivePolicy, COLLAPSED_SIDEBAR_WIDTH_PX, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX,
+34 -1
View File
@@ -4,6 +4,8 @@ use url::Url;
use crate::{DomainError, ProfileId, UrlText}; use crate::{DomainError, ProfileId, UrlText};
pub const MAX_SITE_ORIGIN_BYTES: usize = 512;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SiteOrigin(String); pub struct SiteOrigin(String);
@@ -297,5 +299,36 @@ fn site_origin_from_url(url: &Url) -> Result<SiteOrigin, DomainError> {
return Err(DomainError::InvalidSiteOrigin { value: url.to_string() }); return Err(DomainError::InvalidSiteOrigin { value: url.to_string() });
} }
Ok(SiteOrigin(url.origin().ascii_serialization())) let origin = url.origin().ascii_serialization();
if origin.len() > MAX_SITE_ORIGIN_BYTES {
return Err(DomainError::InvalidSiteOrigin { value: origin });
}
Ok(SiteOrigin(origin))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_site_origin_accepts_512_bytes_and_rejects_513() -> Result<(), DomainError> {
let exact = SiteOrigin::parse(format!("https://{}", ascii_host(504)))?;
assert_eq!(exact.as_str().len(), MAX_SITE_ORIGIN_BYTES);
assert!(matches!(
SiteOrigin::parse(format!("https://{}", ascii_host(505))),
Err(DomainError::InvalidSiteOrigin { .. })
));
Ok(())
}
fn ascii_host(mut bytes: usize) -> String {
let mut labels = Vec::new();
while bytes > 63 {
labels.push("a".repeat(63));
bytes -= 64;
}
labels.push("a".repeat(bytes));
labels.join(".")
}
} }
@@ -1,5 +1,5 @@
use std::{ use std::{
collections::HashMap, collections::{HashMap, VecDeque},
fs::{self, File, OpenOptions, TryLockError}, fs::{self, File, OpenOptions, TryLockError},
io::{self, BufRead}, io::{self, BufRead},
path::Path, path::Path,
@@ -17,7 +17,7 @@ use super::{
live_output::write_outcome, live_output::write_outcome,
live_protocol::{ live_protocol::{
LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError, LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError,
validated_frame_byte_count, MAX_LOADED_URL_BYTES, MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE, validated_frame_byte_count,
}, },
live_session::{ live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile, LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile,
@@ -55,6 +55,7 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
let mut sessions = HashMap::new(); let mut sessions = HashMap::new();
let mut active_profile = None; let mut active_profile = None;
let mut handshake_complete = false; let mut handshake_complete = false;
let mut pending_permission_consumptions = VecDeque::new();
let stdin = io::stdin(); let stdin = io::stdin();
let mut stdout = io::stdout().lock(); let mut stdout = io::stdout().lock();
@@ -78,9 +79,13 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
request, request,
) )
}); });
let outcome = outcome pending_permission_consumptions.extend(host.take_consumed_permissions());
.map(|outcome| outcome.with_permission_consumptions(host.take_consumed_permissions())); let outcome = match outcome {
write_outcome(&mut stdout, outcome)?; Ok(outcome) => outcome,
Err(error) => LiveOutcome::error(error.to_string()),
}
.with_permission_consumptions(take_permission_batch(&mut pending_permission_consumptions));
write_outcome(&mut stdout, Ok(outcome))?;
if should_shutdown { if should_shutdown {
break; break;
} }
@@ -88,6 +93,11 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
Ok(()) Ok(())
} }
fn take_permission_batch<T>(pending: &mut VecDeque<T>) -> Vec<T> {
let count = pending.len().min(MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE);
pending.drain(..count).collect()
}
fn acquire_profile_data_lease(profile_data_dir: &Path) -> Result<File, LiveSidecarError> { fn acquire_profile_data_lease(profile_data_dir: &Path) -> Result<File, LiveSidecarError> {
let lease = OpenOptions::new() let lease = OpenOptions::new()
.create(true) .create(true)
@@ -110,7 +120,7 @@ fn handle_request(
active_profile: &mut Option<ProfileId>, active_profile: &mut Option<ProfileId>,
handshake_complete: &mut bool, handshake_complete: &mut bool,
rendering_context_kind: RenderingContextKind, rendering_context_kind: RenderingContextKind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option< #[cfg(all(feature = "hardware-render", target_os = "macos"))] mut hardware_transport: Option<
&mut HardwareSurfaceTransport, &mut HardwareSurfaceTransport,
>, >,
request: LiveRequest, request: LiveRequest,
@@ -154,6 +164,9 @@ fn handle_request(
pending_surface_ids, pending_surface_ids,
} => { } => {
validated_frame_byte_count(width, height)?; validated_frame_byte_count(width, height)?;
if url.len() > MAX_LOADED_URL_BYTES {
return Err(LiveSidecarError::RequestUrlTooLong { limit: MAX_LOADED_URL_BYTES });
}
let tab = TabId::parse(tab_id.clone())?; let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?; let profile = ProfileId::parse(profile_id)?;
bind_profile(active_profile, &profile)?; bind_profile(active_profile, &profile)?;
@@ -205,7 +218,16 @@ fn handle_request(
rendering_context_kind, rendering_context_kind,
&ready_surface_ids, &ready_surface_ids,
&pending_surface_ids, &pending_surface_ids,
)?; );
retire_oversized_loaded_url_session(
host,
sessions,
&tab_id,
&outcome,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_deref_mut(),
);
let outcome = outcome?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = { let outcome = {
let mut outcome = outcome; let mut outcome = outcome;
@@ -236,7 +258,16 @@ fn handle_request(
rendering_context_kind, rendering_context_kind,
&ready_surface_ids, &ready_surface_ids,
&pending_surface_ids, &pending_surface_ids,
)?; );
retire_oversized_loaded_url_session(
host,
sessions,
&tab_id,
&outcome,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_deref_mut(),
);
let outcome = outcome?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = { let outcome = {
let mut outcome = outcome; let mut outcome = outcome;
@@ -270,6 +301,27 @@ fn handle_request(
} }
} }
fn retire_oversized_loaded_url_session(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
tab_id: &str,
outcome: &Result<LiveOutcome, LiveSidecarError>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
&mut HardwareSurfaceTransport,
>,
) {
if !matches!(outcome, Err(LiveSidecarError::LoadedUrlTooLong { .. })) {
return;
}
if let Some(session) = sessions.remove(tab_id) {
host.close_webview(&session.webview_id);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
if let Some(transport) = hardware_transport {
transport.close_tab(tab_id);
}
}
fn poll_frame( fn poll_frame(
host: &mut SoftwareServoHost, host: &mut SoftwareServoHost,
session: &mut LiveSession, session: &mut LiveSession,
@@ -303,7 +355,7 @@ fn poll_software_frame(
{ {
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?; let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report = let report =
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false); LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false)?;
return Ok(LiveOutcome::frame(report, frame)); return Ok(LiveOutcome::frame(report, frame));
} }
return Ok(LiveOutcome::empty()); return Ok(LiveOutcome::empty());
@@ -313,7 +365,7 @@ fn poll_software_frame(
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?; let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let frame = host.last_rendered_frame()?; let frame = host.last_rendered_frame()?;
session.last_frame = Some(frame.clone()); session.last_frame = Some(frame.clone());
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true); let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true)?;
Ok(LiveOutcome::frame(report, frame)) Ok(LiveOutcome::frame(report, frame))
} }
@@ -358,7 +410,7 @@ fn poll_hardware_frame(
identity.height, identity.height,
session.device_pixel_ratio(), session.device_pixel_ratio(),
false, false,
); )?;
return Ok(LiveOutcome::surface(report)); return Ok(LiveOutcome::surface(report));
} }
HardwarePollAction::Empty => return Ok(LiveOutcome::empty()), HardwarePollAction::Empty => return Ok(LiveOutcome::empty()),
@@ -380,7 +432,7 @@ fn poll_hardware_frame(
identity.height, identity.height,
session.device_pixel_ratio(), session.device_pixel_ratio(),
true, true,
); )?;
Ok(LiveOutcome::surface(report)) Ok(LiveOutcome::surface(report))
} }
@@ -414,35 +466,6 @@ fn hardware_poll_action(
} }
} }
#[cfg(all(test, feature = "hardware-render", target_os = "macos"))] #[cfg(test)]
mod tests { #[path = "live_tests.rs"]
use super::{HardwarePollAction, hardware_poll_action}; mod tests;
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
@@ -1,6 +1,12 @@
use std::io::Write; use std::io::Write;
use super::live_protocol::{LiveOutcome, LiveSidecarError, validated_frame_byte_count}; use super::live_protocol::{
LiveOutcome, LiveResponse, LiveSidecarError, MAX_ERROR_BYTES, MAX_RESPONSE_HEADER_BYTES,
MAX_TITLE_BYTES, validated_frame_byte_count,
};
const TITLE_TRUNCATION_SUFFIX: &str = "";
const ERROR_TRUNCATION_SUFFIX: &str = "… [truncated]";
pub(super) fn write_outcome( pub(super) fn write_outcome(
stdout: &mut impl Write, stdout: &mut impl Write,
@@ -17,8 +23,17 @@ pub(super) fn write_outcome(
outcome = LiveOutcome::error(error.to_string()); outcome = LiveOutcome::error(error.to_string());
outcome.response.permission_consumptions = consumptions; outcome.response.permission_consumptions = consumptions;
} }
bound_response_text(&mut outcome.response);
serde_json::to_writer(&mut *stdout, &outcome.response)?; let header = serde_json::to_vec(&outcome.response)?;
let wire_bytes = header.len().saturating_add(1);
if wire_bytes > MAX_RESPONSE_HEADER_BYTES {
return Err(LiveSidecarError::ResponseHeaderTooLarge {
bytes: wire_bytes,
limit: MAX_RESPONSE_HEADER_BYTES,
});
}
stdout.write_all(&header)?;
stdout.write_all(b"\n")?; stdout.write_all(b"\n")?;
if let Some(frame) = outcome.frame.as_ref() { if let Some(frame) = outcome.frame.as_ref() {
stdout.write_all(frame.rgba_bytes())?; stdout.write_all(frame.rgba_bytes())?;
@@ -27,6 +42,33 @@ pub(super) fn write_outcome(
Ok(()) Ok(())
} }
fn bound_response_text(response: &mut LiveResponse) {
response.error = response
.error
.take()
.map(|error| truncate_utf8(error, MAX_ERROR_BYTES, ERROR_TRUNCATION_SUFFIX));
if let Some(frame) = response.frame.as_mut() {
frame.title = frame
.title
.take()
.map(|title| truncate_utf8(title, MAX_TITLE_BYTES, TITLE_TRUNCATION_SUFFIX));
}
}
fn truncate_utf8(mut value: String, limit: usize, suffix: &str) -> String {
if value.len() <= limit {
return value;
}
let mut end = limit - suffix.len();
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
value.push_str(suffix);
value
}
fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> { fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> {
let expected = validated_frame_byte_count(width, height)?; let expected = validated_frame_byte_count(width, height)?;
if expected != actual { if expected != actual {
@@ -37,8 +79,107 @@ fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSide
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::live_protocol::{
LiveFrameReport, LivePermissionConsumption, MAX_LOADED_URL_BYTES,
MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE,
};
use super::*; use super::*;
#[test]
fn overlong_utf8_title_stays_within_the_app_header_limit() -> Result<(), LiveSidecarError> {
let frame = one_pixel_frame();
let snapshot = snapshot(Some("https://example.com"), Some(&"\"\n".repeat(40_000)));
let report = LiveFrameReport::new(&snapshot, &frame, 1.0, true)?;
let mut output = Vec::new();
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
assert!(header_bytes(&output) <= MAX_RESPONSE_HEADER_BYTES);
Ok(())
}
#[test]
fn wire_text_preserves_exact_boundary_and_marks_ascii_overflow() {
let exact = "a".repeat(MAX_TITLE_BYTES);
assert_eq!(truncate_utf8(exact.clone(), MAX_TITLE_BYTES, TITLE_TRUNCATION_SUFFIX), exact);
let overflow = truncate_utf8(
"a".repeat(MAX_TITLE_BYTES + 1),
MAX_TITLE_BYTES,
TITLE_TRUNCATION_SUFFIX,
);
assert_eq!(overflow.len(), MAX_TITLE_BYTES);
assert!(overflow.ends_with(TITLE_TRUNCATION_SUFFIX));
}
#[test]
fn error_text_truncates_on_a_utf8_boundary() {
let mut response = LiveOutcome::error("".repeat(MAX_ERROR_BYTES)).response;
bound_response_text(&mut response);
let error = response.error.as_deref().unwrap_or_default();
assert!(error.len() <= MAX_ERROR_BYTES);
assert!(error.ends_with(ERROR_TRUNCATION_SUFFIX));
}
#[test]
fn escape_heavy_maximum_frame_and_permission_batch_fit_header() -> Result<(), LiveSidecarError>
{
let frame = one_pixel_frame();
let report = LiveFrameReport {
loaded_url: Some("\u{2}".repeat(MAX_LOADED_URL_BYTES)),
title: Some("\u{1}".repeat(MAX_TITLE_BYTES)),
state: "complete",
width: 1,
height: 1,
device_pixel_ratio: 1.0,
css_viewport_width: 1,
css_viewport_height: 1,
rgba_byte_count: 4,
pixels_changed: true,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
};
let mut outcome = LiveOutcome::frame(report, frame);
let profile_id = ely_domain::ProfileId::new().as_str().to_string();
outcome.response.permission_consumptions = (0..MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE)
.map(|revision| LivePermissionConsumption {
profile_id: profile_id.clone(),
origin: "\u{3}".repeat(ely_domain::MAX_SITE_ORIGIN_BYTES),
feature: "storage-persistence".to_string(),
grant_revision: revision as u64,
})
.collect();
let mut output = Vec::new();
write_outcome(&mut output, Ok(outcome))?;
assert!(header_bytes(&output) <= MAX_RESPONSE_HEADER_BYTES);
Ok(())
}
#[test]
fn loaded_url_preserves_exact_boundary_and_rejects_overflow() -> Result<(), LiveSidecarError> {
let prefix = "https://example.com/";
let exact_url = format!("{prefix}{}", "a".repeat(MAX_LOADED_URL_BYTES - prefix.len()));
assert!(url::Url::parse(&exact_url).is_ok());
let exact_snapshot = snapshot(Some(&exact_url), None);
let exact = LiveFrameReport::new(&exact_snapshot, &one_pixel_frame(), 1.0, true)?;
assert_eq!(exact.loaded_url.as_deref(), Some(exact_url.as_str()));
let overflow_url =
format!("{prefix}{}", "a".repeat(MAX_LOADED_URL_BYTES + 1 - prefix.len()));
assert!(url::Url::parse(&overflow_url).is_ok());
let overflow_snapshot = snapshot(Some(&overflow_url), None);
assert!(matches!(
LiveFrameReport::new(&overflow_snapshot, &one_pixel_frame(), 1.0, true),
Err(LiveSidecarError::LoadedUrlTooLong { limit: MAX_LOADED_URL_BYTES })
));
Ok(())
}
#[test] #[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> { fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let profile_id = ely_domain::ProfileId::new(); let profile_id = ely_domain::ProfileId::new();
@@ -52,8 +193,7 @@ mod tests {
None, None,
ely_servo_host::WebViewSnapshotPending::new(false, false), ely_servo_host::WebViewSnapshotPending::new(false, false),
); );
let report = let report = LiveFrameReport::new(&snapshot, &frame, 1.0, true)?;
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
let mut output = Vec::new(); let mut output = Vec::new();
let outcome = LiveOutcome::frame(report, frame).with_permission_consumptions(vec![ let outcome = LiveOutcome::frame(report, frame).with_permission_consumptions(vec![
ely_servo_host::ConsumedPermission { ely_servo_host::ConsumedPermission {
@@ -74,4 +214,24 @@ mod tests {
assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7); assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7);
Ok(()) Ok(())
} }
fn one_pixel_frame() -> ely_servo_host::RenderedFrame {
ely_servo_host::RenderedFrame::from_rgba_bytes(1, 1, vec![255; 4])
}
fn snapshot(url: Option<&str>, title: Option<&str>) -> ely_servo_host::WebViewSnapshot {
ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(),
ely_domain::TabId::new(),
ely_domain::ProfileId::new(),
ely_servo_host::WebViewState::Complete,
url.map(str::to_string),
title.map(str::to_string),
ely_servo_host::WebViewSnapshotPending::new(false, false),
)
}
fn header_bytes(output: &[u8]) -> usize {
output.iter().position(|byte| *byte == b'\n').map_or(0, |end| end + 1)
}
} }
@@ -13,6 +13,11 @@ use super::iosurface_mach::IOSurfaceMachError;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
pub(super) const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
pub(super) const MAX_TITLE_BYTES: usize = ely_servo_host::MAX_PAGE_TITLE_BYTES;
pub(super) const MAX_LOADED_URL_BYTES: usize = 32 * 1024;
pub(super) const MAX_ERROR_BYTES: usize = 32 * 1024;
pub(super) const MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE: usize = 8;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@@ -208,12 +213,12 @@ impl LiveFrameReport {
frame: &RenderedFrame, frame: &RenderedFrame,
device_pixel_ratio: f32, device_pixel_ratio: f32,
pixels_changed: bool, pixels_changed: bool,
) -> Self { ) -> Result<Self, LiveSidecarError> {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio); let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio); let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio);
let css_viewport_height = css_dimension(frame.height(), device_pixel_ratio); let css_viewport_height = css_dimension(frame.height(), device_pixel_ratio);
Self { Ok(Self {
loaded_url: snapshot.url().map(str::to_string), loaded_url: loaded_url_for_response(snapshot.url())?,
title: snapshot.title().map(str::to_string), title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()), state: state_label(snapshot.state()),
width: frame.width(), width: frame.width(),
@@ -226,7 +231,7 @@ impl LiveFrameReport {
non_white_pixel_count: frame.non_white_pixel_count(), non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(), content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(), sample_hash: frame.sample_hash(),
} })
} }
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
@@ -236,10 +241,10 @@ impl LiveFrameReport {
height: u32, height: u32,
device_pixel_ratio: f32, device_pixel_ratio: f32,
pixels_changed: bool, pixels_changed: bool,
) -> Self { ) -> Result<Self, LiveSidecarError> {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio); let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
Self { Ok(Self {
loaded_url: snapshot.url().map(str::to_string), loaded_url: loaded_url_for_response(snapshot.url())?,
title: snapshot.title().map(str::to_string), title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()), state: state_label(snapshot.state()),
width, width,
@@ -252,7 +257,17 @@ impl LiveFrameReport {
non_white_pixel_count: 0, non_white_pixel_count: 0,
content_pixel_count: 0, content_pixel_count: 0,
sample_hash: 0, sample_hash: 0,
})
} }
}
fn loaded_url_for_response(value: Option<&str>) -> Result<Option<String>, LiveSidecarError> {
match value {
Some(url) if url.len() > MAX_LOADED_URL_BYTES => {
Err(LiveSidecarError::LoadedUrlTooLong { limit: MAX_LOADED_URL_BYTES })
}
Some(url) => Ok(Some(url.to_string())),
None => Ok(None),
} }
} }
@@ -282,6 +297,15 @@ pub(super) enum LiveSidecarError {
#[error("live protocol mismatch: expected {expected}, received {actual}")] #[error("live protocol mismatch: expected {expected}, received {actual}")]
ProtocolVersionMismatch { expected: u32, actual: u32 }, ProtocolVersionMismatch { expected: u32, actual: u32 },
#[error("request URL exceeds the {limit}-byte live protocol limit")]
RequestUrlTooLong { limit: usize },
#[error("loaded URL exceeds the {limit}-byte live protocol limit")]
LoadedUrlTooLong { limit: usize },
#[error("response header requires {bytes} bytes; the live protocol limit is {limit}")]
ResponseHeaderTooLarge { bytes: usize, limit: usize },
#[cfg(all(feature = "hardware-render", target_os = "macos"))] #[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware rendering requires --iosurface-mach-service")] #[error("hardware rendering requires --iosurface-mach-service")]
IOSurfaceMachServiceRequired, IOSurfaceMachServiceRequired,
@@ -0,0 +1,44 @@
use std::collections::VecDeque;
use super::take_permission_batch;
#[test]
fn nine_permission_consumptions_are_sent_in_order_across_two_responses() {
let mut pending = (1_u8..=9).collect::<VecDeque<_>>();
assert_eq!(take_permission_batch(&mut pending), (1_u8..=8).collect::<Vec<_>>());
assert_eq!(take_permission_batch(&mut pending), vec![9]);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
mod hardware {
use super::super::{HardwarePollAction, hardware_poll_action};
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
+2
View File
@@ -4,6 +4,8 @@ use ely_domain::{
use crate::ServoHostError; use crate::ServoHostError;
pub const MAX_PAGE_TITLE_BYTES: usize = 4 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum WebViewState { pub enum WebViewState {
Created, Created,
+5 -5
View File
@@ -20,11 +20,11 @@ pub use error::ServoHostError;
#[cfg(feature = "hardware-render")] #[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext; pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{ pub use host::{
ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MAX_PAGE_TITLE_BYTES,
MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest,
PermissionSnapshotEntry, PermissionSnapshotRequest, PermissionSnapshotState, RenderedFrame, PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest, PermissionSnapshotState, RenderedFrame, RenderedFrameSummary, ResizeRequest, ScrollRequest,
WebViewSnapshot, WebViewSnapshotPending, WebViewState, ServoHost, TouchTapRequest, WebViewSnapshot, WebViewSnapshotPending, WebViewState,
}; };
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(feature = "servo-engine")] #[cfg(feature = "servo-engine")]
+28 -3
View File
@@ -5,7 +5,8 @@ use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate};
use url::Url; use url::Url;
use crate::{ use crate::{
PermissionDecision, WebViewSnapshot, WebViewSnapshotPending, WebViewState, MAX_PAGE_TITLE_BYTES, PermissionDecision, WebViewSnapshot, WebViewSnapshotPending,
WebViewState,
runtime_permissions::{PermissionStore, permission_decision_for_webview}, runtime_permissions::{PermissionStore, permission_decision_for_webview},
}; };
@@ -56,7 +57,7 @@ impl HostWebView {
} }
fn current_title(&self) -> Option<String> { fn current_title(&self) -> Option<String> {
self.webview.page_title().or_else(|| self.delegate.title()) self.delegate.title().or_else(|| self.webview.page_title().map(bound_page_title))
} }
} }
@@ -105,7 +106,7 @@ impl HostWebViewDelegate {
} }
fn record_title_change(&self, title: Option<String>) { fn record_title_change(&self, title: Option<String>) {
self.title.replace(title); self.title.replace(title.map(bound_page_title));
self.has_pending_metadata.set(true); self.has_pending_metadata.set(true);
} }
@@ -139,6 +140,19 @@ impl HostWebViewDelegate {
} }
} }
fn bound_page_title(mut title: String) -> String {
if title.len() <= MAX_PAGE_TITLE_BYTES {
return title;
}
let mut end = MAX_PAGE_TITLE_BYTES - "".len();
while !title.is_char_boundary(end) {
end -= 1;
}
title.truncate(end);
title.push('…');
title
}
impl WebViewDelegate for HostWebViewDelegate { impl WebViewDelegate for HostWebViewDelegate {
fn notify_url_changed(&self, _webview: WebView, url: Url) { fn notify_url_changed(&self, _webview: WebView, url: Url) {
self.record_url_change(url.to_string()); self.record_url_change(url.to_string());
@@ -209,4 +223,15 @@ mod tests {
assert!(!delegate.has_pending_frame()); assert!(!delegate.has_pending_frame());
assert!(delegate.has_pending_metadata()); assert!(delegate.has_pending_metadata());
} }
#[test]
fn snapshot_title_source_bounds_multibyte_metadata() {
let delegate = HostWebViewDelegate::new(ProfileId::new(), PermissionStore::default());
delegate.record_title_change(Some("".repeat(super::MAX_PAGE_TITLE_BYTES)));
let title = delegate.title().unwrap_or_default();
assert!(title.len() <= super::MAX_PAGE_TITLE_BYTES);
assert!(title.ends_with('…'));
}
} }
+75
View File
@@ -190,6 +190,81 @@ fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box<dyn Error
Ok(()) Ok(())
} }
#[test]
fn live_sidecar_rejects_overlong_request_url_without_exiting() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let overlong_url = format!("https://example.com/{}", "a".repeat(32 * 1024));
let response = sidecar.exchange(&ensure_request(&tab_id, &profile_id, &overlong_url))?;
assert!(response.frame.is_none());
assert!(
response
.error
.as_deref()
.is_some_and(|error| error == "request URL exceeds the 32768-byte live protocol limit")
);
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_retires_a_session_with_an_overlong_loaded_url() -> Result<(), Box<dyn Error>> {
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let mut response = sidecar.exchange(&ensure_request(
&tab_id,
&profile_id,
&server.url("/oversized-history"),
))?;
let started_at = Instant::now();
loop {
if response.error.as_deref()
== Some("loaded URL exceeds the 32768-byte live protocol limit")
{
assert!(response.frame.is_none());
break;
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for oversized loaded URL rejection",
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
let mut response =
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &server.url("/white")))?;
let started_at = Instant::now();
while response.frame.as_ref().and_then(|frame| frame.title.as_deref()) != Some("white-ready") {
if let Some(error) = response.error {
return Err(io::Error::other(format!("replacement session failed: {error}")).into());
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for replacement session",
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
sidecar.shutdown()?;
Ok(())
}
#[test] #[test]
fn live_sidecar_rejects_duplicate_permission_snapshot_entries() -> Result<(), Box<dyn Error>> { fn live_sidecar_rejects_duplicate_permission_snapshot_entries() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?; let root = TestDirectory::new()?;
@@ -465,6 +465,8 @@ fn serve_connection(
SET_PAGE SET_PAGE
} else if path.starts_with("/history") { } else if path.starts_with("/history") {
HISTORY_PAGE HISTORY_PAGE
} else if path == "/oversized-history" {
OVERSIZED_HISTORY_PAGE
} else if path == "/white" { } else if path == "/white" {
WHITE_PAGE WHITE_PAGE
} else { } else {
@@ -486,4 +488,6 @@ const READ_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font
const HISTORY_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>History mutation</body><script>history.replaceState({},'', '/history?state=1');document.title='history-ready';</script>"#; const HISTORY_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>History mutation</body><script>history.replaceState({},'', '/history?state=1');document.title='history-ready';</script>"#;
const OVERSIZED_HISTORY_PAGE: &str = r#"<!doctype html><title>oversized-history</title><script>history.replaceState({},'', '/oversized?' + 'a'.repeat(32768));</script>"#;
const WHITE_PAGE: &str = r#"<!doctype html><title>white-ready</title><style>html,body{margin:0;width:100%;height:100%;background:#fff}</style>"#; const WHITE_PAGE: &str = r#"<!doctype html><title>white-ready</title><style>html,body{margin:0;width:100%;height:100%;background:#fff}</style>"#;