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)