feat(sync): Add MSC4354 sticky events

This commit is contained in:
Erwan Leboucher authored and Ellis Git committed 2026-08-02 17:28:00 +00:00
1 parent 7789399ba5
commit 43197f695c
25 files changed
+572 -18

No files matched your search

+4
View File
@@ -181,6 +181,10 @@ pub trait Event: Clone + Debug {
/// The state key for this event.
fn state_key(&self) -> Option<&str>;
/// The MSC4354 sticky object, verbatim, if the event has one.
#[inline]
fn sticky(&self) -> Option<&RawJsonValue> { None }
/// The event type.
fn kind(&self) -> &TimelineEventType;
+10
View File
@@ -9,6 +9,7 @@ use ruma::{
use serde_json::json;
use super::{Event, redact};
use crate::matrix::pdu::sticky;
pub struct Owned<E: Event>(pub(super) E);
@@ -36,6 +37,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnySyncTimelineEvent> {
if let Some(state_key) = event.state_key() {
json["state_key"] = json!(state_key);
}
if let Some(sticky_object) = event.sticky() {
json[sticky::PDU_KEY] = json!(sticky_object);
}
if let Some(unsigned) = event.unsigned() {
json["unsigned"] = json!(unsigned);
}
@@ -67,6 +71,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnyTimelineEvent> {
if let Some(state_key) = event.state_key() {
json["state_key"] = json!(state_key);
}
if let Some(sticky_object) = event.sticky() {
json[sticky::PDU_KEY] = json!(sticky_object);
}
if let Some(unsigned) = event.unsigned() {
json["unsigned"] = json!(unsigned);
}
@@ -98,6 +105,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnyMessageLikeEvent> {
if let Some(state_key) = event.state_key() {
json["state_key"] = json!(state_key);
}
if let Some(sticky_object) = event.sticky() {
json[sticky::PDU_KEY] = json!(sticky_object);
}
if let Some(unsigned) = event.unsigned() {
json["unsigned"] = json!(unsigned);
}
+12
View File
@@ -3,6 +3,7 @@ mod id;
mod partial;
mod raw_id;
mod redact;
pub mod sticky;
#[cfg(test)]
mod tests;
mod unsigned;
@@ -46,6 +47,11 @@ pub struct Pdu {
pub content: Box<RawJsonValue>,
/// MSC4354 sticky object, kept verbatim because it is signed. Interpret it
/// with [`sticky`], never by rewriting it.
#[serde(rename = "msc4354_sticky", default, skip_serializing_if = "Option::is_none")]
pub sticky: Option<Box<RawJsonValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_key: Option<StateKey>,
@@ -137,6 +143,9 @@ impl Event for Pdu {
#[inline]
fn state_key(&self) -> Option<&str> { self.state_key.as_deref() }
#[inline]
fn sticky(&self) -> Option<&RawJsonValue> { self.sticky.as_deref() }
#[inline]
fn kind(&self) -> &TimelineEventType { &self.kind }
@@ -208,6 +217,9 @@ impl Event for &Pdu {
#[inline]
fn state_key(&self) -> Option<&str> { self.state_key.as_deref() }
#[inline]
fn sticky(&self) -> Option<&RawJsonValue> { self.sticky.as_deref() }
#[inline]
fn kind(&self) -> &TimelineEventType { &self.kind }
+3
View File
@@ -20,6 +20,8 @@ pub struct PartialPdu {
pub content: Box<RawJsonValue>,
pub sticky: Option<Box<RawJsonValue>>,
pub unsigned: Option<Unsigned>,
pub state_key: Option<StateKey>,
@@ -66,6 +68,7 @@ impl Default for PartialPdu {
Self {
event_type: "m.room.message".into(),
content: to_raw_value(&json!({})).unwrap(),
sticky: None,
unsigned: None,
state_key: None,
redacts: None,
+3
View File
@@ -11,6 +11,9 @@ impl super::Pdu {
self.unsigned = None;
// a redacted sticky event is just a normal event (MSC4354)
self.sticky = None;
let mut content = serde_json::from_str(self.content.get()).map_err(|e| {
err!(Request(BadJson("Failed to deserialize content into type: {e}")))
})?;
+155
View File
@@ -0,0 +1,155 @@
//! Sticky event helpers, as defined by [MSC4354].
//!
//! [MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354
use ruma::{UInt, events::sticky::StickyDurationMs};
use serde::Deserialize;
use serde_json::{
json,
value::{RawValue as RawJsonValue, to_raw_value},
};
/// Name of the sticky object at the top level of a PDU. Unstable prefix.
pub const PDU_KEY: &str = "msc4354_sticky";
/// Name of the expiry hint injected into `unsigned`. Unstable prefix.
pub const TTL_UNSIGNED_KEY: &str = "msc4354_sticky_duration_ttl_ms";
/// The longest an event may remain sticky (one hour).
#[allow(clippy::as_conversions)]
pub const MAX_DURATION_MS: u64 = StickyDurationMs::MAX as u64;
#[derive(Deserialize)]
struct Object {
duration_ms: u64,
}
/// Build the sticky object for an event we are creating locally.
#[must_use]
pub fn object(duration_ms: StickyDurationMs) -> Box<RawJsonValue> {
to_raw_value(&json!({ "duration_ms": duration_ms }))
.expect("a StickyDurationMs always produces a valid sticky object")
}
/// The sticky duration of `sticky`, clamped to [`MAX_DURATION_MS`], or `None`
/// if this is not a valid sticky object.
///
/// A malformed object leaves the event unsticky rather than invalidating it, so
/// a peer more lenient than us cannot split the room DAG.
#[must_use]
pub fn duration_ms(sticky: &RawJsonValue) -> Option<u64> {
serde_json::from_str::<Object>(sticky.get())
.ok()
.map(|sticky| sticky.duration_ms.min(MAX_DURATION_MS))
}
/// The time at which an event stops being sticky, in milliseconds since the
/// unix epoch, or `None` if the event was never sticky.
///
/// MSC4354 starts the clock at `min(received_ts, origin_server_ts)` so a server
/// cannot extend stickiness by dating its events forward. We keep no receive
/// time per PDU, so instead we deny stickiness to events dated further ahead
/// than any event could still be sticky for.
#[must_use]
pub fn expires_at(origin_server_ts: UInt, sticky: &RawJsonValue, now: u64) -> Option<u64> {
let origin_server_ts = u64::from(origin_server_ts);
if origin_server_ts.saturating_sub(now) > MAX_DURATION_MS {
return None;
}
let start = origin_server_ts.min(now);
duration_ms(sticky).map(|duration_ms| start.saturating_add(duration_ms))
}
#[must_use]
pub fn is_sticky(origin_server_ts: UInt, sticky: &RawJsonValue, now: u64) -> bool {
expires_at(origin_server_ts, sticky, now).is_some_and(|expires_at| expires_at > now)
}
#[cfg(test)]
mod tests {
use ruma::{UInt, events::sticky::StickyDurationMs};
use serde_json::value::{RawValue as RawJsonValue, to_raw_value};
use super::{MAX_DURATION_MS, duration_ms, expires_at, is_sticky, object};
fn raw(json: &serde_json::Value) -> Box<RawJsonValue> { to_raw_value(json).unwrap() }
fn ts(millis: u64) -> UInt { UInt::try_from(millis).unwrap() }
#[test]
fn duration_of_valid_object() {
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
assert_eq!(duration_ms(&sticky), Some(300_000));
}
#[test]
fn duration_is_clamped_to_one_hour() {
let sticky = raw(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
assert_eq!(duration_ms(&sticky), Some(MAX_DURATION_MS));
}
#[test]
fn malformed_objects_are_not_sticky() {
for malformed in [
serde_json::json!({}),
serde_json::json!({ "duration_ms": -1 }),
serde_json::json!({ "duration_ms": "300000" }),
serde_json::json!({ "duration_ms": 3000.5 }),
serde_json::json!("nonsense"),
serde_json::json!(null),
] {
let sticky = raw(&malformed);
assert_eq!(duration_ms(&sticky), None, "{malformed} should not be sticky");
}
}
#[test]
fn object_round_trips_through_duration_ms() {
let sticky = object(StickyDurationMs::new_clamped(600_000_u32));
assert_eq!(duration_ms(&sticky), Some(600_000));
}
#[test]
fn expiry_is_origin_server_ts_plus_duration() {
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
assert_eq!(expires_at(ts(1_000_000), &sticky, 1_100_000), Some(1_300_000));
}
#[test]
fn expiry_uses_the_clamped_duration() {
let sticky = raw(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
assert_eq!(
expires_at(ts(1_000_000), &sticky, 1_000_000),
Some(1_000_000 + MAX_DURATION_MS)
);
}
#[test]
fn timestamps_far_in_the_future_are_not_sticky() {
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
let now = 1_000_000;
// a little ahead of us is tolerated, and starts the clock at `now`
let slightly_ahead = ts(now + MAX_DURATION_MS);
assert_eq!(expires_at(slightly_ahead, &sticky, now), Some(now + 300_000));
// beyond the point any honest event could still be sticky, it is not
let far_ahead = ts(now + MAX_DURATION_MS + 1);
assert_eq!(expires_at(far_ahead, &sticky, now), None);
}
#[test]
fn stickiness_ends_at_the_expiry() {
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
assert!(is_sticky(ts(1_000_000), &sticky, 1_299_999));
assert!(!is_sticky(ts(1_000_000), &sticky, 1_300_000));
assert!(!is_sticky(ts(1_000_000), &sticky, 1_300_001));
}
#[test]
fn zero_duration_is_never_sticky() {
let sticky = raw(&serde_json::json!({ "duration_ms": 0 }));
assert!(!is_sticky(ts(1_000_000), &sticky, 1_000_000));
}
}
+93
View File
@@ -15,3 +15,96 @@ fn normal_parse() {
assert!(!backfilled, "backfilled variant");
}
fn sticky_pdu(sticky: &serde_json::Value) -> super::Pdu {
serde_json::from_value(serde_json::json!({
"event_id": "$test:example.com",
"room_id": "!test:example.com",
"sender": "@test:example.com",
"origin_server_ts": 1_000_000,
"type": "m.room.message",
"content": { "msgtype": "m.text", "body": "test" },
"msc4354_sticky": sticky,
"prev_events": [],
"depth": 1,
"auth_events": [],
"hashes": { "sha256": "test_hash" },
}))
.expect("PDU is valid")
}
#[test]
fn sticky_object_round_trips_verbatim() {
// the object is signed, so serialization must not touch it
let pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000, "unknown": "key" }));
let value = serde_json::to_value(&pdu).expect("PDU serializes");
assert_eq!(
value["msc4354_sticky"],
serde_json::json!({ "duration_ms": 300_000, "unknown": "key" })
);
}
#[test]
fn out_of_range_sticky_object_is_clamped_not_rejected() {
let pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
let sticky = pdu.sticky.as_deref().expect("sticky object is kept");
assert_eq!(super::sticky::duration_ms(sticky), Some(super::sticky::MAX_DURATION_MS));
}
#[test]
fn nonsense_sticky_object_does_not_reject_the_pdu() {
let pdu = sticky_pdu(&serde_json::json!("nonsense"));
let sticky = pdu.sticky.as_deref().expect("sticky object is kept");
assert_eq!(super::sticky::duration_ms(sticky), None);
}
#[test]
fn redaction_removes_stickiness() {
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
pdu.redact(&ruma::RoomVersionId::V11, serde_json::json!({}))
.expect("redaction succeeds");
assert!(pdu.sticky.is_none());
}
#[test]
fn sticky_ttl_is_added_to_unsigned() {
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
pdu.origin_server_ts = ruma::MilliSecondsSinceUnixEpoch::now().get();
pdu.add_sticky_duration_ttl().expect("ttl is added");
let unsigned: serde_json::Value =
serde_json::from_str(pdu.unsigned.as_deref().expect("unsigned is set").get())
.expect("unsigned is valid");
let ttl = unsigned["msc4354_sticky_duration_ttl_ms"]
.as_u64()
.expect("ttl is a number");
assert!(ttl > 0 && ttl <= 300_000, "ttl {ttl} out of range");
}
#[test]
fn no_ttl_for_events_that_are_not_sticky() {
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": "nonsense" }));
pdu.add_sticky_duration_ttl().expect("ttl is skipped");
assert!(pdu.unsigned.is_none());
}
#[test]
fn set_unsigned_adds_the_sticky_ttl() {
// sticky events in the timeline get the hint through here, not the sticky
// section
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
pdu.origin_server_ts = ruma::MilliSecondsSinceUnixEpoch::now().get();
pdu.set_unsigned(None);
let unsigned: serde_json::Value =
serde_json::from_str(pdu.unsigned.as_deref().expect("unsigned is set").get())
.expect("unsigned is valid");
assert!(unsigned["msc4354_sticky_duration_ttl_ms"].is_number());
}
+27 -1
View File
@@ -3,7 +3,7 @@ use std::{borrow::Borrow, collections::BTreeMap};
use ruma::MilliSecondsSinceUnixEpoch;
use serde_json::value::{RawValue as RawJsonValue, Value as JsonValue, to_raw_value};
use super::Pdu;
use super::{Pdu, sticky};
use crate::{Result, err, result::LogErr};
impl Pdu {
@@ -17,6 +17,7 @@ impl Pdu {
self.remove_transaction_id().log_err().ok();
}
self.add_age().log_err().ok();
self.add_sticky_duration_ttl().log_err().ok();
}
pub fn remove_transaction_id(&mut self) -> Result {
@@ -58,6 +59,31 @@ impl Pdu {
Ok(())
}
pub fn add_sticky_duration_ttl(&mut self) -> Result {
use BTreeMap as Map;
let now = u64::from(MilliSecondsSinceUnixEpoch::now().get());
let Some(expires_at) = self
.sticky
.as_deref()
.and_then(|sticky| sticky::expires_at(self.origin_server_ts, sticky, now))
else {
return Ok(());
};
let mut unsigned: Map<&str, Box<RawJsonValue>> = self
.unsigned
.as_deref()
.map(RawJsonValue::get)
.map_or_else(|| Ok(Map::new()), serde_json::from_str)
.map_err(|e| err!(Database("Invalid unsigned in pdu event: {e}")))?;
unsigned.insert(sticky::TTL_UNSIGNED_KEY, to_raw_value(&expires_at.saturating_sub(now))?);
self.unsigned = Some(to_raw_value(&unsigned)?);
Ok(())
}
pub fn add_relation(&mut self, name: &str, pdu: Option<&Self>) -> Result {
use serde_json::Map;
+1
View File
@@ -414,6 +414,7 @@ where
content,
origin: None,
redacts: None,
sticky: None,
unsigned: None,
auth_events,
prev_events,
+2
View File
@@ -414,6 +414,7 @@ pub(crate) fn to_init_pdu_event(
content,
origin: None,
redacts: None,
sticky: None,
unsigned: None,
auth_events: vec![],
prev_events: vec![],
@@ -462,6 +463,7 @@ where
content,
origin: None,
redacts: None,
sticky: None,
unsigned: None,
auth_events,
prev_events,