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)