Skip to main content

tuwunel_service/membership/
stripped_state.rs

1use std::{
2	borrow::Cow,
3	collections::{HashMap, hash_map::Entry},
4};
5
6use ruma::{
7	CanonicalJsonObject, CanonicalJsonValue, OwnedRoomId, RoomId, RoomVersionId, UserId,
8	api::federation::membership::RawStrippedState,
9	events::{AnyStrippedStateEvent, StateEventType, StateKey},
10	room_version_rules::RoomIdFormatVersion,
11	serde::{JsonObject, Raw},
12};
13use serde::Deserialize;
14use tuwunel_core::{Event, PduEvent, Result, implement, matrix::event::gen_event_id};
15
16use super::Service;
17
18/// The `(type, state_key)` pair naming one cell of a room's state.
19type StateCell = (StateEventType, StateKey);
20
21/// The chosen entry per cell, and the entries chosen so far in array order.
22type Accumulator = (HashMap<StateCell, usize>, Vec<RawStrippedState>);
23
24/// The cell an entry names, borrowed where its JSON needs no unescaping.
25#[derive(Deserialize)]
26struct Cell<'a> {
27	#[serde(rename = "type", borrow)]
28	kind: Cow<'a, str>,
29
30	#[serde(borrow)]
31	state_key: Cow<'a, str>,
32}
33
34/// MSC4311 verdict for the create event carried in federated stripped state.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum StrippedCreateVerdict {
37	/// A full create PDU bound to the room with valid signatures.
38	Valid,
39
40	/// No `m.room.create` event was present.
41	Missing,
42
43	/// A create event was present only in the legacy stripped form.
44	NotPdu,
45
46	/// A create PDU was present but does not bind to the room.
47	WrongRoom,
48
49	/// A create PDU was present but failed signature or hash checks.
50	BadSignature,
51}
52
53/// Whether a non-`Valid` verdict warrants rejecting an invite or dropping the
54/// event from knock state, given the room version and operator policy.
55#[must_use]
56pub fn enforce_stripped_create(
57	verdict: StrippedCreateVerdict,
58	v12_room_ids: bool,
59	enforce: bool,
60) -> bool {
61	use StrippedCreateVerdict::*;
62
63	match verdict {
64		| Valid => false,
65		// A complete create PDU bound to a different room must fail for v12+
66		// rooms even during the migration window (MSC4311 Migration).
67		| WrongRoom => v12_room_ids || enforce,
68		| Missing | NotPdu | BadSignature => enforce,
69	}
70}
71
72/// Whether the room version derives room ids from the create event hash
73/// (MSC4291, room version 12 and above), which changes how a create event
74/// binds to its room.
75#[must_use]
76pub fn v12_room_ids(room_version: &RoomVersionId) -> bool {
77	room_version
78		.rules()
79		.is_some_and(|rules| matches!(rules.room_id_format, RoomIdFormatVersion::V2))
80}
81
82/// Collapse a federated stripped-state array to one entry per state cell.
83///
84/// The array is state, so two entries sharing a cell are already malformed, and
85/// which one a consumer obeys is otherwise decided by its own pick strategy
86/// against an ordering the spec never gives.
87///
88/// A cell's first full PDU wins, and only a cell holding no PDU keeps its first
89/// legacy entry. An entry naming no readable cell, missing either half, drops:
90/// it addresses no state, and reading a missing `state_key` as the empty one
91/// would let a non-state event occupy a real cell and displace it.
92#[must_use]
93pub fn dedup_stripped_state(state: Vec<RawStrippedState>) -> Vec<RawStrippedState> {
94	let (_, kept) = state
95		.into_iter()
96		.filter_map(|entry| state_cell(&entry).map(|cell| (cell, entry)))
97		.fold(Accumulator::default(), |(mut chosen, mut kept), (cell, entry)| {
98			match chosen.entry(cell) {
99				| Entry::Vacant(vacant) => {
100					vacant.insert(kept.len());
101					kept.push(entry);
102				},
103				| Entry::Occupied(occupied) => {
104					let held = &mut kept[*occupied.get()];
105
106					if is_legacy(held) && !is_legacy(&entry) {
107						*held = entry;
108					}
109				},
110			}
111
112			(chosen, kept)
113		});
114
115	kept
116}
117
118/// Drop the entries occupying a user's membership cell.
119///
120/// The invite route appends its own copy of that event, whose sender the origin
121/// check bound to the sending server, so a copy the sending server chose is
122/// never the one to serve.
123pub fn without_member(
124	state: Vec<RawStrippedState>,
125	user_id: &UserId,
126) -> impl Iterator<Item = RawStrippedState> {
127	state
128		.into_iter()
129		.filter(move |entry| !occupies_member_cell(entry, user_id))
130}
131
132fn state_cell(state: &RawStrippedState) -> Option<StateCell> {
133	cell(state).map(|cell| (cell.kind.as_ref().into(), cell.state_key.as_ref().into()))
134}
135
136fn occupies_member_cell(state: &RawStrippedState, user_id: &UserId) -> bool {
137	cell(state).is_some_and(|cell| {
138		StateEventType::from(cell.kind.as_ref()) == StateEventType::RoomMember
139			&& cell.state_key == user_id.as_str()
140	})
141}
142
143/// The cell an entry names, borrowed out of the entry's own JSON.
144///
145/// Borrowing keeps a sender-supplied array off the heap for the comparisons
146/// that only need to read a cell. [`state_cell`] pays for an owned copy, which
147/// only the dedup map needs.
148fn cell(state: &RawStrippedState) -> Option<Cell<'_>> {
149	serde_json::from_str(entry_json(state)).ok()
150}
151
152#[expect(
153	deprecated,
154	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
155	          compatibility."
156)]
157fn entry_json(state: &RawStrippedState) -> &str {
158	match state {
159		| RawStrippedState::Stripped(raw) => raw.json().get(),
160		| RawStrippedState::Pdu(raw) => raw.get(),
161	}
162}
163
164#[expect(
165	deprecated,
166	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
167	          compatibility."
168)]
169fn is_legacy(state: &RawStrippedState) -> bool { matches!(state, RawStrippedState::Stripped(_)) }
170
171/// Down-convert a federation stripped-state entry to the 4-field client shape,
172/// reducing a full PDU to content, sender, optional state_key, and type.
173#[expect(
174	deprecated,
175	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
176	          compatibility."
177)]
178#[must_use]
179pub fn into_client_stripped(
180	room_id: &RoomId,
181	state: RawStrippedState,
182) -> Option<Raw<AnyStrippedStateEvent>> {
183	match state {
184		| RawStrippedState::Stripped(raw) => Some(raw),
185		| RawStrippedState::Pdu(raw) => {
186			let mut event: JsonObject = serde_json::from_str(raw.get()).ok()?;
187
188			// PduEvent requires event_id and room_id; a v12 create PDU federates
189			// with neither, and to_format() drops both from the stripped shape.
190			event.insert("event_id".into(), "$placeholder".into());
191			event
192				.entry("room_id")
193				.or_insert_with(|| room_id.as_str().into());
194
195			let pdu: PduEvent = serde_json::from_value(event.into()).ok()?;
196
197			Some(pdu.to_format())
198		},
199	}
200}
201
202/// Validate the `m.room.create` event in a federated invite's or knock's
203/// stripped state against the stated room (MSC4311). Decision-free: callers map
204/// the verdict to their own reject-or-warn policy.
205#[implement(Service)]
206#[expect(
207	deprecated,
208	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
209	          compatibility."
210)]
211#[tracing::instrument(level = "debug", skip_all, fields(%room_id))]
212pub async fn validate_stripped_create(
213	&self,
214	state: &[RawStrippedState],
215	room_id: &RoomId,
216	room_version_id: &RoomVersionId,
217) -> Result<StrippedCreateVerdict> {
218	let create = state.iter().find_map(|event| match event {
219		| RawStrippedState::Pdu(raw) => serde_json::from_str::<CanonicalJsonObject>(raw.get())
220			.ok()
221			.filter(is_create),
222		| RawStrippedState::Stripped(_) => None,
223	});
224
225	let Some(mut create) = create else {
226		let stripped = state.iter().any(|event| match event {
227			| RawStrippedState::Stripped(raw) =>
228				serde_json::from_str::<CanonicalJsonObject>(raw.json().get())
229					.is_ok_and(|json| is_create(&json)),
230			| RawStrippedState::Pdu(_) => false,
231		});
232
233		return Ok(match stripped {
234			| true => StrippedCreateVerdict::NotPdu,
235			| false => StrippedCreateVerdict::Missing,
236		});
237	};
238
239	create.remove("unsigned");
240
241	// Room-id binding: v12+ rooms hash the create event (MSC4291); earlier
242	// versions compare the create event's room_id field.
243	let bound = if v12_room_ids(room_version_id) {
244		gen_event_id(&create, room_version_id)
245			.ok()
246			.and_then(|event_id| OwnedRoomId::from_parts('!', event_id.localpart(), None).ok())
247			.is_some_and(|expected| expected == room_id)
248	} else {
249		create
250			.get("room_id")
251			.and_then(CanonicalJsonValue::as_str)
252			.is_some_and(|id| id == room_id.as_str())
253	};
254
255	if !bound {
256		return Ok(StrippedCreateVerdict::WrongRoom);
257	}
258
259	if self
260		.services
261		.server_keys
262		.verify_event(&create, Some(room_version_id))
263		.await
264		.is_err()
265	{
266		return Ok(StrippedCreateVerdict::BadSignature);
267	}
268
269	Ok(StrippedCreateVerdict::Valid)
270}
271
272fn is_create(json: &CanonicalJsonObject) -> bool {
273	let field = |key| json.get(key).and_then(CanonicalJsonValue::as_str);
274
275	field("type") == Some("m.room.create") && field("state_key") == Some("")
276}
277
278#[cfg(test)]
279#[expect(
280	deprecated,
281	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
282	          compatibility."
283)]
284mod tests {
285	use ruma::{
286		api::federation::membership::RawStrippedState, events::StateEventType, serde::Raw,
287		user_id,
288	};
289	use serde_json::{Value as JsonValue, json, value::RawValue as RawJsonValue};
290
291	use super::{
292		dedup_stripped_state, entry_json, is_legacy, occupies_member_cell, state_cell,
293		without_member,
294	};
295
296	#[test]
297	fn a_cells_first_pdu_wins_over_an_earlier_legacy_entry() {
298		let deduped = dedup_stripped_state(vec![
299			legacy(&create("@forged:example.org")),
300			pdu(&create("@genuine:example.org")),
301		]);
302
303		assert_eq!(senders(&deduped), ["@genuine:example.org"]);
304	}
305
306	#[test]
307	fn a_cell_holding_no_pdu_keeps_its_first_legacy_entry() {
308		let deduped = dedup_stripped_state(vec![
309			legacy(&create("@first:example.org")),
310			legacy(&create("@second:example.org")),
311		]);
312
313		assert_eq!(senders(&deduped), ["@first:example.org"]);
314	}
315
316	#[test]
317	fn distinct_cells_all_survive_in_order() {
318		let deduped = dedup_stripped_state(vec![
319			pdu(&create("@creator:example.org")),
320			pdu(&member("@alice:example.org", "@alice:example.org")),
321			pdu(&member("@bob:example.org", "@alice:example.org")),
322		]);
323
324		assert_eq!(deduped.len(), 3);
325		assert_eq!(state_cell(&deduped[0]).expect("a cell").0, StateEventType::RoomCreate);
326	}
327
328	#[test]
329	fn an_entry_without_a_readable_cell_drops() {
330		let deduped = dedup_stripped_state(vec![
331			pdu(&json!({"sender": "@alice:example.org", "content": {}})),
332			pdu(&create("@creator:example.org")),
333		]);
334
335		assert_eq!(senders(&deduped), ["@creator:example.org"]);
336	}
337
338	#[test]
339	fn the_invitees_membership_cell_never_survives() {
340		let state = vec![
341			pdu(&member("@invitee:example.org", "@forged:example.org")),
342			pdu(&member("@other:example.org", "@alice:example.org")),
343			pdu(&create("@creator:example.org")),
344		];
345
346		let kept: Vec<_> = without_member(state, user_id!("@invitee:example.org")).collect();
347
348		assert_eq!(senders(&kept), ["@alice:example.org", "@creator:example.org"]);
349	}
350
351	#[test]
352	fn an_all_legacy_array_survives_with_every_cell_intact() {
353		let name = json!({
354			"type": "m.room.name",
355			"state_key": "",
356			"sender": "@creator:example.org",
357			"content": {"name": "a room"},
358		});
359
360		let deduped = dedup_stripped_state(vec![
361			legacy(&create("@creator:example.org")),
362			legacy(&member("@alice:example.org", "@alice:example.org")),
363			legacy(&name),
364		]);
365
366		assert_eq!(deduped.len(), 3);
367		assert!(deduped.iter().all(is_legacy));
368		assert_eq!(senders(&deduped), [
369			"@creator:example.org",
370			"@alice:example.org",
371			"@creator:example.org"
372		]);
373	}
374
375	#[test]
376	fn a_cell_spelled_with_escapes_still_reads() {
377		// "m.room.member" with an escaped 'm', which a borrowing deserializer
378		// cannot read out of the source buffer.
379		let escaped = raw_pdu(
380			r#"{"type":"m.room.\u006dember","state_key":"@invitee:example.org",
381			   "sender":"@forged:example.org","content":{"membership":"invite"}}"#,
382		);
383
384		assert!(occupies_member_cell(&escaped, user_id!("@invitee:example.org")));
385
386		let mut kept = without_member(vec![escaped], user_id!("@invitee:example.org"));
387
388		assert!(kept.next().is_none());
389	}
390
391	fn legacy(event: &JsonValue) -> RawStrippedState {
392		RawStrippedState::Stripped(
393			Raw::new(event)
394				.expect("valid json")
395				.cast_unchecked(),
396		)
397	}
398
399	fn raw_pdu(json: &str) -> RawStrippedState {
400		RawStrippedState::Pdu(RawJsonValue::from_string(json.to_owned()).expect("valid json"))
401	}
402
403	fn pdu(event: &JsonValue) -> RawStrippedState {
404		RawStrippedState::Pdu(
405			Raw::<JsonValue>::new(event)
406				.expect("valid json")
407				.into_json(),
408		)
409	}
410
411	fn create(sender: &str) -> JsonValue {
412		json!({"type": "m.room.create", "state_key": "", "sender": sender, "content": {}})
413	}
414
415	fn member(user_id: &str, sender: &str) -> JsonValue {
416		json!({
417			"type": "m.room.member",
418			"state_key": user_id,
419			"sender": sender,
420			"content": {"membership": "invite"},
421		})
422	}
423
424	fn senders(state: &[RawStrippedState]) -> Vec<String> {
425		state
426			.iter()
427			.map(|entry| {
428				let value: JsonValue =
429					serde_json::from_str(entry_json(entry)).expect("valid json");
430
431				value["sender"]
432					.as_str()
433					.expect("a sender")
434					.to_owned()
435			})
436			.collect()
437	}
438}