diff --git a/changelog.d/19922.misc b/changelog.d/19922.misc new file mode 100644 index 0000000000..64a531d4ed --- /dev/null +++ b/changelog.d/19922.misc @@ -0,0 +1 @@ +Port the synchronous core of client event serialization to Rust. diff --git a/rust/src/events/mod.rs b/rust/src/events/mod.rs index ec3ec3c852..0d746a2ec8 100644 --- a/rust/src/events/mod.rs +++ b/rust/src/events/mod.rs @@ -118,6 +118,13 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> child_module.add_function(wrap_pyfunction!(redact_event_py, m)?)?; child_module.add_function(wrap_pyfunction!(redact_event_dict, m)?)?; child_module.add_function(wrap_pyfunction!(serialize::serialize_events, m)?)?; + child_module.add_function(wrap_pyfunction!(serialize::format_event_raw, m)?)?; + child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v1, m)?)?; + child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v2, m)?)?; + child_module.add_function(wrap_pyfunction!( + serialize::format_event_for_client_v2_without_room_id, + m + )?)?; m.add_submodule(&child_module)?; diff --git a/rust/src/events/serialize.rs b/rust/src/events/serialize.rs index 52de233ce1..bb3eb4df06 100644 --- a/rust/src/events/serialize.rs +++ b/rust/src/events/serialize.rs @@ -32,7 +32,9 @@ use std::collections::HashMap; use pyo3::{ exceptions::{PyTypeError, PyValueError}, - pyclass, pyfunction, pymethods, Bound, PyAny, PyResult, Python, + pyclass, pyfunction, pymethods, + types::{PyAnyMethods, PyDict, PyDictMethods}, + Bound, PyAny, PyResult, Python, }; use pythonize::pythonize; use serde_json::{Map, Number, Value}; @@ -582,6 +584,69 @@ fn format_for_client_v2(d: &mut Map) { } } +// Standalone versions of the client format transforms, re-exported from +// `synapse.events.utils` purely as a backwards compatibility hack: they have +// never been part of the module API and modules shouldn't be pulling them in, +// but some in the wild import them from there anyway. They may be removed in +// the future; nothing in Synapse itself should use them. +// +// Unlike [`apply_event_format`], these mutate a Python dict in place and +// return it, matching the original Python implementations that used to live +// in `synapse/events/utils.py`. + +/// Return the event dict unchanged (federation format). +#[pyfunction] +pub fn format_event_raw(d: Bound<'_, PyDict>) -> Bound<'_, PyDict> { + d +} + +/// Apply the legacy `/events`-style v1 client format to `d` in place. +#[pyfunction] +pub fn format_event_for_client_v1(d: Bound<'_, PyDict>) -> PyResult> { + let d = format_event_for_client_v2(d)?; + + if let Some(sender) = d.get_item(SENDER)? { + if !sender.is_none() { + d.set_item(USER_ID, sender)?; + } + } + + // As in the original Python (`d["unsigned"]`), a missing `unsigned` key + // raises `KeyError`. + let unsigned = d.as_any().get_item(UNSIGNED)?; + for key in V1_COPY_KEYS { + if unsigned.contains(key)? { + d.set_item(key, unsigned.get_item(key)?)?; + } + } + + Ok(d) +} + +/// Apply the `/sync`-style v2 client format to `d` in place. +#[pyfunction] +pub fn format_event_for_client_v2(d: Bound<'_, PyDict>) -> PyResult> { + for key in V2_DROP_KEYS { + // Equivalent to `d.pop(key, None)`. + if d.contains(key)? { + d.del_item(key)?; + } + } + Ok(d) +} + +/// Apply the v2 client format to `d` in place, additionally stripping `room_id`. +#[pyfunction] +pub fn format_event_for_client_v2_without_room_id( + d: Bound<'_, PyDict>, +) -> PyResult> { + let d = format_event_for_client_v2(d)?; + if d.contains(ROOM_ID)? { + d.del_item(ROOM_ID)?; + } + Ok(d) +} + /// Return a mutable reference to `map["unsigned"]`, creating it as an empty /// object if it is missing or not an object. fn unsigned_mut(map: &mut Map) -> PyResult<&mut Map> { diff --git a/synapse/events/utils.py b/synapse/events/utils.py index 6815c9ef3a..55891f9b15 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -45,6 +45,10 @@ from synapse.synapse_rust.events import ( EventFormat, SerializeEventConfig, Unsigned, + format_event_for_client_v1, + format_event_for_client_v2, + format_event_for_client_v2_without_room_id, + format_event_raw, redact_event, serialize_events, ) @@ -56,7 +60,19 @@ from . import EventBase, StrippedStateEvent # These are imported only to re-export them (callers import them from this # module); listing them in __all__ stops the unused-import lint flagging them # and re-exports them for `import *`. -__all__ = ["EventFormat", "SerializeEventConfig"] +# +# The `format_event_*` functions are a backwards compatibility hack: they have +# never been part of the module API and modules shouldn't be pulling them in, +# but some in the wild import them from here anyway. They may be removed in +# the future; nothing in Synapse itself should use them. +__all__ = [ + "EventFormat", + "SerializeEventConfig", + "format_event_for_client_v1", + "format_event_for_client_v2", + "format_event_for_client_v2_without_room_id", + "format_event_raw", +] if TYPE_CHECKING: from synapse.handlers.relations import BundledAggregations diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi index fc9173acb2..eb6876b0f1 100644 --- a/synapse/synapse_rust/events.pyi +++ b/synapse/synapse_rust/events.pyi @@ -442,6 +442,40 @@ def serialize_events( The serialized events, in the same order as `events`. """ +# The standalone `format_event_*` transforms below are a backwards +# compatibility hack: they have never been part of the module API and modules +# shouldn't be pulling them in, but some in the wild import them (via +# `synapse.events.utils`) anyway. They may be removed in the future; nothing +# in Synapse itself should use them. + +def format_event_raw(d: JsonDict) -> JsonDict: + """Return the event dict unchanged (federation format). + + Deprecated backwards compatibility hack for modules importing it from + `synapse.events.utils`; don't use this in new code. + """ + +def format_event_for_client_v1(d: JsonDict) -> JsonDict: + """Apply the legacy `/events`-style v1 client format to `d` in place. + + Deprecated backwards compatibility hack for modules importing it from + `synapse.events.utils`; don't use this in new code. + """ + +def format_event_for_client_v2(d: JsonDict) -> JsonDict: + """Apply the `/sync`-style v2 client format to `d` in place. + + Deprecated backwards compatibility hack for modules importing it from + `synapse.events.utils`; don't use this in new code. + """ + +def format_event_for_client_v2_without_room_id(d: JsonDict) -> JsonDict: + """Apply the v2 client format to `d` in place, additionally stripping `room_id`. + + Deprecated backwards compatibility hack for modules importing it from + `synapse.events.utils`; don't use this in new code. + """ + def redact_event(event: Event) -> Event: """Returns a pruned version of the given event, which removes all keys we don't know about or think could potentially be dodgy. diff --git a/tests/events/test_utils.py b/tests/events/test_utils.py index b45f7ccad0..8435d6308a 100644 --- a/tests/events/test_utils.py +++ b/tests/events/test_utils.py @@ -30,6 +30,10 @@ from synapse.events.utils import ( PowerLevelsContent, clone_event, copy_and_fixup_power_levels_contents, + format_event_for_client_v1, + format_event_for_client_v2, + format_event_for_client_v2_without_room_id, + format_event_raw, maybe_upsert_event_field, prune_event, ) @@ -997,3 +1001,99 @@ class CopyPowerLevelsContentTestCase(stdlib_unittest.TestCase): def test_invalid_nesting_raises_type_error(self) -> None: with self.assertRaises(TypeError): copy_and_fixup_power_levels_contents({"a": {"b": {"c": 1}}}) # type: ignore[dict-item] + + +class FormatEventForClientTestCase(stdlib_unittest.TestCase): + """Tests for the standalone `format_event_*` transforms. + + These are Rust reimplementations kept purely as a backwards compatibility + hack for modules in the wild that import them from `synapse.events.utils` + (they were never part of the module API, and nothing in Synapse itself uses + them); like the original Python implementations they must mutate the dict + in place and return it. + """ + + def make_event(self) -> JsonDict: + return { + "event_id": "$event_id", + "room_id": "!room:test", + "sender": "@sender:test", + "type": "m.room.message", + "content": {"body": "hello"}, + "auth_events": [], + "prev_events": [], + "hashes": {}, + "signatures": {}, + "depth": 5, + "origin": "test", + "prev_state": [], + "unsigned": {"age": 100, "replaces_state": "$old", "other": 1}, + } + + def test_raw(self) -> None: + event_dict = self.make_event() + result = format_event_raw(event_dict) + self.assertIs(result, event_dict) + self.assertEqual(result, self.make_event()) + + def test_v2_drops_federation_keys(self) -> None: + event_dict = self.make_event() + result = format_event_for_client_v2(event_dict) + self.assertIs(result, event_dict) + self.assertEqual( + result, + { + "event_id": "$event_id", + "room_id": "!room:test", + "sender": "@sender:test", + "type": "m.room.message", + "content": {"body": "hello"}, + "unsigned": {"age": 100, "replaces_state": "$old", "other": 1}, + }, + ) + + def test_v2_without_room_id(self) -> None: + event_dict = self.make_event() + result = format_event_for_client_v2_without_room_id(event_dict) + self.assertIs(result, event_dict) + self.assertNotIn("room_id", result) + + def test_v1_copies_unsigned_keys(self) -> None: + event_dict = self.make_event() + result = format_event_for_client_v1(event_dict) + self.assertIs(result, event_dict) + self.assertEqual( + result, + { + "event_id": "$event_id", + "room_id": "!room:test", + "sender": "@sender:test", + "user_id": "@sender:test", + "type": "m.room.message", + "content": {"body": "hello"}, + "age": 100, + "replaces_state": "$old", + "unsigned": {"age": 100, "replaces_state": "$old", "other": 1}, + }, + ) + + def test_v1_no_sender(self) -> None: + event_dict = self.make_event() + del event_dict["sender"] + result = format_event_for_client_v1(event_dict) + self.assertNotIn("user_id", result) + + def test_non_json_values_pass_through(self) -> None: + # The transforms only move keys around; values that aren't + # JSON-serializable must survive untouched. + marker = object() + event_dict = { + "sender": "@sender:test", + "auth_events": marker, + "content": marker, + "unsigned": {"age": marker}, + } + result = format_event_for_client_v1(event_dict) + self.assertIs(result["content"], marker) + self.assertIs(result["age"], marker) + self.assertNotIn("auth_events", result)