Files
synapse/rust/src/events/mod.rs
T
Erik Johnston 93a1185ba4 Remove Python redaction in favour of Rust
Rather than keeping two implementations about
2026-05-28 10:59:31 +01:00

858 lines
34 KiB
Rust

/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2024 New Vector, 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>.
*
* Originally licensed under the Apache License, Version 2.0:
* <http://www.apache.org/licenses/LICENSE-2.0>.
*
* [This file includes modifications made by New Vector Limited]
*
*/
//! Classes for representing Matrix events.
//!
//! # Overview
//!
//! A Matrix event has a JSON shape that varies by *room version*. The
//! per-room-version shape is captured in the [`formats`] module, where
//! [`FormattedEvent`] is a generic container parametrised by the
//! room-version-specific portion (`EventFormatV1`, `EventFormatV2V3`,
//! `EventFormatV4`, `EventFormatVMSC4242`). See [`formats`] for the layout
//! of the over-the-wire JSON and how the room-version-agnostic fields are
//! split from the version-specific ones.
//!
//! [`Event`] is the `pyclass` exposed to Python. It bundles a fully parsed
//! [`FormattedEvent`] (with the version-specific part type-erased as
//! [`formats::EventFormatEnum`]) together with the pieces of state that
//! live alongside the event JSON in Synapse:
//!
//! - `event_id` — either taken from the event JSON (format v1) or derived
//! from the canonical-JSON hash (v2+); computed once at construction
//! time and cached.
//! - `room_version` — a `'static` reference into the global room-version
//! table, used to drive format-dependent behaviour (e.g. where the
//! `redacts` field lives, which redaction rules apply).
//! - `internal_metadata` — Synapse-internal flags that are *not* part of
//! the federated event (outlier status, soft-failure, stream positions,
//! …). These come from a separate dict at construction time.
//! - `rejected_reason` — `None` for accepted events; otherwise a short
//! string describing why auth rejected the event.
//!
use std::{borrow::Cow, sync::Arc};
use pyo3::{
exceptions::{PyAttributeError, PyKeyError, PyValueError},
pyclass, pyfunction, pymethods,
types::{PyAnyMethods, PyDict, PyDictMethods, PyList, PyMapping, PyModule, PyModuleMethods},
wrap_pyfunction, Bound, IntoPyObject, PyAny, PyResult, Python,
};
use pythonize::{depythonize, pythonize};
use crate::events::{
formats::{
EventFormatEnum, EventFormatV1, EventFormatV2V3, EventFormatV4, EventFormatVMSC4242,
FormattedEvent,
},
signatures::Signatures,
unsigned::Unsigned,
utils::redact,
};
use crate::{
duration::SynapseDuration,
events::{
constants::event_field::{HASHES, MSC4354_STICKY, SIGNATURES, UNSIGNED},
constants::membership_field::MEMBERSHIP,
constants::redaction_field::REDACTS,
constants::unsigned_field::{AGE, AGE_TS, REDACTED_BECAUSE},
internal_metadata::EventInternalMetadata,
utils::calculate_event_id,
},
room_versions::{EventFormatVersions, RoomVersion},
};
pub mod constants;
pub mod filter;
pub mod formats;
pub mod internal_metadata;
pub mod json_object;
pub mod signatures;
pub mod unsigned;
pub mod utils;
use json_object::JsonObject;
/// Called when registering modules with python.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
// Register the `JsonObject` class as a `Mapping` so that `isinstance` works.
PyMapping::register::<JsonObject>(py)?;
let child_module = PyModule::new(py, "events")?;
child_module.add_class::<EventInternalMetadata>()?;
child_module.add_class::<Signatures>()?;
child_module.add_class::<Unsigned>()?;
child_module.add_class::<JsonObject>()?;
child_module.add_class::<json_object::JsonObjectKeysView>()?;
child_module.add_class::<json_object::JsonObjectValuesView>()?;
child_module.add_class::<json_object::JsonObjectItemsView>()?;
child_module.add_class::<Event>()?;
child_module.add_function(wrap_pyfunction!(filter::event_visible_to_server_py, m)?)?;
child_module.add_function(wrap_pyfunction!(redact_event_to_dict_py, m)?)?;
child_module.add_function(wrap_pyfunction!(redact_event_dict_to_dict_py, m)?)?;
m.add_submodule(&child_module)?;
// We need to manually add the module to sys.modules to make `from
// synapse.synapse_rust import events` work.
py.import("sys")?
.getattr("modules")?
.set_item("synapse.synapse_rust.events", child_module)?;
Ok(())
}
/// The Rust-side representation of a Matrix event, exposed to Python.
///
/// Wraps a parsed [`FormattedEvent`] together with the per-event state
/// that Synapse tracks outside the event JSON (event ID, internal
/// metadata, rejection reason, and a reference to the room version that
/// produced this event). See the module-level docs for the high-level
/// design.
#[pyclass(frozen, weakref)]
pub struct Event {
/// The parsed event JSON.
parsed_event: FormattedEvent,
/// The event ID. For format v1 this is read directly from the JSON;
/// for v2+ it is computed from the canonical-JSON hash at
/// construction time and cached here.
event_id: Arc<str>,
/// Synapse-internal per-event state that lives outside the federated
/// JSON (e.g. outlier flag, soft-failure, stream positions).
#[pyo3(get)]
internal_metadata: EventInternalMetadata,
/// The room version this event was parsed for.
#[pyo3(get)]
room_version: &'static RoomVersion,
/// `None` for accepted events; otherwise a short reason set by auth
/// when the event was rejected.
rejected_reason: Option<Box<str>>,
}
#[pymethods]
impl Event {
#[new]
fn new_from_py<'a, 'py>(
py: Python<'py>,
event_dict: &'a Bound<'py, PyAny>,
room_version: &'a Bound<'py, PyAny>,
internal_metadata_dict: &'a Bound<'py, PyDict>,
rejected_reason: Option<String>,
) -> PyResult<Self> {
let room_version: &RoomVersion = {
let r = room_version.getattr("identifier")?;
let room_version_str = r.extract::<&str>()?;
room_version_str
.parse()
.map_err(|e| PyValueError::new_err(format!("Unsupported room version: {}", e)))?
};
let rejected_reason = rejected_reason.map(String::into_boxed_str);
// Parse the event dict into a FormattedEvent, converting any failures to
// a `ValueError`.
let parsed_event = depythonize_event_dict(room_version, event_dict).map_err(|err| {
let new_err = PyValueError::new_err(format!(
"Failed to parse event for room version {}",
room_version
));
new_err.set_cause(py, Some(err));
new_err
})?;
let internal_metadata = EventInternalMetadata::new(internal_metadata_dict)?;
let event_id = match &*parsed_event.specific_fields {
EventFormatEnum::V1(format) => {
// V1/V2 events have the event_id in the event dict.
Arc::clone(&format.event_id)
}
_ => {
// Calculate the event ID by hashing the event JSON. This can
// fail if the event can't be serialized to canonical JSON (e.g.
// having out-of-range integers), which we report as
// `ValueError` as it indicates the event is invalid.
let event_value = serde_json::to_value(&parsed_event).map_err(|err| {
PyValueError::new_err(format!("Failed to serialize event: {}", err))
})?;
calculate_event_id(&event_value, room_version)
.map_err(|err| {
PyValueError::new_err(format!("Failed to calculate event_id: {}", err))
})?
.into()
}
};
Ok(Self {
parsed_event,
event_id,
room_version,
rejected_reason,
internal_metadata,
})
}
/// Convert the event to a dictionary suitable for serialisation.
fn get_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
Ok(pythonize(py, &self.parsed_event)?)
}
/// Like `get_dict`, but serializes `unsigned` in a form suitable for
/// persistence.
fn get_dict_for_persistence<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let binding = self.get_dict(py)?;
let dict = binding.cast::<PyDict>()?;
dict.set_item("unsigned", self.parsed_event.unsigned.for_persistence(py)?)?;
Ok(binding)
}
/// Like [`Event::get_dict`], but serializes `unsigned` in a form suitable
/// for sending over federation.
#[pyo3(signature = (time_now = None))]
fn get_pdu_json<'py>(
&self,
py: Python<'py>,
time_now: Option<i64>,
) -> PyResult<Bound<'py, PyAny>> {
let obj = self.get_dict(py)?;
let dict = obj.cast::<PyDict>()?;
// Get or create the unsigned dict
if let Ok(Some(unsigned)) = dict.get_item(UNSIGNED) {
let unsigned = unsigned.cast::<PyDict>()?;
if let Some(time_now) = time_now {
if let Ok(Some(age_ts)) = unsigned.get_item(AGE_TS) {
let age = time_now - age_ts.extract::<i64>()?;
unsigned.set_item(AGE, age)?;
unsigned.del_item(AGE_TS)?;
}
}
// This may be a frozen event
unsigned.del_item(REDACTED_BECAUSE).ok();
}
Ok(obj)
}
/// Like [`Event::get_dict`], except strips fields like `signatures`,
/// `hashes` and `unsigned` so that the result is suitable as a template for
/// creating new events. Used in make_{join,leave,knock} flows.
fn get_templated_pdu_json<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
// Use get_dict but strip signatures, unsigned, and hashes — the
// joining/leaving/knocking server will re-sign and recalculate hashes.
let obj = self.get_dict(py)?;
let dict = obj.cast::<PyDict>()?;
dict.del_item(SIGNATURES).ok();
dict.del_item(UNSIGNED).ok();
dict.del_item(HASHES).ok();
Ok(obj)
}
#[getter]
fn rejected_reason(&self) -> Option<&str> {
self.rejected_reason.as_deref()
}
/// Returns the list of prev event IDs. The order matches the order
/// specified in the event, though there is no meaning to it.
fn prev_event_ids(&self) -> Vec<String> {
match &*self.parsed_event.specific_fields {
EventFormatEnum::V1(format) => format.prev_event_ids(),
EventFormatEnum::V2V3(format) => format.prev_events.clone(),
EventFormatEnum::V4(format) => format.prev_events.clone(),
EventFormatEnum::VMSC4242(format) => format.prev_events.clone(),
}
}
/// Returns the list of auth event IDs. The order matches the order
/// specified in the event, though there is no meaning to it.
fn auth_event_ids(&self) -> PyResult<Vec<String>> {
match &*self.parsed_event.specific_fields {
EventFormatEnum::V1(format) => Ok(format.auth_event_ids()),
EventFormatEnum::V2V3(format) => Ok(format.auth_event_ids()),
EventFormatEnum::V4(format) => {
Ok(format.auth_event_ids(&self.parsed_event.common_fields)?)
}
EventFormatEnum::VMSC4242(format) => Ok(format.auth_event_ids(self)?),
}
}
#[getter]
fn membership<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let content = self.content();
let value = content.get_field(MEMBERSHIP);
match value {
Some(value) => Ok(pythonize(py, value)?),
None => Err(PyKeyError::new_err(MEMBERSHIP)),
}
}
fn is_state(&self) -> bool {
self.parsed_event.common_fields.state_key.is_some()
}
/// Get the state key of this event, or None if it's not a state event.
fn get_state_key(&self) -> Option<&str> {
self.parsed_event.common_fields.state_key.as_deref_opt()
}
/// The EventFormatVersion implemented by this event.
#[getter]
fn format_version(&self) -> i32 {
self.room_version.event_format
}
/// Returns a deep copy of this object, such that modifying the copy will
/// not affect the original.
fn deep_copy(&self) -> PyResult<Event> {
let internal_metadata = self.internal_metadata.deep_copy()?;
let new_event = Event {
parsed_event: self.parsed_event.deep_copy(),
internal_metadata,
room_version: self.room_version,
rejected_reason: self.rejected_reason.clone(),
event_id: self.event_id.clone(),
};
Ok(new_event)
}
/// If this event has the `msc4354_sticky` top-level field, returns a
/// `SynapseDuration` representing the sticky duration. Otherwise returns
/// `None`.
fn sticky_duration(&self) -> Option<SynapseDuration> {
const MAX_DURATION: SynapseDuration = SynapseDuration::from_milliseconds(3600 * 1000);
let sticky_obj = self
.parsed_event
.common_fields
.other_fields
.get(MSC4354_STICKY);
let sticky_obj = match sticky_obj {
Some(serde_json::Value::Object(obj)) => obj,
_ => return None,
};
// Check for a valid duration field. The MSC requires `duration_ms` to
// be a non-negative integer. If it's missing or invalid, we treat the
// event as non-sticky by returning `None`.
let duration_ms = sticky_obj.get("duration_ms")?.as_u64()?;
let duration = SynapseDuration::from_milliseconds(duration_ms);
let duration = std::cmp::min(duration, MAX_DURATION);
Some(duration)
}
// Below are the methods for interacting with the event as a mapping.
//
// These are rarely used, so we take the easy approach of re-serializing the
// event to a Python dict and then delegating to the standard dict methods.
// We can't remove these functions as third-party modules may rely on them.
fn __contains__<'py>(&self, py: Python<'py>, key: &str) -> PyResult<bool> {
let dict = self.get_dict(py)?;
dict.contains(key)
}
/// This is deprecated in favor of `get`, but we still need to support it
/// for backwards compatibility with modules. This is therefore not exposed
/// in the type stubs.
fn __getitem__<'py>(&self, py: Python<'py>, key: &str) -> PyResult<Bound<'py, PyAny>> {
let dict = self.get_dict(py)?;
if dict.contains(key)? {
dict.get_item(key)
} else {
Err(PyKeyError::new_err(key.to_owned()))
}
}
#[pyo3(signature = (key, default=None))]
fn get<'py>(
&self,
py: Python<'py>,
key: &str,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
let dict = self.get_dict(py)?;
if dict.contains(key)? {
dict.get_item(key)
} else {
Ok(default.into_pyobject(py)?)
}
}
fn items<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
let dict = self.get_dict(py)?;
let dict = dict.cast::<PyDict>()?;
Ok(dict.items())
}
fn keys<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
let dict = self.get_dict(py)?;
let dict = dict.cast::<PyDict>()?;
Ok(dict.keys())
}
// Below are the getters for the top-level fields on Matrix events.
#[getter]
fn event_id(&self) -> &str {
&self.event_id
}
#[getter]
fn room_id(&self) -> PyResult<Cow<'_, str>> {
match &*self.parsed_event.specific_fields {
EventFormatEnum::V1(format) => Ok(format.room_id.as_ref().into()),
EventFormatEnum::V2V3(format) => Ok(format.room_id.as_ref().into()),
EventFormatEnum::V4(format) => {
Ok(format.room_id(&self.event_id, &self.parsed_event.common_fields)?)
}
EventFormatEnum::VMSC4242(format) => {
Ok(format.room_id(&self.event_id, &self.parsed_event.common_fields)?)
}
}
}
#[getter]
fn signatures(&self) -> Signatures {
self.parsed_event.signatures.clone()
}
#[getter]
fn content(&self) -> JsonObject {
self.parsed_event.common_fields.content.clone()
}
#[getter]
fn depth(&self) -> i64 {
self.parsed_event.common_fields.depth
}
#[getter]
fn hashes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let dict = PyDict::new(py);
for (key, value) in &self.parsed_event.common_fields.hashes {
dict.set_item(&**key, &**value)?;
}
Ok(dict)
}
#[getter]
fn origin_server_ts(&self) -> i64 {
self.parsed_event.common_fields.origin_server_ts
}
#[getter]
fn sender(&self) -> &str {
&self.parsed_event.common_fields.sender
}
/// Deprecated alias for `sender`. Kept for backwards compatibility with
/// modules and tests that still read `event.user_id`. This is therefore not
/// exposed in the type stubs.
#[getter]
fn user_id(&self) -> &str {
&self.parsed_event.common_fields.sender
}
#[getter(state_key)]
// We can't call this `state_key` because that would generate a
// `get_state_key` method which already exists.
fn state_key_attr(&self) -> PyResult<&str> {
let Some(state_key) = self.parsed_event.common_fields.state_key.as_deref_opt() else {
return Err(PyAttributeError::new_err("state_key"));
};
Ok(state_key)
}
#[getter]
fn r#type(&self) -> &str {
&self.parsed_event.common_fields.type_
}
#[getter]
fn unsigned(&self) -> Unsigned {
self.parsed_event.unsigned.clone()
}
#[getter]
fn prev_state_events(&self) -> PyResult<Vec<String>> {
// `prev_state_events` should only be called after validating the event
// is of a format that supports MSC4242, so we return an AttributeError
// for formats that don't support it.
match &*self.parsed_event.specific_fields {
EventFormatEnum::V1(_) | EventFormatEnum::V2V3(_) | EventFormatEnum::V4(_) => {
Err(PyAttributeError::new_err("prev_state_events"))
}
EventFormatEnum::VMSC4242(format) => Ok(format.prev_state_events.clone()),
}
}
#[getter]
fn redacts<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyAny>>> {
let common = &self.parsed_event.common_fields;
let value = if self.room_version.updated_redaction_rules {
common.content.get_field(REDACTS)
} else {
common.other_fields.get(REDACTS)
};
value
.map(|v| pythonize(py, v).map_err(Into::into))
.transpose()
}
}
fn depythonize_event_dict(
room_version: &RoomVersion,
event_dict: &Bound<'_, PyAny>,
) -> PyResult<FormattedEvent> {
let formatted_event: FormattedEvent = match room_version.event_format {
EventFormatVersions::ROOM_V1_V2 => {
let event_format: FormattedEvent<EventFormatV1> = depythonize(event_dict)?;
event_format.into()
}
EventFormatVersions::ROOM_V3 | EventFormatVersions::ROOM_V4_PLUS => {
let event_format: FormattedEvent<EventFormatV2V3> = depythonize(event_dict)?;
event_format.into()
}
EventFormatVersions::ROOM_V11_HYDRA_PLUS => {
let event_format: FormattedEvent<EventFormatV4> = depythonize(event_dict)?;
event_format.into()
}
EventFormatVersions::ROOM_VMSC4242 => {
let event_format: FormattedEvent<EventFormatVMSC4242> = depythonize(event_dict)?;
event_format.into()
}
_ => {
return Err(PyValueError::new_err(format!(
"Unsupported room version: {}",
room_version
)))
}
};
formatted_event.validate()?;
Ok(formatted_event)
}
/// Returns a pruned version of the given event, which removes all keys we don't
/// know about or think could potentially be dodgy.
///
/// Returns the redacted event as a dict.
#[pyfunction(name = "redact_event_to_dict")]
fn redact_event_to_dict_py<'py>(py: Python<'py>, event: &'py Event) -> PyResult<Bound<'py, PyAny>> {
let event_value = serde_json::to_value(&event.parsed_event).map_err(|err| {
PyValueError::new_err(format!("Failed to serialize event for redaction: {}", err))
})?;
let redacted = redact(&event_value, event.room_version)?;
let redacted_py = pythonize(py, &redacted)?;
Ok(redacted_py)
}
/// Returns a pruned version of the given event dict, which removes all keys we
/// don't know about or think could potentially be dodgy.
///
/// Returns the redacted event as a dict.
#[pyfunction(name = "redact_event_dict_to_dict")]
fn redact_event_dict_to_dict_py<'py>(
py: Python<'py>,
room_version: &RoomVersion,
event_dict: &'py Bound<'py, PyAny>,
) -> PyResult<Bound<'py, PyAny>> {
let event_value = depythonize(event_dict)?;
let redacted = redact(&event_value, room_version)?;
let redacted_py = pythonize(py, &redacted)?;
Ok(redacted_py)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{
constants::event_type::M_ROOM_CREATE,
formats::{EventFormatV1, EventFormatV2V3, EventFormatV4, EventFormatVMSC4242},
};
#[test]
fn test_basic_v3_roundtrip() {
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@anon-20260225_142731-20:localhost:8800","content":{"room_version":"10","creator":"@anon-20260225_142731-20:localhost:8800"},"depth":1,"room_id":"!qVoJSympOqdUQRUfiC:localhost:8800","state_key":"","origin_server_ts":1772029657149,"hashes":{"sha256":"RIDkn4CrExGMOfRZlHl//1weAro5QC/q2D76YcyAUqk"},"signatures":{"localhost:8800":{"ed25519:a_GMSl":"GU7WmvI2Kd5kLrXKrWpRbUfEiVKGgH0sxQNEpBMMvgF3QhHN25AubVMmIClht5r/c+Iihb1xsq1j5Sw+RGfiDg"}},"unsigned":{"age_ts":1772029657149}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV2V3> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert_eq!(&*event.common_fields.type_, M_ROOM_CREATE);
assert_eq!(
&*event.specific_fields.room_id,
"!qVoJSympOqdUQRUfiC:localhost:8800"
);
assert_eq!(event_value, parsed_value);
}
#[test]
fn test_room_id_for_create_event_format_v4() {
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@erikj:jki.re","content":{"room_version":"12","predecessor":{"room_id":"!VuNGkDTdbMOOxSmuDa:jki.re"}},"depth":1,"state_key":"","origin_server_ts":1775568141481,"hashes":{"sha256":"qBX+glsKvogXFrvsEN0eh13pO2kpuE6o/b4yREPtOqw"},"signatures":{"jki.re":{"ed25519:auto":"n/4gHQRagk3r1r24L/7a+oaMMf9cysVfQRYdjpDZcf4ppkVym33rhTW18Vy4zMa1L5nsWLkxsBvbrRRDYUOhBQ"}},"unsigned":{"age_ts":1775568141481}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
let event_id = calculate_event_id(&event_value, &RoomVersion::V12).unwrap();
assert_eq!(
&*event
.specific_fields
.room_id(&event_id, &event.common_fields)
.unwrap(),
"!BeXKh925K_M46DwsuJFR0EyBpE1P7CFUDGuWW4xw55Y"
);
}
#[test]
fn test_basic_v1_roundtrip() {
let json = r#"{"auth_events":[["$auth1:localhost",{"sha256":"abc"}],["$auth2:localhost",{"sha256":"def"}]],"prev_events":[["$prev1:localhost",{"sha256":"ghi"}]],"type":"m.room.message","sender":"@user:localhost","content":{"body":"hello","msgtype":"m.text"},"depth":5,"room_id":"!room:localhost","event_id":"$event1:localhost","origin_server_ts":1234567890,"hashes":{"sha256":"base64hash"},"signatures":{"localhost":{"ed25519:key":"sig"}},"unsigned":{}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV1> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert_eq!(&*event.common_fields.type_, "m.room.message");
assert!(event.common_fields.state_key.is_absent());
assert_eq!(&*event.specific_fields.room_id, "!room:localhost");
assert_eq!(&*event.specific_fields.event_id, "$event1:localhost");
// Check auth/prev event extraction
let auth_ids = event.specific_fields.auth_event_ids();
assert_eq!(auth_ids, vec!["$auth1:localhost", "$auth2:localhost"]);
let prev_ids = event.specific_fields.prev_event_ids();
assert_eq!(prev_ids, vec!["$prev1:localhost"]);
assert_eq!(event_value, parsed_value);
}
#[test]
fn test_basic_v4_roundtrip_with_room_id() {
// A regular (non-create) V4 event has an explicit room_id.
let json = r#"{"auth_events":["$auth1","$auth2"],"prev_events":["$prev1"],"type":"m.room.message","sender":"@user:localhost","content":{"body":"hello","msgtype":"m.text"},"depth":5,"room_id":"!room:localhost","origin_server_ts":1234567890,"hashes":{"sha256":"base64hash"},"signatures":{"localhost":{"ed25519:key":"sig"}},"unsigned":{}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert_eq!(&*event.common_fields.type_, "m.room.message");
assert_eq!(
event.specific_fields.room_id.as_deref_opt(),
Some("!room:localhost")
);
assert_eq!(
event.specific_fields.auth_events,
vec!["$auth1".to_string(), "$auth2".to_string()]
);
assert_eq!(
event.specific_fields.prev_events,
vec!["$prev1".to_string()]
);
assert_eq!(event_value, parsed_value);
}
#[test]
fn test_basic_v4_roundtrip_create_event() {
// A V4 create event for a v12 room has no room_id field.
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@erikj:jki.re","content":{"room_version":"12"},"depth":1,"state_key":"","origin_server_ts":1775568141481,"hashes":{"sha256":"qBX+glsKvogXFrvsEN0eh13pO2kpuE6o/b4yREPtOqw"},"signatures":{"jki.re":{"ed25519:auto":"sig"}},"unsigned":{}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert!(event.specific_fields.room_id.is_absent());
assert_eq!(&*event.common_fields.type_, M_ROOM_CREATE);
// Create events have no implicit auth events.
assert!(event
.specific_fields
.auth_event_ids(&event.common_fields)
.unwrap()
.is_empty());
assert_eq!(event_value, parsed_value);
}
#[test]
fn test_v4_auth_event_ids_implicit_create() {
// Non-create events implicitly include the create event (derived from
// the room ID) in their auth chain.
let json = r#"{"auth_events":["$auth1"],"prev_events":["$prev1"],"type":"m.room.message","sender":"@user:localhost","content":{"body":"hi","msgtype":"m.text"},"depth":5,"room_id":"!BeXKh925K_M46DwsuJFR0EyBpE1P7CFUDGuWW4xw55Y","origin_server_ts":1234567890,"hashes":{"sha256":"h"},"signatures":{"localhost":{"ed25519:k":"s"}},"unsigned":{}}"#;
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
let auth_ids = event
.specific_fields
.auth_event_ids(&event.common_fields)
.unwrap();
assert_eq!(
auth_ids,
vec![
"$auth1".to_string(),
"$BeXKh925K_M46DwsuJFR0EyBpE1P7CFUDGuWW4xw55Y".to_string(),
]
);
}
#[test]
fn test_v4_validate_rejects_missing_room_id_for_non_create() {
// A v12 non-create event without a room_id must fail validation.
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.message","sender":"@u:l","content":{},"depth":2,"state_key":"","origin_server_ts":1,"hashes":{"sha256":"h"},"signatures":{"l":{"ed25519:k":"s"}},"unsigned":{}}"#;
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
assert!(event
.specific_fields
.validate(&event.common_fields)
.is_err());
}
#[test]
fn test_v4_validate_accepts_create_without_room_id() {
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@u:l","content":{"room_version":"12"},"depth":1,"state_key":"","origin_server_ts":1,"hashes":{"sha256":"h"},"signatures":{"l":{"ed25519:k":"s"}},"unsigned":{}}"#;
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
event
.specific_fields
.validate(&event.common_fields)
.unwrap();
}
#[test]
fn test_basic_vmsc4242_roundtrip() {
// VMSC4242 introduces a `prev_state_events` field on top of V4.
let json = r#"{"auth_events":["$auth1"],"prev_events":["$prev1"],"prev_state_events":["$pstate1","$pstate2"],"type":"m.room.member","sender":"@user:localhost","content":{"membership":"join"},"depth":5,"room_id":"!room:localhost","state_key":"@user:localhost","origin_server_ts":1234567890,"hashes":{"sha256":"h"},"signatures":{"localhost":{"ed25519:k":"s"}},"unsigned":{}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatVMSC4242> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert_eq!(
event.specific_fields.prev_state_events,
vec!["$pstate1".to_string(), "$pstate2".to_string()]
);
assert_eq!(
event.specific_fields.room_id.as_deref_opt(),
Some("!room:localhost")
);
assert_eq!(
event.common_fields.state_key.as_deref_opt(),
Some("@user:localhost")
);
assert_eq!(event_value, parsed_value);
}
#[test]
fn test_vmsc4242_room_id_for_create_event() {
let json = r#"{"auth_events":[],"prev_events":[],"prev_state_events":[],"type":"m.room.create","sender":"@erikj:jki.re","content":{"room_version":"12","predecessor":{"room_id":"!VuNGkDTdbMOOxSmuDa:jki.re"}},"depth":1,"state_key":"","origin_server_ts":1775568141481,"hashes":{"sha256":"qBX+glsKvogXFrvsEN0eh13pO2kpuE6o/b4yREPtOqw"},"signatures":{"jki.re":{"ed25519:auto":"n/4gHQRagk3r1r24L/7a+oaMMf9cysVfQRYdjpDZcf4ppkVym33rhTW18Vy4zMa1L5nsWLkxsBvbrRRDYUOhBQ"}},"unsigned":{"age_ts":1775568141481}}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatVMSC4242> = serde_json::from_str(json).unwrap();
// The event_id calculation is independent of the `prev_state_events`
// field not being present in V4, so the same event_id derivation works.
let event_id = calculate_event_id(&event_value, &RoomVersion::V12).unwrap();
assert_eq!(
&*event
.specific_fields
.room_id(&event_id, &event.common_fields)
.unwrap(),
"!BeXKh925K_M46DwsuJFR0EyBpE1P7CFUDGuWW4xw55Y"
);
}
#[test]
fn test_event_format_enum_untagged_roundtrip() {
// The untagged EventFormatEnum serialization/deserialization is
// driven by fields, so serializing any variant must match the
// original JSON exactly.
let v2v3_json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@a:b","content":{},"depth":1,"room_id":"!r:b","state_key":"","origin_server_ts":1,"hashes":{"sha256":"h"},"signatures":{"b":{"ed25519:k":"s"}},"unsigned":{}}"#;
let v2v3_value: serde_json::Value = serde_json::from_str(v2v3_json).unwrap();
let v2v3_container: FormattedEvent<EventFormatV2V3> =
serde_json::from_str(v2v3_json).unwrap();
assert_eq!(serde_json::to_value(&v2v3_container).unwrap(), v2v3_value);
assert_eq!(
serde_json::to_value(v2v3_container.into_general()).unwrap(),
v2v3_value
);
let v4_json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.create","sender":"@a:b","content":{"room_version":"12"},"depth":1,"state_key":"","origin_server_ts":1,"hashes":{"sha256":"h"},"signatures":{"b":{"ed25519:k":"s"}},"unsigned":{}}"#;
let v4_value: serde_json::Value = serde_json::from_str(v4_json).unwrap();
let v4_container: FormattedEvent<EventFormatV4> = serde_json::from_str(v4_json).unwrap();
assert_eq!(serde_json::to_value(&v4_container).unwrap(), v4_value);
assert_eq!(
serde_json::to_value(v4_container.into_general()).unwrap(),
v4_value
);
}
#[test]
fn test_unknown_top_level_fields_preserved_roundtrip() {
// Extra top-level fields (e.g. unknown or experimental) are captured
// via `other_fields` and must round-trip losslessly.
let json = r#"{"auth_events":[],"prev_events":[],"type":"m.room.message","sender":"@a:b","content":{"body":"hi","msgtype":"m.text"},"depth":1,"room_id":"!r:b","origin_server_ts":1,"hashes":{"sha256":"h"},"signatures":{"b":{"ed25519:k":"s"}},"unsigned":{},"msc4354_sticky":{"duration_ms":5000},"some_unknown_field":"some_value"}"#;
let event_value: serde_json::Value = serde_json::from_str(json).unwrap();
let event: FormattedEvent<EventFormatV4> = serde_json::from_str(json).unwrap();
let parsed_value = serde_json::to_value(&event).unwrap();
assert!(event
.common_fields
.other_fields
.contains_key(MSC4354_STICKY));
assert!(event
.common_fields
.other_fields
.contains_key("some_unknown_field"));
assert_eq!(event_value, parsed_value);
}
}