fix(permissions): make profile snapshots authoritative

This commit is contained in:
2026-07-09 21:56:36 -04:00
parent c28ec2bee8
commit b422ac1631
47 changed files with 1918 additions and 403 deletions
@@ -74,6 +74,8 @@ 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)?;
if should_shutdown {
break;
@@ -126,6 +128,7 @@ fn handle_request(
hover_x,
hover_y,
typed_text,
site_permission_generation,
site_permissions,
ready_surface_ids,
pending_surface_ids,
@@ -138,7 +141,13 @@ fn handle_request(
let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?;
apply_permissions(host, session, &profile, site_permissions)?;
apply_permissions(
host,
session,
&profile,
site_permission_generation,
site_permissions,
)?;
if session.requested_url != url.as_str() {
let servo_current_url =
host.snapshot(&session.webview_id)?.url().map(str::to_string);
@@ -13,7 +13,9 @@ pub(super) fn write_outcome(
if let Some(frame) = outcome.frame.as_ref()
&& let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len())
{
let consumptions = std::mem::take(&mut outcome.response.permission_consumptions);
outcome = LiveOutcome::error(error.to_string());
outcome.response.permission_consumptions = consumptions;
}
serde_json::to_writer(&mut *stdout, &outcome.response)?;
@@ -39,11 +41,12 @@ mod tests {
#[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let profile_id = ely_domain::ProfileId::new();
let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]);
let snapshot = ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(),
ely_domain::TabId::new(),
ely_domain::ProfileId::new(),
profile_id.clone(),
ely_servo_host::WebViewState::Complete,
None,
None,
@@ -52,13 +55,23 @@ mod tests {
let report =
super::super::live_protocol::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 {
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse("https://example.com")?,
feature: ely_domain::SitePermissionFeature::Camera,
grant_revision: 7,
},
]);
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
write_outcome(&mut output, Ok(outcome))?;
assert!(output.ends_with(b"\n"));
let response: serde_json::Value = serde_json::from_slice(&output)?;
assert!(response["error"].as_str().is_some());
assert!(response["frame"].is_null());
assert_eq!(response["permission_consumptions"][0]["profile_id"], profile_id.as_str());
assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7);
Ok(())
}
}
@@ -1,7 +1,8 @@
use std::io;
use ely_servo_host::{
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
ConsumedPermission, IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot,
WebViewState,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -9,7 +10,7 @@ use thiserror::Error;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::IOSurfaceMachError;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
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;
@@ -47,6 +48,7 @@ pub(super) enum LiveRequest {
hover_y: Option<u32>,
#[serde(default)]
typed_text: Option<String>,
site_permission_generation: u64,
#[serde(default)]
site_permissions: Vec<LiveSitePermission>,
#[serde(default)]
@@ -79,7 +81,8 @@ const fn default_device_pixel_ratio() -> f32 {
pub(super) struct LiveSitePermission {
pub(super) origin: String,
pub(super) feature: String,
pub(super) decision: String,
pub(super) state: String,
pub(super) revision: u64,
}
pub(super) struct LiveOutcome {
@@ -100,6 +103,15 @@ impl LiveOutcome {
Self { response: LiveResponse::frame(report), frame: Some(frame) }
}
pub(super) fn with_permission_consumptions(
mut self,
consumptions: Vec<ConsumedPermission>,
) -> Self {
self.response.permission_consumptions =
consumptions.into_iter().map(LivePermissionConsumption::from).collect();
self
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn surface(report: LiveFrameReport) -> Self {
Self { response: LiveResponse::frame(report), frame: None }
@@ -115,6 +127,27 @@ pub(super) struct LiveResponse {
pub(super) surface_handle: Option<IOSurfaceHandle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) current_surface_id: Option<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(super) permission_consumptions: Vec<LivePermissionConsumption>,
}
#[derive(Debug, Serialize)]
pub(super) struct LivePermissionConsumption {
pub(super) profile_id: String,
pub(super) origin: String,
pub(super) feature: String,
pub(super) grant_revision: u64,
}
impl From<ConsumedPermission> for LivePermissionConsumption {
fn from(consumed: ConsumedPermission) -> Self {
Self {
profile_id: consumed.profile_id.as_str().to_string(),
origin: consumed.origin.as_str().to_string(),
feature: consumed.feature.as_str().to_string(),
grant_revision: consumed.grant_revision,
}
}
}
impl LiveResponse {
@@ -125,6 +158,7 @@ impl LiveResponse {
frame: None,
surface_handle: None,
current_surface_id: None,
permission_consumptions: Vec::new(),
}
}
@@ -135,6 +169,7 @@ impl LiveResponse {
frame: None,
surface_handle: None,
current_surface_id: None,
permission_consumptions: Vec::new(),
}
}
@@ -145,6 +180,7 @@ impl LiveResponse {
frame: Some(frame),
surface_handle: None,
current_surface_id: None,
permission_consumptions: Vec::new(),
}
}
}
@@ -332,7 +368,7 @@ mod tests {
#[test]
fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600}"#,
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600,"site_permission_generation":0}"#,
)?;
assert!(matches!(
@@ -353,12 +389,21 @@ mod tests {
#[test]
fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> {
let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":2}"#)?;
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":3}"#)?;
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 2 }));
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 3 }));
Ok(())
}
#[test]
fn site_permission_requires_revision() {
let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600,"site_permission_generation":0,"site_permissions":[{"origin":"https://example.com","feature":"camera","state":"allow-once"}]}"#,
);
assert!(request.is_err());
}
#[test]
fn frame_layout_enforces_dimension_and_byte_limits() {
assert!(matches!(
@@ -3,7 +3,8 @@ use std::collections::{HashMap, hash_map::Entry};
use ely_domain::{ProfileId, TabId, validate_zoom_percent};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest,
PermissionSnapshotState, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
ServoSurfaceSize, SoftwareServoHost,
};
@@ -132,22 +133,41 @@ pub(super) fn apply_permissions(
host: &mut SoftwareServoHost,
session: &LiveSession,
profile_id: &ProfileId,
generation: u64,
permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> {
for permission in permissions {
host.set_permission(
PermissionRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
let entries = permissions
.into_iter()
.map(|permission| {
let state = match permission.state.as_str() {
"allow-once" => PermissionSnapshotState::Decision(PermissionDecision::AllowOnce),
"allow-always" => {
PermissionSnapshotState::Decision(PermissionDecision::AllowAlways)
}
"deny-always" => PermissionSnapshotState::Decision(PermissionDecision::DenyAlways),
"transferred-allow-once" => PermissionSnapshotState::TransferredAllowOnce,
value => {
return Err(ely_domain::DomainError::InvalidSitePermissionDecision {
value: value.to_string(),
}
.into());
}
};
Ok(PermissionSnapshotEntry {
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(&permission.feature)?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
&permission.decision,
)?),
)?;
}
Ok(())
state,
revision: permission.revision,
})
})
.collect::<Result<Vec<_>, LiveSidecarError>>()?;
host.replace_permissions(PermissionSnapshotRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
generation,
entries,
})
.map_err(LiveSidecarError::from)
}
pub(super) struct LiveInput {