From 90e4d6492a26d484ed66475e62dfb3e02f497ce1 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Wed, 14 Oct 2026 13:29:04 +0000 Subject: [PATCH] Port the client visibility checks to Rust The per-event decision of `filter_and_transform_events_for_client`: `_check_client_allowed_to_see_event` and the membership, history visibility, send-to-client and effective visibility checks under it, the call invite rule, and the membership annotation for MSC4115. The inputs are the event, the user's membership and the history visibility at it as events, and flags; loading those is the caller's job, so the module can serve the sync handler and later ports alike. The erased-sender case prunes through the existing Rust redaction. The `allowed` closure in the Python is now a module-level function, `_check_event_for_client`, with the same body, so that `tests/sync/test_rust_visibility.py` can compare the two exactly on random events, state and flags, sticky and outlier events included. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU --- rust/src/events/mod.rs | 9 +- rust/src/events/unsigned.rs | 6 + rust/src/lib.rs | 2 + rust/src/visibility.rs | 478 ++++++++++++++++++++++++++++ synapse/synapse_rust/visibility.pyi | 33 ++ synapse/visibility.py | 126 +++++--- tests/sync/test_rust_visibility.py | 262 +++++++++++++++ 7 files changed, 867 insertions(+), 49 deletions(-) create mode 100644 rust/src/visibility.rs create mode 100644 synapse/synapse_rust/visibility.pyi create mode 100644 tests/sync/test_rust_visibility.py diff --git a/rust/src/events/mod.rs b/rust/src/events/mod.rs index 9e97fe95cb..f05bcd321d 100644 --- a/rust/src/events/mod.rs +++ b/rust/src/events/mod.rs @@ -407,7 +407,7 @@ impl Event { /// the expiry of the event's stickiness, as seen locally on this homeserver. /// /// It is the caller's responsibility to check this time is not in the past. - fn locally_sticky_until_ts(&self) -> PyResult> { + pub fn locally_sticky_until_ts(&self) -> PyResult> { let Some(sticky_duration) = self.sticky_duration()? else { return Ok(None); }; @@ -732,6 +732,11 @@ impl Event { &self.parsed_event.common_fields.content } + /// The event's `unsigned` data, shared with the Python object. + pub fn unsigned_ref(&self) -> &Unsigned { + &self.parsed_event.unsigned + } + /// The room version this event was parsed for. pub fn room_version(&self) -> &'static RoomVersion { self.room_version @@ -795,7 +800,7 @@ pub(crate) fn event_dict_from_json_str( /// /// Returns the redacted event as a dict. #[pyfunction(name = "redact_event")] -fn redact_event_py(event: &Event) -> PyResult { +pub(crate) fn redact_event_py(event: &Event) -> PyResult { let event_value = serde_json::to_value(&event.parsed_event).map_err(|err| { PyValueError::new_err(format!("Failed to serialize event for redaction: {}", err)) })?; diff --git a/rust/src/events/unsigned.rs b/rust/src/events/unsigned.rs index 8db0a338bd..3b259e8462 100644 --- a/rust/src/events/unsigned.rs +++ b/rust/src/events/unsigned.rs @@ -297,6 +297,12 @@ impl Unsigned { pub fn age_ts(&self) -> PyResult> { Ok(self.py_read()?.persisted_fields.age_ts.clone()) } + + /// Get the `prev_content` field: the content of the state this event + /// replaced, if it is a state event and the content is known. + pub fn prev_content(&self) -> PyResult>> { + Ok(self.py_read()?.prev_content.clone()) + } } fn room_state_to_py<'py>( diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4561030a1c..4e1eb8b91f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -39,6 +39,7 @@ pub mod storage; pub mod sync; pub mod twisted_dispatch; pub mod types; +pub mod visibility; lazy_static! { static ref LOGGING_HANDLE: ResetHandle = pyo3_log::init(); @@ -110,6 +111,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { sync::register_module(py, m)?; room_versions::register_module(py, m)?; types::register_module(py, m)?; + visibility::register_module(py, m)?; Ok(()) } diff --git a/rust/src/visibility.rs b/rust/src/visibility.rs new file mode 100644 index 0000000000..311c3d08da --- /dev/null +++ b/rust/src/visibility.rs @@ -0,0 +1,478 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2026 Element Creations Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * . + */ + +//! Whether a user may see an event: the per-event checks of +//! `synapse.visibility.filter_and_transform_events_for_client`. +//! +//! Everything here is a function of the event, the state at it and a few +//! facts about the user and room. Loading those (the state at each event, +//! the ignored users, who is erased, the retention policy) is the caller's +//! job, so that this can serve the sync handler and any later port alike. +//! [`check_event_for_client`] is the whole per-event decision; the +//! functions below it are the steps, named as the Python ones are. + +use std::{collections::HashSet, sync::Arc}; + +use pyo3::{ + prelude::*, + types::{PyDict, PyModule, PyModuleMethods}, +}; + +use crate::events::{ + constants::{ + event_type::{M_ROOM_ALIASES, M_ROOM_HISTORY_VISIBILITY, M_ROOM_JOIN_RULES, M_ROOM_MEMBER}, + history_visibility_field::HISTORY_VISIBILITY, + join_rules_field::JOIN_RULE, + membership_field::MEMBERSHIP, + }, + redact_event_py, Event, +}; + +/// `EventTypes.Dummy`. +const M_DUMMY: &str = "org.matrix.dummy_event"; +/// `EventTypes.CallInvite`. +const M_CALL_INVITE: &str = "m.call.invite"; + +/// History visibilities from the least to the most restrictive. +pub const VISIBILITY_PRIORITY: [&str; 4] = ["world_readable", "shared", "invited", "joined"]; + +/// Memberships from the "most joined" down. +pub const MEMBERSHIP_PRIORITY: [&str; 5] = ["join", "invite", "knock", "leave", "ban"]; + +const SHARED: &str = "shared"; +const WORLD_READABLE: &str = "world_readable"; +const INVITED: &str = "invited"; +const JOINED: &str = "joined"; +const JOIN: &str = "join"; +const INVITE: &str = "invite"; +const LEAVE: &str = "leave"; +const PUBLIC: &str = "public"; + +/// The state at an event that the checks read: the room's history +/// visibility, the user's own membership, and the join rules (only read +/// for call invites). +#[derive(Clone, Copy, Default)] +pub struct StateAtEvent<'a> { + pub history_visibility: Option<&'a Event>, + pub user_membership: Option<&'a Event>, + pub join_rules: Option<&'a Event>, +} + +/// What `filter_and_transform_events_for_client` knows about the user and +/// room when it checks an event. +#[derive(Debug, Clone)] +pub struct VisibilityInputs<'a> { + pub user_id: &'a str, + /// The time now, for sticky events and retention. + pub now_ms: i64, + pub msc4354_enabled: bool, + /// Whether the event is going to a client, as opposed to being checked + /// for what state the user may see. Some checks only apply to the + /// former. + pub filter_send_to_client: bool, + /// The user is not a member of the room and has not been since the + /// event. + pub is_peeking: bool, + /// Events to include if present, unless their sender is ignored. + pub always_include_ids: &'a HashSet>, + pub sender_ignored: bool, + /// The room's retention policy's `max_lifetime`, if it has one. + pub retention_max_lifetime: Option, + pub sender_erased: bool, +} + +/// An event the user may see, with their membership at it, for MSC4115. +pub struct FilteredEvent { + /// The event, or a pruned copy of it when the user may only see that. + pub event: Event, + pub membership: Arc, +} + +/// The `membership` of a membership event's content. +fn membership_of(event: &Event) -> Option<&str> { + event + .content() + .get_field(MEMBERSHIP) + .and_then(|m| m.as_str()) +} + +fn priority(list: &[&str], value: &str) -> Option { + list.iter().position(|v| *v == value) +} + +/// `value` as the entry of `list` it equals, so that it outlives the +/// borrow it came from, or `fallback` if it is not in the list. +fn known<'l>(list: &[&'l str], value: Option<&str>, fallback: &'l str) -> &'l str { + value + .and_then(|value| list.iter().copied().find(|v| *v == value)) + .unwrap_or(fallback) +} + +/// The `allowed` closure of `filter_and_transform_events_for_client`: +/// whether the user may see `event`, what form of it, and their membership +/// at it. `state` is `None` for an outlier. +/// +/// Fails only if pruning the event does, which a persisted event cannot. +pub fn check_event_for_client( + inputs: &VisibilityInputs<'_>, + event: &Event, + state: Option<&StateAtEvent<'_>>, +) -> PyResult> { + let Some(filtered) = check_client_allowed_to_see_event(inputs, event, state)? else { + return Ok(None); + }; + + let is_own_membership = + event.type_() == M_ROOM_MEMBER && event.state_key() == Some(inputs.user_id); + + // Call invites in public rooms would ring a lot of users. + if event.type_() == M_CALL_INVITE && !event.is_state() { + // The state is only absent for an outlier, and the only outliers + // that get this far are the user's own memberships. + let state = state.expect("a call invite is not an outlier"); + let join_rule = state + .join_rules + .and_then(|rules| rules.content().get_field(JOIN_RULE)) + .and_then(|rule| rule.as_str()); + if join_rule == Some(PUBLIC) { + return Ok(None); + } + } + + // The user's membership after the event. An outlier has no state, but + // the only outliers returned are the user's own membership events, so + // the event itself says. + let membership_event = if is_own_membership { + Some(event) + } else { + state + .expect("an event that is not the user's own membership has state") + .user_membership + }; + let membership = membership_event.and_then(membership_of).unwrap_or(LEAVE); + + Ok(Some(FilteredEvent { + event: filtered, + membership: Arc::from(membership), + })) +} + +/// `_check_client_allowed_to_see_event`: `None` if the user cannot see the +/// event at all, a pruned copy if they may only see that, or the event as +/// it is. +pub fn check_client_allowed_to_see_event( + inputs: &VisibilityInputs<'_>, + event: &Event, + state: Option<&StateAtEvent<'_>>, +) -> PyResult> { + if inputs.filter_send_to_client + && !check_filter_send_to_client( + event, + inputs.now_ms, + inputs.retention_max_lifetime, + inputs.sender_ignored, + ) + { + return Ok(None); + } + + if inputs.always_include_ids.contains(event.event_id()) { + return Ok(Some(event.shallow_clone())); + } + + // Outliers have no room state. Normally clients can't see them, but + // out-of-band membership events (incoming invites, rejections of them) + // are shown to the user they are about. + if event.internal_metadata().is_outlier()? { + if event.type_() == M_ROOM_MEMBER && event.state_key() == Some(inputs.user_id) { + return Ok(Some(event.shallow_clone())); + } + return Ok(None); + } + + let state = state.expect("state for a non-outlier event"); + + if inputs.msc4354_enabled && !inputs.is_peeking { + // Sticky events are exempt from the membership part of the history + // visibility rules while they are sticky: any joined user may see + // them, as for state events (MSC4354). + if let Some(sticky_until) = event.locally_sticky_until_ts()? { + if inputs.now_ms < sticky_until { + return Ok(Some(event.shallow_clone())); + } + } + } + + let visibility = get_effective_room_visibility(state.history_visibility); + + // Lax history visibility means no membership check, unless the sender + // was erased: then whether the user was joined decides the form. + if !inputs.sender_erased && check_history_visibility(event, visibility, inputs.is_peeking) { + return Ok(Some(event.shallow_clone())); + } + + let (allowed, joined) = check_membership( + inputs.user_id, + event, + visibility, + state.user_membership, + inputs.is_peeking, + ); + if !allowed { + return Ok(None); + } + + // An erased sender's events are only shown in full to users who were + // joined at the time. + if inputs.sender_erased && !joined { + return Ok(Some(redact_event_py(event)?)); + } + + Ok(Some(event.shallow_clone())) +} + +/// `_check_membership`: `(allowed, joined)`, whether the user's membership +/// at the event lets them see it, and whether they were joined. +pub fn check_membership( + user_id: &str, + event: &Event, + visibility: &str, + membership_event: Option<&Event>, + is_peeking: bool, +) -> (bool, bool) { + let mut membership: Option<&str> = None; + + // The user's own membership event is judged by its "most joined" side. + if event.type_() == M_ROOM_MEMBER && event.state_key() == Some(user_id) { + let new = known(&MEMBERSHIP_PRIORITY, membership_of(event), LEAVE); + let prev_content = event.unsigned_ref().prev_content().ok().flatten(); + let prev = known( + &MEMBERSHIP_PRIORITY, + prev_content + .as_ref() + .and_then(|content| content.get(MEMBERSHIP)) + .and_then(|m| m.as_str()), + LEAVE, + ); + + // Always let the user see their own leave, so that they see the + // room go when they reject an invite. (Out-of-band rejections have + // no prev content; they are outliers and handled before this.) + if new == LEAVE && (prev == JOIN || prev == INVITE) { + return (true, false); + } + + let new_priority = priority(&MEMBERSHIP_PRIORITY, new).expect("checked above"); + let old_priority = priority(&MEMBERSHIP_PRIORITY, prev).expect("checked above"); + membership = Some(if old_priority < new_priority { + prev + } else { + new + }); + } + + // Otherwise, their membership at the time of the event. + if membership.is_none() { + membership = membership_event.and_then(membership_of); + } + + if membership == Some(JOIN) { + return (true, true); + } + + match visibility { + JOINED => (false, false), + INVITED => (membership == Some(INVITE), false), + SHARED if is_peeking => (false, false), + // Shared or world readable, and the user was not a member. + _ => (true, false), + } +} + +/// `_check_filter_send_to_client`: `false` if the event is definitely not +/// to be sent to a client: a dummy event, a non-state event from an ignored +/// sender, an aliases event, or a non-state event past the room's +/// retention. +pub fn check_filter_send_to_client( + event: &Event, + now_ms: i64, + retention_max_lifetime: Option, + sender_ignored: bool, +) -> bool { + if event.type_() == M_DUMMY { + return false; + } + if !event.is_state() && sender_ignored { + return false; + } + // Until MSC2261 lands, alias events cannot be redacted when malicious, + // so they are filtered out entirely. + if event.type_() == M_ROOM_ALIASES { + return false; + } + // Retention only applies to non-state events (MSC1763). + if !event.is_state() { + if let Some(max_lifetime) = retention_max_lifetime { + if event.origin_server_ts() < now_ms - max_lifetime { + return false; + } + } + } + true +} + +/// `_check_history_visibility`: `true` if the user can definitely see the +/// event by the room's history visibility alone. +/// +/// A history visibility event is judged by the laxer of the old and new +/// settings, so that the change itself is visible on both sides. +pub fn check_history_visibility(event: &Event, visibility: &str, is_peeking: bool) -> bool { + let mut visibility = visibility; + if event.type_() == M_ROOM_HISTORY_VISIBILITY { + let prev_content = event.unsigned_ref().prev_content().ok().flatten(); + let prev = known( + &VISIBILITY_PRIORITY, + prev_content + .as_ref() + .and_then(|content| content.get(HISTORY_VISIBILITY)) + .and_then(|v| v.as_str()), + SHARED, + ); + let new_priority = + priority(&VISIBILITY_PRIORITY, visibility).expect("effective visibility"); + let old_priority = priority(&VISIBILITY_PRIORITY, prev).expect("checked above"); + if old_priority < new_priority { + visibility = prev; + } + } + + (visibility == SHARED && !is_peeking) || visibility == WORLD_READABLE +} + +/// `get_effective_room_visibility_from_state`: the room's history +/// visibility from its event, `shared` when there is none or it is invalid. +pub fn get_effective_room_visibility(history_visibility: Option<&Event>) -> &'static str { + let visibility = history_visibility + .and_then(|event| event.content().get_field(HISTORY_VISIBILITY)) + .and_then(|v| v.as_str()) + .unwrap_or(SHARED); + VISIBILITY_PRIORITY + .iter() + .copied() + .find(|v| *v == visibility) + .unwrap_or(SHARED) +} + +/// What a server admin asked to see of soft-failed events, from +/// `AdminClientConfig`. +#[derive(Debug, Clone, Copy, Default)] +pub struct SoftFailedPolicy { + pub return_soft_failed_events: bool, + pub return_policy_server_spammy_events: bool, +} + +/// Whether a soft-failed event is kept before the visibility checks: never, +/// unless the user is a server admin who asked for soft-failed events, or +/// for the ones a policy server marked as spam. `policy` is `None` for a +/// user who is not an admin, or when the events are not going to a client. +pub fn keep_soft_failed(event: &Event, policy: Option<&SoftFailedPolicy>) -> bool { + if !event.internal_metadata().soft_failed().unwrap_or(false) { + return true; + } + match policy { + Some(policy) if policy.return_soft_failed_events => true, + Some(policy) if policy.return_policy_server_spammy_events => event + .internal_metadata() + .policy_server_spammy() + .unwrap_or(false), + _ => false, + } +} + +// The entry point for the differential test. + +/// [`check_event_for_client`] through the Rust implementation. `state` maps +/// `(type, state_key)` to events, as the Python state map does, or is +/// `None` for an outlier. Returns `(event, membership)` or `None`. +#[pyfunction] +#[pyo3(signature = (user_id, event, state, *, now_ms, msc4354_enabled, filter_send_to_client, is_peeking, always_include_ids, sender_ignored, retention_max_lifetime, sender_erased))] +#[allow(clippy::too_many_arguments)] +fn check_event_for_client_for_test( + user_id: &str, + event: &Event, + state: Option<&Bound<'_, PyDict>>, + now_ms: i64, + msc4354_enabled: bool, + filter_send_to_client: bool, + is_peeking: bool, + always_include_ids: HashSet, + sender_ignored: bool, + retention_max_lifetime: Option, + sender_erased: bool, +) -> PyResult> { + let always_include_ids: HashSet> = + always_include_ids.into_iter().map(Arc::from).collect(); + let inputs = VisibilityInputs { + user_id, + now_ms, + msc4354_enabled, + filter_send_to_client, + is_peeking, + always_include_ids: &always_include_ids, + sender_ignored, + retention_max_lifetime, + sender_erased, + }; + + let lookup = |dict: &Bound<'_, PyDict>, key: (&str, &str)| -> PyResult> { + match dict.get_item(key)? { + Some(event) => Ok(Some(event.cast::()?.get().shallow_clone())), + None => Ok(None), + } + }; + let events = match state { + Some(dict) => Some(( + lookup(dict, (M_ROOM_HISTORY_VISIBILITY, ""))?, + lookup(dict, (M_ROOM_MEMBER, user_id))?, + lookup(dict, (M_ROOM_JOIN_RULES, ""))?, + )), + None => None, + }; + let state = events.as_ref().map(|(vis, member, rules)| StateAtEvent { + history_visibility: vis.as_ref(), + user_membership: member.as_ref(), + join_rules: rules.as_ref(), + }); + + Ok(check_event_for_client(&inputs, event, state.as_ref())? + .map(|filtered| (filtered.event, filtered.membership.to_string()))) +} + +pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + let child_module = PyModule::new(py, "visibility")?; + child_module.add_function(wrap_pyfunction!( + check_event_for_client_for_test, + &child_module + )?)?; + + m.add_submodule(&child_module)?; + + // We need to manually add the module to sys.modules to make `from + // synapse.synapse_rust import visibility` work. + py.import("sys")? + .getattr("modules")? + .set_item("synapse.synapse_rust.visibility", child_module)?; + + Ok(()) +} diff --git a/synapse/synapse_rust/visibility.pyi b/synapse/synapse_rust/visibility.pyi new file mode 100644 index 0000000000..ce75f8b3dc --- /dev/null +++ b/synapse/synapse_rust/visibility.pyi @@ -0,0 +1,33 @@ +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + +from collections.abc import Collection, Mapping + +from synapse.synapse_rust.events import Event + +def check_event_for_client_for_test( + user_id: str, + event: Event, + state: Mapping[tuple[str, str], Event] | None, + *, + now_ms: int, + msc4354_enabled: bool, + filter_send_to_client: bool, + is_peeking: bool, + always_include_ids: Collection[str], + sender_ignored: bool, + retention_max_lifetime: int | None, + sender_erased: bool, +) -> tuple[Event, str] | None: + """The Rust port of `synapse.visibility._check_event_for_client`: the + event the user may see (pruned if that is all they may see) and their + membership at it, or None. `state` is None for an outlier.""" diff --git a/synapse/visibility.py b/synapse/visibility.py index d6b3d4d452..782336cd64 100644 --- a/synapse/visibility.py +++ b/synapse/visibility.py @@ -177,64 +177,19 @@ async def filter_and_transform_events_for_client( ] = await storage.main.get_retention_policy_for_room(room_id) def allowed(event: EventBase) -> FilteredEvent | None: - state_after_event = event_id_to_state.get(event.event_id) - filtered = _check_client_allowed_to_see_event( + return _check_event_for_client( user_id=user_id, event=event, + state_after_event=event_id_to_state.get(event.event_id), clock=storage.main.clock, msc4354_enabled=storage.main.hs.config.experimental.msc4354_enabled, filter_send_to_client=filter_send_to_client, sender_ignored=event.sender in ignore_list, always_include_ids=always_include_ids, retention_policy=retention_policies[event.room_id], - state=state_after_event, is_peeking=is_peeking, sender_erased=erased_senders.get(event.sender, False), ) - if filtered is None: - return None - - # Filter out call invites in public rooms, as this would potentially - # ring a lot of users. - if event.type == EventTypes.CallInvite and not event.is_state(): - # `state_after_event` should only be None if the event is an outlier, - # and earlier code should filter out outliers entirely. - # - # In addition, we only create outliers locally for out-of-band - # invite rejections, invites received over federation, or state - # events needed to authorise other events. None of this applies to - # call invites. - assert state_after_event is not None - - room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) - if ( - room_join_rules is not None - and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC - ): - return None - - # Annotate the event with the user's membership after the event. - # - # Normally we just look in `state_after_event`, but if the event is an outlier - # we won't have such a state. The only outliers that are returned here are the - # user's own membership event, so we can just inspect that. - - user_membership_event: EventBase | None - if event.type == EventTypes.Member and event.state_key == user_id: - user_membership_event = event - elif state_after_event is not None: - user_membership_event = state_after_event.get((EventTypes.Member, user_id)) - else: - # unreachable! - raise Exception("Missing state for event that is not user's own membership") - - user_membership = ( - user_membership_event.membership - if user_membership_event - else Membership.LEAVE - ) - - return FilteredEvent(event=filtered, membership=user_membership) # Check each event: gives an iterable of None or a FilteredEvent. filtered_events = map(allowed, events) @@ -243,6 +198,83 @@ async def filter_and_transform_events_for_client( return [ev for ev in filtered_events if ev] +def _check_event_for_client( + user_id: str, + event: EventBase, + state_after_event: StateMap[EventBase] | None, + clock: Clock, + msc4354_enabled: bool, + filter_send_to_client: bool, + sender_ignored: bool, + always_include_ids: frozenset[str], + retention_policy: RetentionPolicy, + is_peeking: bool, + sender_erased: bool, +) -> FilteredEvent | None: + """The per-event decision of `filter_and_transform_events_for_client`: + whether the user may see `event`, in what form, and their membership at + it. `state_after_event` is `None` for an outlier. + + The Rust sync handler has a port of this; `tests/sync/test_rust_pure.py` + checks the two agree. + """ + filtered = _check_client_allowed_to_see_event( + user_id=user_id, + event=event, + clock=clock, + msc4354_enabled=msc4354_enabled, + filter_send_to_client=filter_send_to_client, + sender_ignored=sender_ignored, + always_include_ids=always_include_ids, + retention_policy=retention_policy, + state=state_after_event, + is_peeking=is_peeking, + sender_erased=sender_erased, + ) + if filtered is None: + return None + + # Filter out call invites in public rooms, as this would potentially + # ring a lot of users. + if event.type == EventTypes.CallInvite and not event.is_state(): + # `state_after_event` should only be None if the event is an outlier, + # and earlier code should filter out outliers entirely. + # + # In addition, we only create outliers locally for out-of-band + # invite rejections, invites received over federation, or state + # events needed to authorise other events. None of this applies to + # call invites. + assert state_after_event is not None + + room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) + if ( + room_join_rules is not None + and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC + ): + return None + + # Annotate the event with the user's membership after the event. + # + # Normally we just look in `state_after_event`, but if the event is an outlier + # we won't have such a state. The only outliers that are returned here are the + # user's own membership event, so we can just inspect that. + + user_membership_event: EventBase | None + if event.type == EventTypes.Member and event.state_key == user_id: + user_membership_event = event + elif state_after_event is not None: + user_membership_event = state_after_event.get((EventTypes.Member, user_id)) + else: + # unreachable! + raise Exception("Missing state for event that is not user's own membership") + + user_membership = ( + user_membership_event.membership if user_membership_event else Membership.LEAVE + ) + + return FilteredEvent(event=filtered, membership=user_membership) + + async def filter_event_for_clients_with_state( store: DataStore, user_ids: StrCollection, diff --git a/tests/sync/test_rust_visibility.py b/tests/sync/test_rust_visibility.py new file mode 100644 index 0000000000..4474f40115 --- /dev/null +++ b/tests/sync/test_rust_visibility.py @@ -0,0 +1,262 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + +"""The per-event checks of `synapse.visibility` against `rust/src/visibility.rs`, +on random events, state and flags.""" + +import random + +from parameterized import parameterized + +from synapse.api.constants import EventTypes, HistoryVisibility, Membership +from synapse.api.room_versions import RoomVersions +from synapse.events import EventBase +from synapse.synapse_rust.visibility import check_event_for_client_for_test +from synapse.types import ( + JsonDict, + RetentionPolicy, + StateKey, + StateMap, +) +from synapse.visibility import _check_event_for_client + +from tests import unittest +from tests.sync.fixtures import ( + ME, + SEEDS, + USERS, +) +from tests.test_utils.event_builders import make_test_event + + +class _FakeClock: + def __init__(self, now_ms: int) -> None: + self.now_ms = now_ms + + def time_msec(self) -> int: + return self.now_ms + + +class VisibilityTestCase(unittest.TestCase): + """The per-event checks of `synapse.visibility` against + `rust/src/visibility.rs`.""" + + VISIBILITIES = [ + HistoryVisibility.WORLD_READABLE, + HistoryVisibility.SHARED, + HistoryVisibility.INVITED, + HistoryVisibility.JOINED, + "bogus", + None, + ] + MEMBERSHIPS = [ + Membership.JOIN, + Membership.INVITE, + Membership.KNOCK, + Membership.LEAVE, + Membership.BAN, + "bogus", + None, + ] + + @classmethod + def _state_event( + cls, rng: random.Random, event_type: str, state_key: str, content: JsonDict + ) -> EventBase: + unsigned: JsonDict = {} + if rng.random() < 0.5: + prev: JsonDict = {} + if event_type == EventTypes.Member: + prev_membership = rng.choice(cls.MEMBERSHIPS) + if prev_membership is not None: + prev["membership"] = prev_membership + elif event_type == EventTypes.RoomHistoryVisibility: + prev_visibility = rng.choice(cls.VISIBILITIES) + if prev_visibility is not None: + prev["history_visibility"] = prev_visibility + unsigned["prev_content"] = prev + return make_test_event( + { + "type": event_type, + "state_key": state_key, + "sender": rng.choice(USERS), + "room_id": "!a:test", + "content": content, + "unsigned": unsigned, + }, + room_version=RoomVersions.V10, + ) + + @classmethod + def _random_state(cls, rng: random.Random) -> StateMap[EventBase]: + state: dict[StateKey, EventBase] = {} + visibility = rng.choice(cls.VISIBILITIES) + if visibility is not None: + content = {"history_visibility": visibility} + if rng.random() < 0.1: + content = {} + state[(EventTypes.RoomHistoryVisibility, "")] = cls._state_event( + rng, EventTypes.RoomHistoryVisibility, "", content + ) + membership = rng.choice(cls.MEMBERSHIPS) + if membership is not None: + state[(EventTypes.Member, ME)] = cls._state_event( + rng, EventTypes.Member, ME, {"membership": membership} + ) + if rng.random() < 0.5: + state[(EventTypes.JoinRules, "")] = cls._state_event( + rng, + EventTypes.JoinRules, + "", + {"join_rule": rng.choice(["public", "invite"])}, + ) + return state + + @classmethod + def _random_event(cls, rng: random.Random, now_ms: int) -> EventBase: + kind = rng.choice( + [ + "message", + "own_member", + "other_member", + "history_vis", + "call", + "dummy", + "aliases", + "sticky", + ] + ) + fields: JsonDict = { + "sender": rng.choice(USERS), + "room_id": "!a:test", + "origin_server_ts": now_ms - rng.choice([0, 1000, 100_000, 10_000_000]), + "content": {}, + } + unsigned: JsonDict = {} + if kind == "message": + fields["type"] = EventTypes.Message + fields["content"] = {"body": "hi"} + elif kind in ("own_member", "other_member"): + fields["type"] = EventTypes.Member + fields["state_key"] = ME if kind == "own_member" else "@alice:test" + # Always with a membership: the auth rules require one, and the + # Python raises on an event without. + fields["content"] = {"membership": rng.choice(cls.MEMBERSHIPS) or "bogus"} + prev = rng.choice(cls.MEMBERSHIPS) + if rng.random() < 0.7: + unsigned["prev_content"] = {"membership": prev} if prev else {} + elif kind == "history_vis": + fields["type"] = EventTypes.RoomHistoryVisibility + fields["state_key"] = "" + visibility = rng.choice(cls.VISIBILITIES) + fields["content"] = {"history_visibility": visibility} if visibility else {} + prev = rng.choice(cls.VISIBILITIES) + if rng.random() < 0.7: + unsigned["prev_content"] = {"history_visibility": prev} if prev else {} + elif kind == "call": + fields["type"] = EventTypes.CallInvite + if rng.random() < 0.2: + fields["state_key"] = "" + elif kind == "dummy": + fields["type"] = EventTypes.Dummy + elif kind == "aliases": + fields["type"] = EventTypes.Aliases + fields["state_key"] = "test" + elif kind == "sticky": + fields["type"] = EventTypes.Message + fields["msc4354_sticky"] = { + "duration_ms": rng.choice([0, 60_000, 10_000_000]) + } + fields["unsigned"] = unsigned + internal_metadata: JsonDict = {} + if kind == "sticky" or rng.random() < 0.3: + internal_metadata["received_ts"] = now_ms - rng.choice( + [0, 30_000, 7_200_000] + ) + if kind in ("own_member", "other_member", "message") and rng.random() < 0.2: + internal_metadata["outlier"] = True + return make_test_event( + fields, + room_version=RoomVersions.V10, + internal_metadata_dict=internal_metadata, + ) + + @parameterized.expand(SEEDS) + def test_random_events_agree(self, seed: int) -> None: + rng = random.Random(seed) + now_ms = 1_700_000_000_000 + for _ in range(200): + event = self._random_event(rng, now_ms) + state = ( + None + if event.internal_metadata.is_outlier() + else self._random_state(rng) + ) + msc4354_enabled = rng.random() < 0.5 + filter_send_to_client = rng.random() < 0.8 + is_peeking = rng.random() < 0.3 + always_include_ids = frozenset( + [event.event_id] if rng.random() < 0.1 else [] + ) + sender_ignored = rng.random() < 0.2 + max_lifetime = rng.choice([None, 500, 1_000_000]) + sender_erased = rng.random() < 0.2 + context = ( + f"event={event} state={state} msc4354={msc4354_enabled} " + f"send={filter_send_to_client} peeking={is_peeking} " + f"ignored={sender_ignored} max_lifetime={max_lifetime} erased={sender_erased}" + ) + + expected = _check_event_for_client( + user_id=ME, + event=event, + state_after_event=state, + clock=_FakeClock(now_ms), # type: ignore[arg-type] + msc4354_enabled=msc4354_enabled, + filter_send_to_client=filter_send_to_client, + sender_ignored=sender_ignored, + always_include_ids=always_include_ids, + retention_policy=RetentionPolicy(max_lifetime=max_lifetime), + is_peeking=is_peeking, + sender_erased=sender_erased, + ) + actual = check_event_for_client_for_test( + ME, + event, + state, + now_ms=now_ms, + msc4354_enabled=msc4354_enabled, + filter_send_to_client=filter_send_to_client, + is_peeking=is_peeking, + always_include_ids=always_include_ids, + sender_ignored=sender_ignored, + retention_max_lifetime=max_lifetime, + sender_erased=sender_erased, + ) + if expected is None: + self.assertIsNone(actual, context) + continue + self.assertIsNotNone(actual, context) + assert actual is not None + actual_event, actual_membership = actual + self.assertEqual(actual_event.event_id, expected.event.event_id, context) + self.assertEqual( + actual_event.internal_metadata.is_redacted(), + expected.event.internal_metadata.is_redacted(), + context, + ) + self.assertEqual( + actual_event.get_dict(), expected.event.get_dict(), context + ) + self.assertEqual(actual_membership, expected.membership, context)