diff --git a/crates/ely_app/src/services/servo_live_ipc.rs b/crates/ely_app/src/services/servo_live_ipc.rs index 462ecfb..e4e60b3 100644 --- a/crates/ely_app/src/services/servo_live_ipc.rs +++ b/crates/ely_app/src/services/servo_live_ipc.rs @@ -10,11 +10,10 @@ use super::{ ServoLiveError, ServoLiveFrame, ServoLivePermissionGrant, wire::{ 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 { requests: Option>, thread: Option>, @@ -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] fn reply_parses_permission_consumption_without_a_frame() -> Result<(), ServoLiveError> { let profile_id = ely_domain::ProfileId::new(); diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs index 0c176b6..978068b 100644 --- a/crates/ely_app/src/services/servo_live_wire.rs +++ b/crates/ely_app/src/services/servo_live_wire.rs @@ -5,6 +5,7 @@ use super::ServoLiveSitePermission; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024; +pub(super) const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024; #[derive(Serialize)] #[serde(tag = "type", rename_all = "snake_case")] diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index f32b7f5..78a37f8 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -55,8 +55,8 @@ pub use profile::{Profile, ProfileKind, ProfileSyncPolicy}; pub use reading_list::{ReadingListEntry, ReadingProgress, ReadingProgressPercent}; pub use search::SearchEngine; pub use site_permission::{ - SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, - SitePermissionEntry, SitePermissionFeature, + MAX_SITE_ORIGIN_BYTES, SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, + SitePermissionDecision, SitePermissionEntry, SitePermissionFeature, }; pub use space::{ ArchivePolicy, COLLAPSED_SIDEBAR_WIDTH_PX, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX, diff --git a/crates/ely_domain/src/site_permission.rs b/crates/ely_domain/src/site_permission.rs index 1d50d40..52a7009 100644 --- a/crates/ely_domain/src/site_permission.rs +++ b/crates/ely_domain/src/site_permission.rs @@ -4,6 +4,8 @@ use url::Url; use crate::{DomainError, ProfileId, UrlText}; +pub const MAX_SITE_ORIGIN_BYTES: usize = 512; + #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct SiteOrigin(String); @@ -297,5 +299,36 @@ fn site_origin_from_url(url: &Url) -> Result { 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(".") + } } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index c9ac412..7f8e480 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, fs::{self, File, OpenOptions, TryLockError}, io::{self, BufRead}, path::Path, @@ -17,7 +17,7 @@ use super::{ live_output::write_outcome, live_protocol::{ 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::{ 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 active_profile = None; let mut handshake_complete = false; + let mut pending_permission_consumptions = VecDeque::new(); let stdin = io::stdin(); let mut stdout = io::stdout().lock(); @@ -78,9 +79,13 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> { request, ) }); - let outcome = outcome - .map(|outcome| outcome.with_permission_consumptions(host.take_consumed_permissions())); - write_outcome(&mut stdout, outcome)?; + pending_permission_consumptions.extend(host.take_consumed_permissions()); + let outcome = match 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 { break; } @@ -88,6 +93,11 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> { Ok(()) } +fn take_permission_batch(pending: &mut VecDeque) -> Vec { + let count = pending.len().min(MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE); + pending.drain(..count).collect() +} + fn acquire_profile_data_lease(profile_data_dir: &Path) -> Result { let lease = OpenOptions::new() .create(true) @@ -110,7 +120,7 @@ fn handle_request( active_profile: &mut Option, handshake_complete: &mut bool, 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, >, request: LiveRequest, @@ -154,6 +164,9 @@ fn handle_request( pending_surface_ids, } => { 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 profile = ProfileId::parse(profile_id)?; bind_profile(active_profile, &profile)?; @@ -205,7 +218,16 @@ fn handle_request( rendering_context_kind, &ready_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"))] let outcome = { let mut outcome = outcome; @@ -236,7 +258,16 @@ fn handle_request( rendering_context_kind, &ready_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"))] let outcome = { let mut outcome = outcome; @@ -270,6 +301,27 @@ fn handle_request( } } +fn retire_oversized_loaded_url_session( + host: &mut SoftwareServoHost, + sessions: &mut HashMap, + tab_id: &str, + outcome: &Result, + #[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( host: &mut SoftwareServoHost, session: &mut LiveSession, @@ -303,7 +355,7 @@ fn poll_software_frame( { let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?; 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::empty()); @@ -313,7 +365,7 @@ fn poll_software_frame( let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?; let frame = host.last_rendered_frame()?; 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)) } @@ -358,7 +410,7 @@ fn poll_hardware_frame( identity.height, session.device_pixel_ratio(), false, - ); + )?; return Ok(LiveOutcome::surface(report)); } HardwarePollAction::Empty => return Ok(LiveOutcome::empty()), @@ -380,7 +432,7 @@ fn poll_hardware_frame( identity.height, session.device_pixel_ratio(), true, - ); + )?; Ok(LiveOutcome::surface(report)) } @@ -414,35 +466,6 @@ fn hardware_poll_action( } } -#[cfg(all(test, feature = "hardware-render", target_os = "macos"))] -mod tests { - use 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 - ); - } -} +#[cfg(test)] +#[path = "live_tests.rs"] +mod tests; diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs index cacd09a..35f08fe 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs @@ -1,6 +1,12 @@ 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( stdout: &mut impl Write, @@ -17,8 +23,17 @@ pub(super) fn write_outcome( outcome = LiveOutcome::error(error.to_string()); 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")?; if let Some(frame) = outcome.frame.as_ref() { stdout.write_all(frame.rgba_bytes())?; @@ -27,6 +42,33 @@ pub(super) fn write_outcome( 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> { let expected = validated_frame_byte_count(width, height)?; if expected != actual { @@ -37,8 +79,107 @@ fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSide #[cfg(test)] mod tests { + use super::super::live_protocol::{ + LiveFrameReport, LivePermissionConsumption, MAX_LOADED_URL_BYTES, + MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE, + }; 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] fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> { let profile_id = ely_domain::ProfileId::new(); @@ -52,8 +193,7 @@ mod tests { None, ely_servo_host::WebViewSnapshotPending::new(false, false), ); - let report = - super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true); + let report = LiveFrameReport::new(&snapshot, &frame, 1.0, true)?; let mut output = Vec::new(); let outcome = LiveOutcome::frame(report, frame).with_permission_consumptions(vec![ ely_servo_host::ConsumedPermission { @@ -74,4 +214,24 @@ mod tests { assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7); 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) + } } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs index e1abaa6..124bc3e 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -13,6 +13,11 @@ use super::iosurface_mach::IOSurfaceMachError; pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3; pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384; 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)] #[serde(tag = "type", rename_all = "snake_case")] @@ -208,12 +213,12 @@ impl LiveFrameReport { frame: &RenderedFrame, device_pixel_ratio: f32, pixels_changed: bool, - ) -> Self { + ) -> Result { 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_height = css_dimension(frame.height(), device_pixel_ratio); - Self { - loaded_url: snapshot.url().map(str::to_string), + Ok(Self { + loaded_url: loaded_url_for_response(snapshot.url())?, title: snapshot.title().map(str::to_string), state: state_label(snapshot.state()), width: frame.width(), @@ -226,7 +231,7 @@ impl LiveFrameReport { non_white_pixel_count: frame.non_white_pixel_count(), content_pixel_count: frame.content_pixel_count(), sample_hash: frame.sample_hash(), - } + }) } #[cfg(all(feature = "hardware-render", target_os = "macos"))] @@ -236,10 +241,10 @@ impl LiveFrameReport { height: u32, device_pixel_ratio: f32, pixels_changed: bool, - ) -> Self { + ) -> Result { let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio); - Self { - loaded_url: snapshot.url().map(str::to_string), + Ok(Self { + loaded_url: loaded_url_for_response(snapshot.url())?, title: snapshot.title().map(str::to_string), state: state_label(snapshot.state()), width, @@ -252,7 +257,17 @@ impl LiveFrameReport { non_white_pixel_count: 0, content_pixel_count: 0, sample_hash: 0, + }) + } +} + +fn loaded_url_for_response(value: Option<&str>) -> Result, 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}")] 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"))] #[error("hardware rendering requires --iosurface-mach-service")] IOSurfaceMachServiceRequired, diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_tests.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_tests.rs new file mode 100644 index 0000000..5057673 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_tests.rs @@ -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::>(); + + assert_eq!(take_permission_batch(&mut pending), (1_u8..=8).collect::>()); + 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 + ); + } +} diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index e3fd441..ed49987 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -4,6 +4,8 @@ use ely_domain::{ use crate::ServoHostError; +pub const MAX_PAGE_TITLE_BYTES: usize = 4 * 1024; + #[derive(Clone, Debug, Eq, PartialEq)] pub enum WebViewState { Created, diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 2a22f8d..ba495dc 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -20,11 +20,11 @@ pub use error::ServoHostError; #[cfg(feature = "hardware-render")] pub use hardware_rendering_context::HardwareOffscreenContext; pub use host::{ - ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, - MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, - PermissionSnapshotEntry, PermissionSnapshotRequest, PermissionSnapshotState, RenderedFrame, - RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest, - WebViewSnapshot, WebViewSnapshotPending, WebViewState, + ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MAX_PAGE_TITLE_BYTES, + MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, + PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest, + PermissionSnapshotState, RenderedFrame, RenderedFrameSummary, ResizeRequest, ScrollRequest, + ServoHost, TouchTapRequest, WebViewSnapshot, WebViewSnapshotPending, WebViewState, }; pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; #[cfg(feature = "servo-engine")] diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index 45349bc..d6ec87a 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -5,7 +5,8 @@ use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate}; use url::Url; use crate::{ - PermissionDecision, WebViewSnapshot, WebViewSnapshotPending, WebViewState, + MAX_PAGE_TITLE_BYTES, PermissionDecision, WebViewSnapshot, WebViewSnapshotPending, + WebViewState, runtime_permissions::{PermissionStore, permission_decision_for_webview}, }; @@ -56,7 +57,7 @@ impl HostWebView { } fn current_title(&self) -> Option { - 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) { - self.title.replace(title); + self.title.replace(title.map(bound_page_title)); 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 { fn notify_url_changed(&self, _webview: WebView, url: Url) { self.record_url_change(url.to_string()); @@ -209,4 +223,15 @@ mod tests { assert!(!delegate.has_pending_frame()); 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('…')); + } } diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs index 13621c9..88211b4 100644 --- a/crates/ely_servo_host/tests/sidecar.rs +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -190,6 +190,81 @@ fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box Result<(), Box> { + 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> { + 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] fn live_sidecar_rejects_duplicate_permission_snapshot_entries() -> Result<(), Box> { let root = TestDirectory::new()?; diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs index 070a236..c227262 100644 --- a/crates/ely_servo_host/tests/sidecar/support.rs +++ b/crates/ely_servo_host/tests/sidecar/support.rs @@ -465,6 +465,8 @@ fn serve_connection( SET_PAGE } else if path.starts_with("/history") { HISTORY_PAGE + } else if path == "/oversized-history" { + OVERSIZED_HISTORY_PAGE } else if path == "/white" { WHITE_PAGE } else { @@ -486,4 +488,6 @@ const READ_PAGE: &str = r#"loadingHistory mutation"#; +const OVERSIZED_HISTORY_PAGE: &str = r#"oversized-history"#; + const WHITE_PAGE: &str = r#"white-ready"#;