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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
This commit is contained in:
Erik JohnstonandClaude Fable 5.1 committed 2026-10-14 13:35:25 +00:00
1 parent d017f728f8
commit 90e4d6492a
7 files changed
+867 -49

No files matched your search

+7 -2
View File
@@ -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<Option<i64>> {
pub fn locally_sticky_until_ts(&self) -> PyResult<Option<i64>> {
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<Event> {
pub(crate) fn redact_event_py(event: &Event) -> PyResult<Event> {
let event_value = serde_json::to_value(&event.parsed_event).map_err(|err| {
PyValueError::new_err(format!("Failed to serialize event for redaction: {}", err))
})?;
+6
View File
@@ -297,6 +297,12 @@ impl Unsigned {
pub fn age_ts(&self) -> PyResult<Option<Number>> {
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<Option<Box<serde_json::Value>>> {
Ok(self.py_read()?.prev_content.clone())
}
}
fn room_state_to_py<'py>(
+2
View File
@@ -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(())
}
+478
View File
@@ -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:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*/
//! 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<Arc<str>>,
pub sender_ignored: bool,
/// The room's retention policy's `max_lifetime`, if it has one.
pub retention_max_lifetime: Option<i64>,
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<str>,
}
/// 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<usize> {
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<Option<FilteredEvent>> {
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<Option<Event>> {
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<i64>,
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<String>,
sender_ignored: bool,
retention_max_lifetime: Option<i64>,
sender_erased: bool,
) -> PyResult<Option<(Event, String)>> {
let always_include_ids: HashSet<Arc<str>> =
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<Option<Event>> {
match dict.get_item(key)? {
Some(event) => Ok(Some(event.cast::<Event>()?.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(())
}
+33
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
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."""
+79 -47
View File
@@ -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,
+262
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""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)