use std::collections::HashSet;
use rusqlite::Connection;
use crate::error::MatrixError;
use crate::store::{HistoryVisibility, JoinRule, Membership, PowerAction, Room, RoomKind};
#[derive(serde::Deserialize, Default)]
pub struct CreateRoomRequest {
#[serde(default)]
pub visibility: Option<String>,
#[serde(default)]
pub is_direct: bool,
#[serde(default)]
pub invite: Vec<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub topic: Option<String>,
#[serde(default)]
pub power_level_content_override: Option<serde_json::Value>,
#[serde(default)]
pub room_alias_name: Option<String>,
#[serde(default)]
pub creation_content: Option<serde_json::Value>,
}
#[derive(serde::Deserialize)]
pub struct UserIdBody {
pub user_id: String,
}
#[derive(serde::Deserialize)]
pub struct KickBanBody {
pub user_id: String,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct MembersQuery {
pub membership: Option<String>,
pub at: Option<i64>,
}
pub fn derive_room_kind(is_direct: bool, invite_count: usize, visibility_public: bool) -> Result<RoomKind, MatrixError> {
if is_direct {
return if invite_count == 1 {
Ok(RoomKind::Dm)
} else {
Err(MatrixError::invalid_param("is_direct requires exactly one invite target"))
};
}
if visibility_public {
return Ok(RoomKind::Channel);
}
Ok(RoomKind::Group)
}
pub fn with_displayname(mut content: serde_json::Value, displayname: &str) -> serde_json::Value {
if !displayname.is_empty() {
content["displayname"] = serde_json::Value::String(displayname.to_string());
}
content
}
pub fn join_member_content(displayname: &str) -> serde_json::Value {
with_displayname(serde_json::json!({ "membership": "join" }), displayname)
}
pub fn invite_member_content(kind: RoomKind, displayname: &str) -> serde_json::Value {
let mut content = with_displayname(serde_json::json!({ "membership": "invite" }), displayname);
if kind == RoomKind::Dm {
content["is_direct"] = serde_json::Value::Bool(true);
}
content
}
pub fn stamp_own_member_displayname(content: &str, displayname: &str) -> Result<String, MatrixError> {
let mut value: serde_json::Value = serde_json::from_str(content)?;
let Some(fields) = value.as_object_mut() else {
return Err(MatrixError::invalid_param("m.room.member content must be an object"));
};
if displayname.is_empty() {
fields.remove("displayname");
} else {
fields.insert("displayname".to_string(), serde_json::Value::String(displayname.to_string()));
}
Ok(value.to_string())
}
pub fn bootstrap_member_events(kind: RoomKind, creator: (i64, &str, &str), invitees: &[(String, String)]) -> Vec<crate::store::NewStateEvent> {
let (creator_user_id, creator_mxid, creator_label) = creator;
let mut events = vec![state_event("m.room.member", creator_mxid, creator_user_id, join_member_content(creator_label))];
for (mxid, label) in invitees {
events.push(state_event("m.room.member", mxid, creator_user_id, invite_member_content(kind, label)));
}
events
}
pub fn room_kind_settings(kind: RoomKind) -> (JoinRule, HistoryVisibility, bool) {
match kind {
RoomKind::Dm => (JoinRule::Invite, HistoryVisibility::Shared, true),
RoomKind::Group => (JoinRule::Invite, HistoryVisibility::Shared, true),
RoomKind::Channel => (JoinRule::Public, HistoryVisibility::WorldReadable, false),
}
}
pub fn validate_power_level_override(kind: RoomKind, override_value: &Option<serde_json::Value>) -> Result<Option<i64>, MatrixError> {
let Some(value) = override_value else { return Ok(None) };
let obj = value.as_object().ok_or_else(|| MatrixError::invalid_param("power_level_content_override must be an object"))?;
if obj.keys().any(|k| k != "invite") {
return Err(MatrixError::invalid_param("power_level_content_override only accepts the 'invite' key"));
}
let Some(invite_value) = obj.get("invite") else { return Ok(None) };
if kind != RoomKind::Group {
return Err(MatrixError::invalid_param("power_level_content_override is only accepted for group rooms"));
}
match invite_value.as_i64() {
Some(0) => Ok(Some(0)),
Some(50) => Ok(Some(50)),
_ => Err(MatrixError::invalid_param("power_level_content_override.invite must be 0 or 50")),
}
}
pub fn invite_power_level(kind: RoomKind, override_value: Option<i64>) -> i64 {
match kind {
RoomKind::Dm | RoomKind::Channel => 50,
RoomKind::Group => override_value.unwrap_or(50),
}
}
pub fn power_levels_content(creator_mxid: &str, kind: RoomKind, invite_level: i64) -> serde_json::Value {
let events_default = if kind == RoomKind::Channel { 50 } else { 0 };
serde_json::json!({
"users": { creator_mxid: 100 },
"users_default": 0,
"events_default": events_default,
"state_default": 50,
"ban": 50,
"kick": 50,
"redact": 50,
"invite": invite_level,
})
}
pub fn check_encryption_state_change(event_type: &str, join_rule: JoinRule, already_encrypted: bool) -> Result<(), MatrixError> {
let _ = join_rule;
if event_type != "m.room.encryption" {
return Ok(());
}
if already_encrypted {
return Err(MatrixError::forbidden("encryption cannot be changed once set"));
}
Ok(())
}
pub fn check_state_event_type_allowed(event_type: &str, state_key: &str, caller_mxid: &str, content_str: &str) -> Result<(), MatrixError> {
if event_type == "m.room.create" {
return Err(MatrixError::forbidden("m.room.create cannot be modified"));
}
if event_type == "m.space.child" || event_type == "m.space.parent" {
let value: serde_json::Value = serde_json::from_str(content_str)?;
crate::spaces::validate_space_state(event_type, &value)?;
}
if event_type == "m.room.member" {
let membership: Option<String> = serde_json::from_str::<serde_json::Value>(content_str)
.ok()
.and_then(|v| v.get("membership").and_then(|m| m.as_str()).map(str::to_string));
let is_own_join_update = state_key == caller_mxid && membership.as_deref() == Some("join");
if !is_own_join_update {
return Err(MatrixError::forbidden(
"m.room.member only changes via join/leave/invite/kick/ban, except your own profile update while still joined",
));
}
}
Ok(())
}
pub fn dm_pair_key(a: i64, b: i64) -> String {
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
format!("{lo}:{hi}")
}
pub fn state_event(event_type: &str, state_key: &str, sender_user_id: i64, content: serde_json::Value) -> crate::store::NewStateEvent {
crate::store::NewStateEvent {
event_id: crate::store::new_event_id(),
sender_user_id,
event_type: event_type.to_string(),
state_key: state_key.to_string(),
content: content.to_string(),
}
}
pub fn power_levels_of(conn: &Connection, room_id: &str) -> Result<serde_json::Value, MatrixError> {
match crate::store::current_state_event(conn, room_id, "m.room.power_levels", "")? {
Some(event) => Ok(serde_json::from_str(&event.content)?),
None => Ok(serde_json::json!({})),
}
}
pub fn require_member(membership: Option<Membership>) -> Result<(), MatrixError> {
match membership {
Some(Membership::Join) => Ok(()),
_ => Err(MatrixError::forbidden("not a member of this room")),
}
}
pub fn require_power(power_levels: &serde_json::Value, mxid: &str, action: PowerAction) -> Result<(), MatrixError> {
if crate::store::can(power_levels, action, mxid) {
Ok(())
} else {
Err(MatrixError::forbidden("insufficient power level for this action"))
}
}
pub fn require_power_over_target(power_levels: &serde_json::Value, caller_mxid: &str, target_mxid: &str, action: PowerAction, self_leave: bool) -> Result<(), MatrixError> {
if crate::store::can_act_on(power_levels, action, caller_mxid, target_mxid, self_leave) {
Ok(())
} else {
Err(MatrixError::forbidden("insufficient power level for this action"))
}
}
pub fn require_pub_read(conn: &Connection, room: &Room, user_id: i64) -> Result<(), MatrixError> {
let membership = crate::store::room_member(conn, &room.id, user_id)?.map(|m| m.membership);
if matches!(membership, Some(Membership::Join)) {
return Ok(());
}
if room.join_rule == JoinRule::Public && room.history_visibility == HistoryVisibility::WorldReadable {
return Ok(());
}
Err(MatrixError::forbidden("no read access to this room"))
}
pub fn member_and_invited_ids(conn: &Connection, room_id: &str) -> rusqlite::Result<HashSet<i64>> {
let mut ids = HashSet::new();
for member in crate::store::room_members(conn, room_id, Some(Membership::Join))? {
ids.insert(member.user_id);
}
for member in crate::store::room_members(conn, room_id, Some(Membership::Invite))? {
ids.insert(member.user_id);
}
Ok(ids)
}
pub fn find_reusable_dm_room(conn: &Connection, pair_key: &str, user_a: i64, user_b: i64) -> rusqlite::Result<Option<String>> {
let Some(existing) = crate::store::room_by_dm_pair_key(conn, pair_key)? else {
return Ok(None);
};
let members = crate::store::room_members(conn, &existing.id, None)?;
let alive = |uid: i64| members.iter().any(|m| m.user_id == uid && matches!(m.membership, Membership::Join | Membership::Invite));
if alive(user_a) && alive(user_b) {
return Ok(Some(existing.id));
}
crate::store::clear_dm_pair_key(conn, &existing.id)?;
Ok(None)
}
#[derive(Debug, Clone, Copy)]
pub struct InviteTarget<'a> {
pub user_id: i64,
pub displayname: &'a str,
}
pub fn apply_invite(
conn: &mut Connection,
room_id: &str,
caller_user_id: i64,
caller_mxid: &str,
target: InviteTarget<'_>,
now: &str,
origin_ts: i64,
) -> Result<HashSet<i64>, MatrixError> {
let target_user_id = target.user_id;
let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
require_member(caller_membership)?;
let power_levels = power_levels_of(conn, room_id)?;
require_power(&power_levels, caller_mxid, PowerAction::Invite)?;
let target_membership = crate::store::room_member(conn, room_id, target_user_id)?.map(|m| m.membership);
match target_membership {
Some(Membership::Join) | Some(Membership::Invite) => return Err(MatrixError::invalid_param("user is already invited or joined")),
Some(Membership::Ban) => return Err(MatrixError::forbidden("user is banned from this room")),
Some(Membership::Leave) | None => {}
}
let target_mxid = crate::store::mxid_of(conn, target_user_id)?.ok_or_else(MatrixError::internal)?;
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &crate::store::new_event_id(),
room_id,
sender_user_id: caller_user_id,
event_type: "m.room.member",
state_key: &target_mxid,
content: &invite_member_content(room.kind, target.displayname).to_string(),
origin_server_ts: origin_ts,
now,
},
)?;
let mut ids = member_and_invited_ids(conn, room_id)?;
ids.insert(target_user_id);
Ok(ids)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetStateRule {
MustBeActiveMember,
Any,
MustBeBanned,
}
pub fn check_target_state_rule(rule: TargetStateRule, membership: Option<Membership>) -> Result<(), MatrixError> {
match rule {
TargetStateRule::MustBeActiveMember => {
if matches!(membership, Some(Membership::Join) | Some(Membership::Invite)) {
Ok(())
} else {
Err(MatrixError::not_found("user is not a member of this room"))
}
}
TargetStateRule::MustBeBanned => {
if membership == Some(Membership::Ban) {
Ok(())
} else {
Err(MatrixError::invalid_param("user is not banned"))
}
}
TargetStateRule::Any => Ok(()),
}
}
pub struct MembershipPowerAction<'a> {
pub room_id: &'a str,
pub action: PowerAction,
pub target_user_id: i64,
pub target_rule: TargetStateRule,
pub new_membership: Membership,
pub reason: Option<&'a str>,
}
pub fn apply_membership_power_action(
conn: &mut Connection,
caller_user_id: i64,
caller_mxid: &str,
action: MembershipPowerAction<'_>,
now: &str,
origin_ts: i64,
) -> Result<HashSet<i64>, MatrixError> {
let room_id = action.room_id;
let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
require_member(caller_membership)?;
let power_levels = power_levels_of(conn, room_id)?;
let target_mxid = crate::store::mxid_of(conn, action.target_user_id)?.ok_or_else(MatrixError::internal)?;
let self_leave = action.new_membership == Membership::Leave;
require_power_over_target(&power_levels, caller_mxid, &target_mxid, action.action, self_leave)?;
let target_membership = crate::store::room_member(conn, room_id, action.target_user_id)?.map(|m| m.membership);
check_target_state_rule(action.target_rule, target_membership)?;
let mut content = serde_json::json!({ "membership": action.new_membership.as_str() });
if let Some(reason) = action.reason {
content["reason"] = serde_json::Value::String(reason.to_string());
}
crate::store::apply_state_event(conn, &crate::store::StateEventWrite { event_id: &crate::store::new_event_id(), room_id, sender_user_id: caller_user_id, event_type: "m.room.member", state_key: &target_mxid, content: &content.to_string(), origin_server_ts: origin_ts, now })?;
let mut ids = member_and_invited_ids(conn, room_id)?;
ids.insert(action.target_user_id);
Ok(ids)
}
pub fn apply_join(
conn: &mut Connection,
room_id: &str,
caller_user_id: i64,
caller_mxid: &str,
displayname: &str,
now: &str,
origin_ts: i64,
) -> Result<HashSet<i64>, MatrixError> {
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &crate::store::new_event_id(),
room_id,
sender_user_id: caller_user_id,
event_type: "m.room.member",
state_key: caller_mxid,
content: &join_member_content(displayname).to_string(),
origin_server_ts: origin_ts,
now,
},
)?;
Ok(member_and_invited_ids(conn, room_id)?)
}
pub fn apply_leave(conn: &mut Connection, room_id: &str, caller_user_id: i64, caller_mxid: &str, now: &str, origin_ts: i64) -> Result<HashSet<i64>, MatrixError> {
let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
if !matches!(caller_membership, Some(Membership::Join) | Some(Membership::Invite)) {
return Err(MatrixError::forbidden("not a member or invitee of this room"));
}
let wake_ids = member_and_invited_ids(conn, room_id)?;
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &crate::store::new_event_id(),
room_id,
sender_user_id: caller_user_id,
event_type: "m.room.member",
state_key: caller_mxid,
content: &serde_json::json!({ "membership": "leave" }).to_string(),
origin_server_ts: origin_ts,
now,
},
)?;
Ok(wake_ids)
}
pub struct RoomInvitee<'a> {
pub user_id: i64,
pub displayname: &'a str,
}
pub struct RoomCreate<'a> {
pub creator_user_id: i64,
pub creator_mxid: &'a str,
pub creator_displayname: &'a str,
pub is_direct: bool,
pub invitees: &'a [RoomInvitee<'a>],
pub visibility_public: bool,
pub power_level_content_override: Option<serde_json::Value>,
pub name: Option<&'a str>,
pub topic: Option<&'a str>,
pub room_type: Option<&'a str>,
}
pub enum RoomCreation {
Reused(String),
Created { room_id: String, notify_user_ids: HashSet<i64> },
}
pub fn apply_create_room(
conn: &mut Connection,
req: RoomCreate<'_>,
now: &str,
origin_ts: i64,
) -> Result<RoomCreation, MatrixError> {
if req.invitees.iter().any(|invitee| invitee.user_id == req.creator_user_id) {
return Err(MatrixError::invalid_param("cannot invite yourself"));
}
let kind = derive_room_kind(req.is_direct, req.invitees.len(), req.visibility_public)?;
let invite_override = validate_power_level_override(kind, &req.power_level_content_override)?;
if kind == RoomKind::Dm {
let peer_id = req.invitees[0].user_id;
let pair_key = dm_pair_key(req.creator_user_id, peer_id);
if let Some(existing) = find_reusable_dm_room(conn, &pair_key, req.creator_user_id, peer_id)? {
return Ok(RoomCreation::Reused(existing));
}
}
let room_id = crate::store::new_room_id();
let (join_rule, history_visibility, is_encrypted) = room_kind_settings(kind);
let invite_level = invite_power_level(kind, invite_override);
let mut invitees = Vec::with_capacity(req.invitees.len());
for invitee in req.invitees {
let mxid = crate::store::mxid_of(conn, invitee.user_id)?.ok_or_else(MatrixError::internal)?;
invitees.push((mxid, invitee.displayname.to_string()));
}
let mut state_events = vec![state_event(
"m.room.create",
"",
req.creator_user_id,
match req.room_type {
Some(t) => serde_json::json!({ "room_version": crate::store::MATRIX_ROOM_VERSION, "type": t }),
None => serde_json::json!({ "room_version": crate::store::MATRIX_ROOM_VERSION }),
},
)];
state_events.extend(bootstrap_member_events(
kind,
(req.creator_user_id, req.creator_mxid, req.creator_displayname),
&invitees,
));
state_events.push(state_event(
"m.room.power_levels",
"",
req.creator_user_id,
power_levels_content(req.creator_mxid, kind, invite_level),
));
state_events.push(state_event(
"m.room.join_rules",
"",
req.creator_user_id,
serde_json::json!({ "join_rule": join_rule.as_str() }),
));
state_events.push(state_event(
"m.room.history_visibility",
"",
req.creator_user_id,
serde_json::json!({ "history_visibility": history_visibility.as_str() }),
));
if let Some(name) = req.name {
state_events.push(state_event(
"m.room.name",
"",
req.creator_user_id,
serde_json::json!({ "name": name }),
));
}
if let Some(topic) = req.topic {
state_events.push(state_event(
"m.room.topic",
"",
req.creator_user_id,
serde_json::json!({ "topic": topic }),
));
}
if is_encrypted {
state_events.push(state_event(
"m.room.encryption",
"",
req.creator_user_id,
serde_json::json!({ "algorithm": "m.megolm.v1.aes-sha2" }),
));
}
let dm_pair = (kind == RoomKind::Dm).then(|| dm_pair_key(req.creator_user_id, req.invitees[0].user_id));
let bootstrap = crate::store::RoomBootstrap {
room_id: &room_id,
kind,
creator_user_id: req.creator_user_id,
created_at: now,
is_encrypted,
join_rule,
history_visibility,
dm_pair_key: dm_pair.as_deref(),
legacy_dm_id: None,
};
crate::store::create_room_with_state(conn, bootstrap, &state_events, origin_ts)?;
let mut notify_user_ids: HashSet<i64> = req.invitees.iter().map(|invitee| invitee.user_id).collect();
notify_user_ids.insert(req.creator_user_id);
Ok(RoomCreation::Created { room_id, notify_user_ids })
}
pub enum JoinDecision {
AlreadyJoined,
Joined(HashSet<i64>),
}
pub fn decide_and_apply_join(
conn: &mut Connection,
room_id: &str,
caller_user_id: i64,
caller_mxid: &str,
displayname: &str,
now: &str,
origin_ts: i64,
) -> Result<JoinDecision, MatrixError> {
let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
let current = crate::store::room_member(conn, room_id, caller_user_id)?.map(|member| member.membership);
match current {
Some(Membership::Join) => return Ok(JoinDecision::AlreadyJoined),
Some(Membership::Ban) => return Err(MatrixError::forbidden("banned from this room")),
Some(Membership::Invite) => {}
Some(Membership::Leave) | None => {
if room.join_rule != JoinRule::Public && !crate::spaces::restricted_allows(conn, room_id, caller_user_id)? {
return Err(MatrixError::forbidden("no invitation to this room"));
}
}
}
let ids = apply_join(conn, room_id, caller_user_id, caller_mxid, displayname, now, origin_ts)?;
Ok(JoinDecision::Joined(ids))
}
pub fn apply_put_state(
conn: &mut Connection,
room_id: &str,
sender_user_id: i64,
sender_mxid: &str,
event_type: &str,
state_key: &str,
content: &str,
now: &str,
origin_ts: i64,
) -> Result<(String, HashSet<i64>), MatrixError> {
if content.len() > crate::store::MATRIX_EVENT_CONTENT_MAX_BYTES {
return Err(MatrixError::invalid_param("event content too large"));
}
let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
let caller_membership = crate::store::room_member(conn, room_id, sender_user_id)?.map(|member| member.membership);
require_member(caller_membership)?;
check_state_event_type_allowed(event_type, state_key, sender_mxid, content)?;
let already_encrypted = crate::store::current_state_event(conn, room_id, "m.room.encryption", "")?.is_some();
check_encryption_state_change(event_type, room.join_rule, already_encrypted)?;
let power_levels = power_levels_of(conn, room_id)?;
if crate::store::user_level(&power_levels, sender_mxid) < crate::store::event_level(&power_levels, event_type, true) {
return Err(MatrixError::forbidden("insufficient power level to set this state event"));
}
if event_type == "m.room.power_levels" {
let new_power_levels: serde_json::Value = serde_json::from_str(content)?;
crate::store::validate_power_levels_change(&power_levels, &new_power_levels, sender_mxid).map_err(MatrixError::forbidden)?;
}
let event_id = crate::store::new_event_id();
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &event_id,
room_id,
sender_user_id,
event_type,
state_key,
content,
origin_server_ts: origin_ts,
now,
},
)?;
let notify = member_and_invited_ids(conn, room_id)?;
Ok((event_id, notify))
}
pub fn apply_forget(conn: &Connection, room_id: &str, user_id: i64) -> Result<(), MatrixError> {
let deleted = crate::store::forget_membership(conn, room_id, user_id)?;
if deleted == 0 {
return Err(MatrixError::forbidden("must have left the room before forgetting it"));
}
Ok(())
}
pub fn migrate_plaintext_rooms_to_encrypted(
conn: &mut Connection,
now: &str,
origin_ts: i64,
) -> Result<usize, MatrixError> {
let plaintext: Vec<(String, i64, String)> = {
let mut stmt = conn
.prepare("SELECT id, creator_user_id, history_visibility FROM rooms WHERE is_encrypted = 0 AND kind != 'channel'")
.map_err(|e| MatrixError::unknown(e.to_string()))?;
let rows = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.map_err(|e| MatrixError::unknown(e.to_string()))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| MatrixError::unknown(e.to_string()))?
};
let mut changed = 0usize;
for (room_id, creator_user_id, hv) in plaintext {
let already = crate::store::current_state_event(conn, &room_id, "m.room.encryption", "")?.is_some();
if !already {
let event_id = crate::store::new_event_id();
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &event_id,
room_id: &room_id,
sender_user_id: creator_user_id,
event_type: "m.room.encryption",
state_key: "",
content: r#"{"algorithm":"m.megolm.v1.aes-sha2"}"#,
origin_server_ts: origin_ts,
now,
},
)?;
}
if hv == HistoryVisibility::WorldReadable.as_str() {
let event_id = crate::store::new_event_id();
crate::store::apply_state_event(
conn,
&crate::store::StateEventWrite {
event_id: &event_id,
room_id: &room_id,
sender_user_id: creator_user_id,
event_type: "m.room.history_visibility",
state_key: "",
content: r#"{"history_visibility":"shared"}"#,
origin_server_ts: origin_ts,
now,
},
)?;
conn.execute(
"UPDATE rooms SET history_visibility = ?1 WHERE id = ?2",
rusqlite::params![HistoryVisibility::Shared.as_str(), room_id],
)
.map_err(|e| MatrixError::unknown(e.to_string()))?;
}
conn.execute(
"UPDATE rooms SET is_encrypted = 1 WHERE id = ?1",
rusqlite::params![room_id],
)
.map_err(|e| MatrixError::unknown(e.to_string()))?;
changed += 1;
}
Ok(changed)
}
pub fn drop_legacy_dm_scaffold_if_empty(conn: &Connection) -> Result<bool, MatrixError> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_dm_message_map'",
[],
|row| row.get(0),
)
.map_err(|e| MatrixError::unknown(e.to_string()))?;
if count == 0 {
return Ok(false);
}
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM legacy_dm_message_map", [], |row| row.get(0))
.map_err(|e| MatrixError::unknown(e.to_string()))?;
if rows > 0 {
return Err(MatrixError::forbidden("legacy_dm_message_map still has rows; refuse drop"));
}
conn.execute_batch("DROP TABLE IF EXISTS legacy_dm_message_map;")
.map_err(|e| MatrixError::unknown(e.to_string()))?;
Ok(true)
}
#[cfg(test)]
mod messenger_model_tests {
use super::*;
use crate::store::{self, HistoryVisibility, JoinRule, RoomKind};
use rusqlite::Connection;
const T0: &str = "2026-10-06T00:00:00+00:00";
fn test_conn() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory sqlite");
store::create_matrix_schema(&conn).expect("schema");
conn
}
#[test]
fn dm_and_group_are_encrypted_channel_is_public_plaintext() {
let (jr, hv, enc) = room_kind_settings(RoomKind::Dm);
assert_eq!(jr, JoinRule::Invite);
assert_eq!(hv, HistoryVisibility::Shared);
assert!(enc);
let (jr, hv, enc) = room_kind_settings(RoomKind::Group);
assert_eq!(jr, JoinRule::Invite);
assert_eq!(hv, HistoryVisibility::Shared);
assert!(enc);
let (jr, hv, enc) = room_kind_settings(RoomKind::Channel);
assert_eq!(jr, JoinRule::Public);
assert_eq!(hv, HistoryVisibility::WorldReadable);
assert!(!enc, "public channels are plaintext in the public store");
}
#[test]
fn encryption_may_be_enabled_on_a_public_room_but_never_changed() {
check_encryption_state_change("m.room.encryption", JoinRule::Public, false).expect("enable ok");
let err = check_encryption_state_change("m.room.encryption", JoinRule::Public, true).unwrap_err();
assert!(format!("{err:?}").contains("changed") || format!("{err:?}").to_lowercase().contains("forbidden"));
}
#[test]
fn public_channel_join_without_invite_private_group_requires_invite() {
let mut conn = test_conn();
store::ensure_matrix_user(&conn, 1, "alice000000000000000000000000001", T0).expect("alice");
let bob_mxid = store::ensure_matrix_user(&conn, 2, "bob00000000000000000000000000002", T0).expect("bob");
let channel = "!chan:example.org";
let (jr, hv, enc) = room_kind_settings(RoomKind::Channel);
store::create_room(&conn, channel, RoomKind::Channel, 1, T0, enc, jr, hv, None, None).expect("channel");
match decide_and_apply_join(&mut conn, channel, 2, &bob_mxid, "bob", T0, 1_000).expect("join") {
JoinDecision::Joined(_) => {}
JoinDecision::AlreadyJoined => panic!("expected fresh join"),
}
let group = "!grp:example.org";
let (jr, hv, enc) = room_kind_settings(RoomKind::Group);
store::create_room(&conn, group, RoomKind::Group, 1, T0, enc, jr, hv, None, None).expect("group");
match decide_and_apply_join(&mut conn, group, 2, &bob_mxid, "bob", T0, 2_000) {
Err(err) => {
let msg = format!("{err:?}").to_lowercase();
assert!(msg.contains("invitation") || msg.contains("forbidden"), "{msg}");
}
Ok(_) => panic!("stranger must not join a private group without invite"),
}
}
#[test]
fn migration_encrypts_plaintext_group_but_leaves_public_channel_alone() {
let mut conn = test_conn();
store::ensure_matrix_user(&conn, 1, "alice000000000000000000000000001", T0).expect("alice");
let channel = "!oldchan:example.org";
store::create_room(&conn, channel, RoomKind::Channel, 1, T0, false, JoinRule::Public, HistoryVisibility::WorldReadable, None, None).expect("channel");
let group = "!oldgrp:example.org";
store::create_room(&conn, group, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None).expect("group");
let n = migrate_plaintext_rooms_to_encrypted(&mut conn, T0, 1_000).expect("migrate");
assert_eq!(n, 1, "only the group is rewritten");
let room = store::get_room(&conn, group).expect("get").expect("exists");
assert!(room.is_encrypted);
let chan = store::get_room(&conn, channel).expect("get").expect("exists");
assert!(!chan.is_encrypted, "public channel stays plaintext");
assert_eq!(chan.history_visibility, HistoryVisibility::WorldReadable);
assert!(store::current_state_event(&conn, channel, "m.room.encryption", "").expect("state").is_none());
let n2 = migrate_plaintext_rooms_to_encrypted(&mut conn, T0, 2_000).expect("idempotent");
assert_eq!(n2, 0);
}
#[test]
fn drop_legacy_dm_scaffold_when_empty() {
let conn = test_conn();
conn.execute_batch(
"CREATE TABLE legacy_dm_message_map (
legacy_message_id INTEGER PRIMARY KEY,
event_id TEXT NOT NULL
);",
)
.expect("old table");
assert!(drop_legacy_dm_scaffold_if_empty(&conn).expect("drop"));
assert!(!drop_legacy_dm_scaffold_if_empty(&conn).expect("already gone"));
}
}