- MUC сохраняет согласованный журнал конфигурации, affiliations, nick,

ролей,
  членства и ограниченной истории без MAM. Изменение фиксируется до
  публикации
  ответа; при ошибке записи состояние комнаты откатывается.
- Crash recovery использует последнее сохранённое поколение после
  SIGKILL,
  включая временные комнаты. Heartbeat обновляет живое членство;
  ожидающие записи сохраняют исходный deadline через повторные
  перезапуски.
  `muc_recovery_grace_period`: по умолчанию 300 секунд, пределы
  1..604800.
- Подтверждённые выходы, смена nick, ролей и политик комнаты не
  заменяются
  старой записью восстановления. Native config/state остаются экспортом
  для
  Prosody; согласованное восстановление Rust использует собственный
  sidecar.
- Исправлены MUC probe, неизвестные типы presence, вход без MUC `<x/>`,
  ошибки
  доставки и статус 333. Обычные presence extensions сохраняются в живом
  обмене; поддельная MUC identity фильтруется. `subject` вместе с `body`
  либо
  `thread` не изменяет тему комнаты.
- Подключены `muc_max_rooms`, `restrict_room_creation`, политики
  регистрации
  и зарегистрированного nick, `muc_tombstone_expiry`; добавлены
  встроенные
  административные ad-hoc команды уничтожения комнат и изменения
  affiliation.
- Hub сохраняет resumable XEP-0198 сессии, счётчики и очередь. Resume
  после
  перезапуска доступен на новом TLS/SASL-транспорте через legacy SASL и
  SASL2.
  Действующие приватные SM-записи восстанавливают логические сессии и
  MUC
  до открытия listeners: offline-доставка попадает в очередь,
  синтетический
  join и история повторно не отправляются. SM resume не требует
  дополнительной
  available presence; без SM восстановление MUC требует новой
  аутентификации
  точного полного JID и available presence либо явного MUC-входа.
  Входящий handled-счётчик обновляется после обработки. Сокеты TCP/TLS
  не сохраняются; неопределённая сетевая отправка допускает повторную
  доставку.
- Известные встроенные MUC-возможности Prosody отображаются на
  Rust-реализацию.
  Произвольные Lua/community-модули не исполняются; неизвестные явно
  включённые
  MUC-модули отклоняются при запуске. Общие и host `modules_disabled`
  учитываются
  для vCard и occupant-id.

Порядок восстановления, ограничения хранения и отката на Prosody:
[docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).

Проверка: 293 Rust tests, Clippy/fmt/release, полный сетевой прогон, 500
TLS
MUC+SM ресурсов с SIGKILL/resume и native reference — успешно.
Подробности: [docs/VALIDATION-0.8.0.md](docs/VALIDATION-0.8.0.md).
This commit is contained in:
wt committed 2026-10-20 17:34:43 +07:00
1 parent b30fc524d8
commit 7f60661eef
41 files changed
+9804 -452

No files matched your search

+11 -1
View File
@@ -10,7 +10,13 @@ fn send(muc: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
}
fn join(muc: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
send(muc, jid, &format!("<presence to='r@rooms.test/{nick}'/>"))
send(
muc,
jid,
&format!(
"<presence to='r@rooms.test/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
)
}
fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult {
@@ -27,6 +33,7 @@ fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult {
fn moderators_cannot_kick_or_demote_other_moderators() {
let temp = tempfile::tempdir().unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut muc, "owner@test/r", "Owner");
join(&mut muc, "bob@test/r", "Bob");
join(&mut muc, "carol@test/r", "Carol");
@@ -67,6 +74,7 @@ fn binary_occupant_id_salt_survives_saves_and_restarts() {
let mut ids = Vec::new();
for _ in 0..2 {
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
let result = join(&mut muc, "owner@test/r", "Owner");
let packet = result
.replies
@@ -99,6 +107,7 @@ fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
let raw = storage::parse(b"return {_jid='r@rooms.test'; _data={persistent=true; members_only=true}; _affiliations={['owner@test']='owner'; ['member@test']='member'}; ['member@test']='owner'}").unwrap();
storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut muc, "owner@test/r", "Owner");
let result = send(
&mut muc,
@@ -109,5 +118,6 @@ fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
drop(muc);
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
}
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""SIGKILL MUC durability over real TCP/STARTTLS (no shutdown checkpoint).
Usage: python3 tests/muc_crash_network.py target/release/rust-xmpp
All crashes happen with sockets open, immediately after the relevant wire reply.
The grace test ages only the persisted clock while the server is stopped.
"""
import json
from pathlib import Path
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
from _wire_client import Client, local
from muc_restart_network import MUC, USER, iq, occupant, presence, start, not_joined
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
def join(client, room, nick, history=0, password=None):
secret = '' if password is None else f'<password>{escape(password)}</password>'
client.send(f"<presence to='{room}/{nick}'><x xmlns='{MUC}'>{secret}"
f"<history maxstanzas='{history}'/></x><show>away</show>"
'<status>durable presence</status></presence>')
return presence(client, room, nick)
def configure(client, room, **values):
fields = "<field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#roomconfig</value></field>"
fields += ''.join(f"<field var='muc#roomconfig_{key}'><value>{escape(str(value))}</value></field>"
for key, value in values.items())
return iq(client, 'config', f"<query xmlns='{MUC}#owner'><x xmlns='jabber:x:data' type='submit'>{fields}</x></query>", room)
def role(client, room, nick, value):
return iq(client, 'role', f"<query xmlns='{MUC}#admin'><item nick='{nick}' role='{value}'/></query>", room)
def denied(packet, condition):
assert packet.get('type') == 'error', ET.tostring(packet)
assert packet.find(f'.//{{{STANZAS}}}{condition}') is not None, ET.tostring(packet)
class Server:
def __init__(self, binary, root, options=None):
self.binary, self.root = binary, root
self.clients, self.process = [], None
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj',
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
self.port = probe.getsockname()[1]
self.config = root/'config.json'
self.config.write_text(json.dumps({'global': {
'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [self.port],
's2s_ports': [], 'http_ports': [], 'https_ports': [],
'modules_enabled': ['tls', 'saslauth', 'ping', 'disco'],
}, 'hosts': {
'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}},
'rooms.localhost': {'component': 'muc', 'options': {'max_history_messages': 20, **(options or {})}},
}}))
for user in ('alice', 'bob', 'carol'):
subprocess.run([binary, 'create-user', str(self.config), user, '--password-stdin'],
input='secret\n', text=True, check=True, capture_output=True)
self.log = (root/'server.log').open('w+')
self.restart()
def client(self, user, resource='desktop', available=True):
client = Client(self.port, self.root/'cert.pem', user, 'secret', resource)
self.clients.append(client)
if available:
client.available()
return client
def restart(self):
self.process = start(self.binary, self.config, self.port, self.log)
def crash(self):
# Never close a client first: disconnect cleanup must not save the state.
self.process.kill()
assert self.process.wait(timeout=10) == -signal.SIGKILL
self.process = None
for client in self.clients:
client.close()
self.clients.clear()
def close(self):
if self.process is not None:
self.crash()
if sys.exc_info()[0] is not None:
self.log.seek(0)
print(self.log.read(), file=sys.stderr)
self.log.close()
def recovery_and_history(server):
rooms = ('persistent@rooms.localhost', 'temporary@rooms.localhost')
alice, phone, bob = server.client('alice'), server.client('alice', 'phone'), server.client('bob')
for index, room in enumerate(rooms):
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
configure(alice, room, persistentroom=int(index == 0), enablearchiving=0, historylength=20)
# Exercise both shared-nickname sessions and distinct resource nicknames.
occupant(join(phone, room, 'Alice' if index == 0 else 'Phone'), 'moderator', 'owner')
occupant(join(bob, room, 'Bob'), 'participant', 'none')
role(alice, room, 'Bob', 'moderator')
for number in range(3):
body = f'{room} precrash {number}'
alice.send(f"<message to='{room}' type='groupchat'><body>{body}</body></message>")
bob.body(body)
server.crash()
server.restart()
wrong = server.client('alice', 'tablet')
stranger = server.client('carol')
for room in rooms:
not_joined(wrong, room)
not_joined(stranger, room)
# A dormant exact-resource claim reserves its nick before activation.
denied(join(stranger, room, 'Alice'), 'conflict')
bob = server.client('bob')
for room in rooms:
occupant(presence(bob, room, 'Bob'), 'moderator', 'none')
alice = server.client('alice')
phone = server.client('alice', 'phone')
for index, room in enumerate(rooms):
packet = presence(alice, room, 'Alice')
occupant(packet, 'moderator', 'owner')
status = packet.find('{jabber:client}status')
assert status is not None and status.text == 'durable presence', ET.tostring(packet)
phone_nick = 'Alice' if index == 0 else 'Phone'
occupant(presence(phone, room, phone_nick), 'moderator', 'owner')
body = room + ' recovered route'
phone.send(f"<message to='{room}' type='groupchat'><body>{body}</body></message>")
assert bob.body(body).get('from') == room + '/' + phone_nick
assert alice.body(body).get('from') == room + '/' + phone_nick
occupant(join(stranger, room, 'History', history=20), 'participant', 'none')
for number in range(3):
packet = stranger.body(f'{room} precrash {number}')
assert packet.find('{urn:xmpp:delay}delay') is not None, ET.tostring(packet)
stranger.body(body)
def mutation_barrier(server, mutation):
room = mutation + '@rooms.localhost'
alice, bob = server.client('alice'), server.client('bob')
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
configure(alice, room, persistentroom=1)
occupant(join(bob, room, 'Bob'), 'participant', 'none')
if mutation == 'leave':
bob.send(f"<presence to='{room}/Bob' type='unavailable'/>")
packet = bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob'
and e.get('type') == 'unavailable')
assert packet.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'none', ET.tostring(packet)
elif mutation == 'kick':
role(alice, room, 'Bob', 'none')
elif mutation == 'demotion':
role(alice, room, 'Bob', 'moderator')
role(alice, room, 'Bob', 'visitor')
elif mutation == 'password':
configure(alice, room, roomsecret='new-private-password')
elif mutation == 'configuration':
configure(alice, room, membersonly=1)
else:
raise AssertionError(mutation)
# No ping, sleep, extra read or orderly close between acknowledgment and kill.
server.crash()
server.restart()
bob = server.client('bob')
if mutation in ('leave', 'kick', 'configuration'):
not_joined(bob, room)
elif mutation == 'demotion':
occupant(presence(bob, room, 'Bob'), 'visitor', 'none')
bob.send(f"<message to='{room}' type='groupchat' id='visitor'><body>forbidden</body></message>")
packet = bob.take(lambda e: local(e) == 'message' and e.get('id') == 'visitor')
assert packet.get('type') == 'error', ET.tostring(packet)
stranger = server.client('carol')
if mutation == 'password':
denied(join(stranger, room, 'Carol'), 'not-authorized')
occupant(join(stranger, room, 'Carol', password='new-private-password'), 'participant', 'none')
elif mutation == 'configuration':
denied(join(stranger, room, 'Carol'), 'registration-required')
def checkpoint_clock(server, age=None):
"""Age the committed journal clock without changing membership or config."""
files = list((server.root/'data').rglob('rust_muc_checkpoint.dat'))
found = []
pattern = re.compile(r'(\["saved_at"\]\s*=\s*)(\d+)')
for path in files:
text = path.read_text()
if pattern.search(text):
found.append(int(pattern.search(text).group(2)))
if age is not None:
path.write_text(pattern.sub(lambda m: m.group(1) + str(int(time.time()) - age), text))
assert found, 'no committed recovery checkpoint timestamp found'
return found
def restart_grace(server, grace=300):
room = 'grace@rooms.localhost'
alice = server.client('alice')
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
configure(alice, room, persistentroom=1)
server.crash()
# Make the original admission old enough to distinguish it from restart time.
checkpoint_clock(server, age=grace - 10)
original = checkpoint_clock(server)
for _ in range(2):
server.restart()
probe = server.client('carol')
not_joined(probe, room)
server.crash()
assert checkpoint_clock(server) == original, 'restart extended dormant recovery deadline'
checkpoint_clock(server, age=grace + 1)
server.restart()
alice = server.client('alice')
not_joined(alice, room)
# Expiration releases nickname reservation; persistent-room ownership remains.
carol = server.client('carol')
occupant(join(carol, room, 'Alice'), 'participant', 'none')
def unknown_component_module(server):
server.crash()
config = json.loads(server.config.read_text())
marker = 'unimplemented_muc_crash_test_module'
config['hosts']['rooms.localhost']['options']['modules_enabled'] = [marker]
server.config.write_text(json.dumps(config))
result = subprocess.run([server.binary, 'serve', str(server.config), '--experimental'],
capture_output=True, text=True, timeout=10)
assert result.returncode != 0, 'unknown MUC component module was accepted'
assert marker in result.stdout + result.stderr, result.stdout + result.stderr
def destroy_tombstone(server):
room = 'destroyed@rooms.localhost'
replacement = 'replacement@rooms.localhost'
reason = 'Closed after migration'
alice, bob = server.client('alice'), server.client('bob')
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
configure(alice, room, persistentroom=1)
occupant(join(bob, room, 'Bob'), 'participant', 'none')
iq(alice, 'destroy', f"<query xmlns='{MUC}#owner'><destroy jid='{replacement}'>"
f'<reason>{reason}</reason></destroy></query>', room)
# The IQ result is the durability barrier; no reads or disconnect precede kill.
server.crash()
journals = list((server.root/'data').rglob('rust_muc_checkpoint.dat'))
assert len(journals) == 1, journals
checkpoint = journals[0].read_text()
for field in ('["destroyed"]=true', f'["reason"]="{reason}"',
f'["newjid"]="{replacement}"'):
assert field in checkpoint, f'acknowledged tombstone omitted {field}'
server.restart()
# Neither an old exact identity nor a new account can recreate the room.
alice, carol = server.client('alice'), server.client('carol')
for client in (alice, carol):
client.sync()
assert not any(local(packet) == 'presence' and packet.get('from', '').startswith(room + '/')
for packet in client.queue), [ET.tostring(packet) for packet in client.queue]
denied(join(client, room, 'Alice'), 'gone')
def run(binary, selected=()):
cases = [('recovery-history', recovery_and_history)]
cases += [(name, lambda server, name=name: mutation_barrier(server, name))
for name in ('leave', 'kick', 'demotion', 'password', 'configuration')]
cases.append(('restart-grace', restart_grace))
cases.append(('custom-grace', lambda server: restart_grace(server, grace=30)))
cases.append(('unknown-module', unknown_component_module))
cases.append(('destroy-tombstone', destroy_tombstone))
for name, test in cases:
if selected and name not in selected:
continue
with tempfile.TemporaryDirectory(prefix='xmpp-muc-crash-' + name + '-') as tmp:
options = {'muc_recovery_grace_period': 30} if name == 'custom-grace' else None
server = Server(binary, Path(tmp), options)
try:
test(server)
finally:
server.close()
print('PASS:', name, flush=True)
print('PASS: actual STARTTLS SIGKILL MUC recovery, history, acknowledged barriers and fixed grace')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()), sys.argv[2:])
+228
View File
@@ -0,0 +1,228 @@
//! Dropping Muc without checkpoint_live models an abrupt process loss: only
//! checkpoints committed before returning the accepted stanza can survive.
use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage};
use serde_json::json;
const HOST: &str = "rooms.example.org";
const ROOM: &str = "crash@rooms.example.org";
const A: &str = "alice@example.org/desktop";
const B: &str = "bob@example.org/phone";
fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
let doc = roxmltree::Document::parse(packet).unwrap();
m.handle(jid, doc.root_element()).unwrap().unwrap()
}
fn muc(root: &std::path::Path) -> Muc {
let mut m = Muc::new(root.into(), HOST.into());
m.set_component_options(json!({"muc_room_locking":false,"max_history_messages":3,"muc_room_default_history_length":3}), false);
m
}
fn join(m: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
send(
m,
jid,
&format!(
"<presence to='{ROOM}/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
)
}
fn has_self(out: &ModuleResult) -> bool {
out.replies.iter().any(|p| p.contains("code='110'"))
}
#[test]
fn temporary_membership_and_full_bounded_history_survive_ungraceful_loss() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
assert!(has_self(&join(&mut first, A, "Alice")));
assert!(has_self(&join(&mut first, B, "Bob")));
for text in ["old-zero", "recent-one", "recent-two", "recent-three"] {
let out = send(
&mut first,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>{text}</body></message>"),
);
assert!(!out.deliveries.is_empty());
}
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(
next.restore_for_session("alice@example.org/other")
.unwrap()
.replies
.is_empty()
);
let a = next.restore_for_session(A).unwrap();
assert!(has_self(&a));
// A dormant Bob is never an available routing target before authenticating.
let out = send(
&mut next,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>after-restart</body></message>"),
);
assert!(out.deliveries.iter().all(|(jid, _)| jid != B));
let b = next.restore_for_session(B).unwrap();
let history = b.replies.join("");
assert!(has_self(&b));
assert!(
!history.contains("old-zero") && !history.contains("recent-one"),
"{history}"
);
assert!(
history.contains("recent-two")
&& history.contains("recent-three")
&& history.contains("after-restart")
);
assert!(history.contains("urn:xmpp:delay"));
drop(next);
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(has_self(&third.restore_for_session(A).unwrap()));
assert!(has_self(&third.restore_for_session(B).unwrap()));
}
#[test]
fn acknowledged_kick_and_room_destruction_cannot_resurrect_membership() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
let kicked = send(
&mut first,
A,
&format!(
"<iq to='{ROOM}' type='set' id='kick'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='Bob' role='none'/></query></iq>"
),
);
assert!(kicked.replies.iter().any(|p| p.contains("type='result'")));
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(next.restore_for_session(B).unwrap().replies.is_empty());
assert!(has_self(&next.restore_for_session(A).unwrap()));
let destroyed = send(
&mut next,
A,
&format!(
"<iq to='{ROOM}' type='set' id='destroy'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>"
),
);
assert!(
destroyed
.replies
.iter()
.any(|p| p.contains("type='result'"))
);
drop(next);
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(third.restore_for_session(A).unwrap().replies.is_empty());
assert!(third.restore_for_session(B).unwrap().replies.is_empty());
}
#[test]
fn dormant_deadline_stays_fixed_when_another_occupant_recovers_and_sends() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
drop(first);
let initial = storage::load(root.path(), HOST, None, "rust_muc_checkpoint")
.unwrap()
.unwrap();
let initial_at = initial
.get("rooms")
.unwrap()
.get(ROOM)
.unwrap()
.get("saved_at")
.unwrap()
.as_i64()
.unwrap();
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
next.restore_for_session(A).unwrap();
send(
&mut next,
A,
&format!("<message to='{ROOM}' type='groupchat'><body>traffic</body></message>"),
);
drop(next);
storage::update(root.path(), HOST, None, "rust_muc_checkpoint", |v| {
let mut rooms = v.get("rooms").unwrap().clone();
let mut room = rooms.get(ROOM).unwrap().clone();
assert_eq!(room.get("saved_at").unwrap().as_i64().unwrap(), initial_at);
room.set("saved_at", storage::LuaValue::Integer(initial_at - 301));
rooms.set(ROOM, room);
v.set("rooms", rooms);
Ok(())
})
.unwrap();
let mut third = muc(root.path());
third.prepare_recovery().unwrap();
assert!(third.restore_for_session(B).unwrap().replies.is_empty());
}
#[test]
fn durable_disconnect_removes_last_temporary_room_without_shutdown_export() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
first.disconnect_durable(A).unwrap();
drop(first);
let mut next = muc(root.path());
next.prepare_recovery().unwrap();
assert!(next.restore_for_session(A).unwrap().replies.is_empty());
}
#[test]
fn journal_failure_returns_no_routing_and_rolls_back_role_mutation() {
let root = tempfile::tempdir().unwrap();
let mut first = muc(root.path());
join(&mut first, A, "Alice");
join(&mut first, B, "Bob");
let path = storage::path(root.path(), HOST, None, "rust_muc_checkpoint");
let checkpoint = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let packet = format!(
"<iq to='{ROOM}' type='set' id='kick'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='Bob' role='none'/></query></iq>"
);
let doc = roxmltree::Document::parse(&packet).unwrap();
assert!(first.handle(A, doc.root_element()).is_err());
std::fs::remove_dir(&path).unwrap();
storage::atomic_write(&path, &checkpoint).unwrap();
let out = send(
&mut first,
B,
&format!("<message to='{ROOM}' type='groupchat'><body>still-participant</body></message>"),
);
assert!(
out.deliveries
.iter()
.any(|(jid, p)| jid == A && p.contains("still-participant"))
);
}
#[test]
fn safe_presence_checkpoint_sanitization_is_idempotent() {
use rust_xmpp::muc_recovery::sanitize_presence_payload;
let payload = "<show xmlns='jabber:client'>away</show><status xmlns='jabber:client'>Ready &amp; waiting</status><x xmlns='http://jabber.org/protocol/muc'><password>secret</password></x>";
let safe = sanitize_presence_payload(payload);
assert_eq!(
safe,
"<show>away</show><status>Ready &amp; waiting</status>"
);
assert_eq!(sanitize_presence_payload(&safe), safe);
}
#[test]
fn foreign_stanzas_do_not_initialize_or_rewrite_muc_checkpoint() {
let root = tempfile::tempdir().unwrap();
let mut m = muc(root.path());
let packet = "<message to='bob@example.org/phone' type='chat'><body>direct</body></message>";
let doc = roxmltree::Document::parse(packet).unwrap();
assert!(m.handle(A, doc.root_element()).unwrap().is_none());
let path = storage::path(root.path(), HOST, None, "rust_muc_checkpoint");
assert!(!path.exists());
join(&mut m, A, "Alice");
let checkpoint = std::fs::read(&path).unwrap();
// A sentinel distinguishes a write from an identical journal serialization.
let sentinel = [checkpoint.as_slice(), b"-- untouched\n"].concat();
storage::atomic_write(&path, &sentinel).unwrap();
assert!(m.handle(A, doc.root_element()).unwrap().is_none());
assert_eq!(std::fs::read(&path).unwrap(), sentinel);
}
+8 -6
View File
@@ -12,10 +12,11 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
let raw = storage::parse(b"return {_jid='room@rooms.test'; _data={persistent=true}; ['owner@test']='owner'; ['remote.test']='outcast'; ['member@remote.test']='member'}").unwrap();
storage::save(temp.path(), "rooms.test", Some("room"), "config", &raw).unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
let denied = send(
&mut muc,
"outsider@remote.test/client",
"<presence to='room@rooms.test/Outsider'/>",
"<presence to='room@rooms.test/Outsider'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
assert!(
denied.replies[0].contains("forbidden"),
@@ -25,7 +26,7 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
let admitted = send(
&mut muc,
"member@remote.test/client",
"<presence to='room@rooms.test/Member'/>",
"<presence to='room@rooms.test/Member'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
assert!(!admitted.replies.iter().any(|p| p.contains("type='error'")));
assert!(
@@ -40,20 +41,21 @@ fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
fn applying_domain_ban_removes_remote_occupants_and_blocks_rejoin() {
let temp = tempfile::tempdir().unwrap();
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
send(
&mut muc,
"owner@test/r",
"<presence to='room@rooms.test/Owner'/>",
"<presence to='room@rooms.test/Owner'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
send(
&mut muc,
"remote@remote.test/r",
"<presence to='room@rooms.test/Remote'/>",
"<presence to='room@rooms.test/Remote'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
send(
&mut muc,
"other@allowed.test/r",
"<presence to='room@rooms.test/Other'/>",
"<presence to='room@rooms.test/Other'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
let banned = send(
&mut muc,
@@ -79,7 +81,7 @@ fn applying_domain_ban_removes_remote_occupants_and_blocks_rejoin() {
send(
&mut muc,
"remote@remote.test/new",
"<presence to='room@rooms.test/Remote'/>"
"<presence to='room@rooms.test/Remote'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
)
.replies[0]
.contains("forbidden")
+64 -23
View File
@@ -8,11 +8,14 @@ fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
fn setup(defaults: serde_json::Value) -> (tempfile::TempDir, Muc) {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
m.set_defaults(defaults);
send(
&mut m,
"alice@example.org/r",
&format!("<presence to='{ROOM}/Alice'/>"),
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
(root, m)
}
@@ -22,7 +25,9 @@ fn restricted_private_messages_allow_self_and_optional_moderators() {
send(
&mut m,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
let denied = send(
&mut m,
@@ -58,7 +63,9 @@ fn broadcast_roles_hide_visitors_and_configuration_announces_transition() {
let join = send(
&mut m,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(join.deliveries.is_empty());
assert!(
@@ -99,11 +106,12 @@ fn language_config_sections_discovery_and_native_affiliation_hats() {
frozen.set("_affiliation_data", hats);
std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
let join = send(
&mut m,
"alice@example.org/r",
&format!(
"<presence to='{ROOM}/Alice'><hats xmlns='urn:xmpp:hats:0'><hat uri='fake'/></hats></presence>"
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/><hats xmlns='urn:xmpp:hats:0'><hat uri='fake'/></hats></presence>"
),
);
let presence = join.replies.iter().find(|p| p.contains("110")).unwrap();
@@ -135,7 +143,9 @@ fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() {
send(
&mut m,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
for (kind, to) in [
("groupchat", ROOM.to_string()),
@@ -159,11 +169,17 @@ fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() {
fn disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(json!({"muc_vcard":false,"muc_occupant_id":false}), false);
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
m.set_component_options(
json!({"muc_room_locking":false,"muc_vcard":false,"muc_occupant_id":false}),
false,
);
let join = send(
&mut m,
"alice@example.org/r",
&format!("<presence to='{ROOM}/Alice'/>"),
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(join.replies.iter().all(|p| !p.contains("occupant-id")));
let disco = send(
@@ -186,11 +202,14 @@ fn disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted() {
fn mam_all_rooms_presence_logging_public_queries_and_optional_totals() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(json!({"muc_log_all_rooms":true,"muc_log_by_default":false,"muc_log_presences":true,"muc_log_include_total":false}),true);
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
m.set_component_options(json!({"muc_room_locking":false,"muc_log_all_rooms":true,"muc_log_by_default":false,"muc_log_presences":true,"muc_log_include_total":false}),true);
send(
&mut m,
"alice@example.org/r",
&format!("<presence to='{ROOM}/Alice'/>"),
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
send(
&mut m,
@@ -258,10 +277,13 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
);
std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
let guest = send(
&mut m,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
let offline = guest
.replies
@@ -279,7 +301,9 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
let owner = send(
&mut m,
"alice@example.org/r",
&format!("<presence to='{ROOM}/Alice'/>"),
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(
owner
@@ -290,7 +314,9 @@ fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames()
send(
&mut m,
"bob@example.org/second",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
let disco = send(
&mut m,
@@ -309,6 +335,7 @@ fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() {
first.checkpoint_live().unwrap();
drop(first);
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
recovered.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
recovered.prepare_recovery().unwrap();
recovered.disconnect("unrelated@example.org/r");
let restored = recovered
@@ -321,12 +348,17 @@ fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() {
.any(|p| p.contains("110") && p.contains("role='moderator'"))
);
drop(recovered);
// No new checkpoint: an already consumed claim cannot resurrect stale role.
// Authenticated restoration committed a new live membership checkpoint.
let mut again = Muc::new(root.path().into(), "rooms.example.org".into());
again.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
again.prepare_recovery().unwrap();
let absent = again.restore_for_session("alice@example.org/r").unwrap();
assert!(absent.replies.is_empty());
assert!(absent.deliveries.is_empty());
let restored_again = again.restore_for_session("alice@example.org/r").unwrap();
assert!(
restored_again
.replies
.iter()
.any(|p| p.contains("/Alice") && p.contains("110") && p.contains("role='moderator'"))
);
}
#[test]
fn large_optional_history_does_not_prevent_checkpointing_membership() {
@@ -347,35 +379,44 @@ fn explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved() {
send(
&mut first,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
first.checkpoint_live().unwrap();
drop(first);
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
recovered.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
recovered.prepare_recovery().unwrap();
let stolen = send(
&mut recovered,
"mallory@example.org/r",
&format!("<presence to='{ROOM}/Bob'/>"),
&format!(
"<presence to='{ROOM}/Bob'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(stolen.replies.iter().any(|p| p.contains("conflict")));
let explicit = send(
&mut recovered,
"alice@example.org/r",
&format!("<presence to='{ROOM}/Changed'/>"),
&format!(
"<presence to='{ROOM}/Changed'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(explicit.replies.iter().any(|p| p.contains("110")));
// Temporary room config and Bob's claim survive Alice's ordinary join.
drop(recovered);
let mut again = Muc::new(root.path().into(), "rooms.example.org".into());
again.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
again.prepare_recovery().unwrap();
let alice = again.restore_for_session("alice@example.org/r").unwrap();
assert!(
again
.restore_for_session("alice@example.org/r")
.unwrap()
alice
.replies
.is_empty()
.iter()
.any(|p| p.contains("/Changed") && p.contains("110"))
);
assert!(alice.replies.iter().all(|p| !p.contains("/Alice")));
assert!(
again
.restore_for_session("bob@example.org/r")
+2 -1
View File
@@ -125,8 +125,9 @@ fn archive_history_and_permissions() {
fn nick_invite_destroy_and_local_creation() {
let t = tempfile::tempdir().unwrap();
let mut m = Muc::new(t.path().into(), "rooms.test".into());
m.set_component_options(serde_json::json!({"restrict_room_creation":"local"}), false);
m.set_local_domain("test".into());
assert!(join(&mut m, "external@else/a", "External").replies[0].contains("forbidden"));
assert!(join(&mut m, "external@else/a", "External").replies[0].contains("not-allowed"));
join(&mut m, "alice@test/a", "Alice");
unlock(&mut m);
let renamed = join(&mut m, "alice@test/a", "NewName");
+201
View File
@@ -0,0 +1,201 @@
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
use serde_json::json;
const ROOM: &str = "room@rooms.example.org";
const COMMANDS: &str = "http://jabber.org/protocol/commands";
const AFF: &str = "http://prosody.im/protocol/muc#set-affiliation";
const DESTROY: &str = "http://prosody.im/protocol/muc#destroy";
fn send(m: &mut Muc, jid: &str, xml: &str) -> ModuleResult {
let doc = roxmltree::Document::parse(xml).unwrap();
m.handle(jid, doc.root_element()).unwrap().unwrap()
}
fn room(m: &mut Muc) {
send(
m,
"owner@example.org/r",
&format!(
"<presence to='{ROOM}/Owner'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
send(
m,
"owner@example.org/r",
&format!(
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_persistentroom'><value>1</value></field></x></query></iq>"
),
);
}
fn register(m: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
send(
m,
jid,
&format!(
"<iq to='{ROOM}' type='set'><query xmlns='jabber:iq:register'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#register</value></field><field var='muc#register_roomnick'><value>{nick}</value></field></x></query></iq>"
),
)
}
#[test]
fn optional_registration_grants_membership_and_enforces_reserved_nickname() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(json!({"allow_unaffiliated_register":true,"enforce_registered_nickname":true,"muc_registration_include_form":true}),false);
room(&mut m);
assert!(register(&mut m, "bob@example.org/r", "Reserved").replies[0].contains("type='result'"));
let result = send(
&mut m,
"bob@example.org/r",
&format!("<iq to='{ROOM}' type='get'><query xmlns='jabber:iq:register'/></iq>"),
);
assert!(result.replies[0].contains("type='result'><field"));
assert!(result.replies[0].contains("<value>Reserved</value>"));
let joined = send(
&mut m,
"bob@example.org/r",
&format!(
"<presence to='{ROOM}/Other'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(
joined
.replies
.iter()
.any(|p| p.contains("/Reserved'") && p.contains("affiliation='member'"))
);
let rename = send(
&mut m,
"bob@example.org/r",
&format!("<presence to='{ROOM}/Other'/>"),
);
assert!(rename.replies[0].contains("not-acceptable"));
let reregister = register(&mut m, "bob@example.org/r", "New");
assert!(
reregister
.deliveries
.iter()
.any(|(_, p)| p.contains("code='307'"))
);
let message = send(
&mut m,
"bob@example.org/r",
&format!("<message to='{ROOM}' type='groupchat'><body>departed</body></message>"),
);
assert!(message.replies[0].contains("not-acceptable"));
}
#[test]
fn default_registration_denies_unaffiliated_users() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
room(&mut m);
assert!(
register(&mut m, "bob@example.org/r", "Bob").replies[0].contains("registration-required")
);
}
fn start(m: &mut Muc, jid: &str, node: &str) -> String {
let result = send(
m,
jid,
&format!(
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{node}'/></iq>"
),
);
let doc = roxmltree::Document::parse(&result.replies[0]).unwrap();
doc.descendants()
.find(|n| n.has_tag_name((COMMANDS, "command")))
.unwrap()
.attribute("sessionid")
.unwrap()
.to_owned()
}
fn submit(m: &mut Muc, jid: &str, node: &str, id: &str, fields: &str) -> ModuleResult {
send(
m,
jid,
&format!(
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{node}' sessionid='{id}' action='complete'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>{node}</value></field>{fields}</x></command></iq>"
),
)
}
#[test]
fn administration_commands_recheck_authority_bind_sessions_and_update_live_rooms() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(json!({"admins":["admin@example.org","other@example.org"],"muc_tombstone_expiry":"0 seconds"}),false);
room(&mut m);
let outsider = send(
&mut m,
"stranger@example.org/r",
&format!(
"<iq to='rooms.example.org' type='get'><query xmlns='http://jabber.org/protocol/disco#items' node='{COMMANDS}'/></iq>"
),
);
assert!(!outsider.replies[0].contains("node='http://prosody.im/protocol/muc#destroy'"));
let denied = send(
&mut m,
"stranger@example.org/r",
&format!(
"<iq to='rooms.example.org' type='set'><command xmlns='{COMMANDS}' node='{AFF}'/></iq>"
),
);
assert!(denied.replies[0].contains("forbidden"));
let id = start(&mut m, "admin@example.org/r", AFF);
let fields = format!(
"<field var='room'><value>{ROOM}</value></field><field var='jid'><value>owner@example.org</value></field><field var='affiliation'><value>outcast</value></field>"
);
assert!(
submit(&mut m, "other@example.org/r", AFF, &id, &fields).replies[0].contains("bad-request")
);
let updated = submit(&mut m, "admin@example.org/r", AFF, &id, &fields);
assert!(updated.replies[0].contains("status='completed'"));
assert!(
updated
.deliveries
.iter()
.any(|(_, p)| p.contains("code='301'"))
);
assert!(
submit(&mut m, "admin@example.org/r", AFF, &id, &fields).replies[0].contains("bad-request")
);
// A privileged component command may remove the final owner, just like
// native room:set_affiliation(true,...); destroying such a room still works.
let id = start(&mut m, "admin@example.org/r", DESTROY);
assert!(
submit(
&mut m,
"admin@example.org/r",
DESTROY,
&id,
&format!("<field var='rooms'><value>{ROOM}</value></field>")
)
.replies[0]
.contains("status='completed'")
);
let rejoin = send(
&mut m,
"new@example.org/r",
&format!(
"<presence to='{ROOM}/New'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(rejoin.replies.iter().any(|p| p.contains("code='201'")));
}
#[test]
fn configured_tombstone_expiry_allows_immediate_recreation() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
m.set_component_options(json!({"muc_tombstone_expiry":"0 seconds"}), false);
room(&mut m);
send(
&mut m,
"owner@example.org/r",
&format!(
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>"
),
);
let joined = send(
&mut m,
"new@example.org/r",
&format!(
"<presence to='{ROOM}/New'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(joined.replies.iter().any(|p| p.contains("code='201'")));
}
+177
View File
@@ -0,0 +1,177 @@
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
use serde_json::json;
fn send(m: &mut Muc, who: &str, packet: &str) -> ModuleResult {
let doc = roxmltree::Document::parse(packet).unwrap();
m.handle(who, doc.root_element()).unwrap().unwrap()
}
fn join(m: &mut Muc, who: &str, nick: &str) -> ModuleResult {
send(
m,
who,
&format!(
"<presence to='r@rooms.test/{nick}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
)
}
#[test]
fn probes_and_invalid_presence_cannot_join_or_rename() {
let dir = tempfile::tempdir().unwrap();
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
assert!(
send(
&mut m,
"alice@test/r",
"<presence to='r@rooms.test/Alice'/>"
)
.replies[0]
.contains("item-not-found")
);
join(&mut m, "alice@test/r", "Alice");
assert!(
send(
&mut m,
"bob@test/r",
"<presence type='probe' to='r@rooms.test/Alice'/>"
)
.replies[0]
.contains("not-acceptable")
);
assert!(
send(
&mut m,
"bob@test/r",
"<presence type='subscribe' to='r@rooms.test/Bob'/>"
)
.replies[0]
.contains("bad-request")
);
let probe = send(
&mut m,
"alice@test/r",
"<presence type='probe' to='r@rooms.test/Alice'/>",
);
assert!(probe.replies[0].contains("role='moderator'"));
assert!(probe.deliveries.is_empty());
assert!(
send(
&mut m,
"alice@test/r",
"<presence type='probe' to='r@rooms.test/Absent'/>"
)
.replies
.is_empty()
);
}
#[test]
fn error_messages_kick_only_on_delivery_failure_and_presence_errors_kick() {
let dir = tempfile::tempdir().unwrap();
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut m, "alice@test/r", "Alice");
join(&mut m, "bob@test/r", "Bob");
let denied = send(
&mut m,
"bob@test/r",
"<message type='error' to='r@rooms.test'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
);
assert!(denied.replies.is_empty() && denied.deliveries.is_empty());
let forwarded = send(
&mut m,
"bob@test/r",
"<message type='error' to='r@rooms.test/Alice'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
);
assert_eq!(forwarded.deliveries.len(), 1);
assert!(forwarded.deliveries[0].1.contains("type='error'"));
let kicked = send(
&mut m,
"bob@test/r",
"<message type='error' to='r@rooms.test/Alice'><error><service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></message>",
);
assert!(
kicked
.deliveries
.iter()
.any(|(_, p)| p.contains("code='333'") && p.contains("Kicked: service unavailable"))
);
join(&mut m, "bob@test/r", "Bob");
let kicked = send(
&mut m,
"bob@test/r",
"<presence type='error' to='r@rooms.test/Bob'><error><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error></presence>",
);
assert!(
kicked
.deliveries
.iter()
.any(|(_, p)| p.contains("code='333'"))
);
}
#[test]
fn room_creation_policies_apply_only_to_new_rooms() {
let dir = tempfile::tempdir().unwrap();
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
m.set_local_domain("test".into());
m.set_component_options(json!({"muc_room_locking":false,"restrict_room_creation":true,"admins":["admin@test"],"muc_max_rooms":1}), false);
assert!(join(&mut m, "alice@test/r", "Alice").replies[0].contains("not-allowed"));
assert!(
join(&mut m, "admin@test/r", "Admin")
.replies
.iter()
.any(|p| p.contains("code='201'"))
);
assert!(
join(&mut m, "alice@remote/r", "Alice")
.replies
.iter()
.any(|p| p.contains("code='110'"))
);
let full = send(
&mut m,
"admin@test/r",
"<presence to='s@rooms.test/Admin'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
);
assert!(full.replies[0].contains("resource-constraint"));
m.set_component_options(json!({"restrict_room_creation":"local"}), false);
assert!(send(&mut m, "alice@remote/r", "<presence to='s@rooms.test/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>").replies[0].contains("not-allowed"));
m.set_component_options(json!({}), false);
assert!(send(&mut m, "alice@remote/r", "<presence to='s@rooms.test/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>").replies.iter().any(|p|p.contains("code='201'")));
}
#[test]
fn ordinary_presence_extensions_survive_but_client_identity_is_filtered() {
let dir = tempfile::tempdir().unwrap();
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
m.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
join(&mut m, "alice@test/r", "Alice");
let result = send(
&mut m,
"bob@test/r",
"<presence to='r@rooms.test/Bob'><x xmlns='http://jabber.org/protocol/muc'/><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='client' ver='v'/><idle xmlns='urn:xmpp:idle:1' since='2026-01-01T00:00:00Z'/><occupant-id xmlns='urn:xmpp:occupant-id:0' id='forged'/></presence>",
);
let p = &result.deliveries[0].1;
assert!(p.contains("http://jabber.org/protocol/caps") && p.contains("urn:xmpp:idle:1"));
assert!(!p.contains("forged"));
}
#[test]
fn subject_with_body_remains_an_ordinary_groupchat_message() {
let dir = tempfile::tempdir().unwrap();
let mut m = Muc::new(dir.path().into(), "rooms.test".into());
m.set_component_options(json!({"muc_room_locking":false}), false);
join(&mut m, "alice@test/r", "Alice");
join(&mut m, "bob@test/r", "Bob");
let ordinary = send(
&mut m,
"bob@test/r",
"<message type='groupchat' to='r@rooms.test'><subject>Heading</subject><body>Message text</body></message>",
);
assert!(ordinary.replies.is_empty());
assert_eq!(ordinary.deliveries.len(), 2);
let subject = send(
&mut m,
"bob@test/r",
"<message type='groupchat' to='r@rooms.test'><subject>Room title</subject></message>",
);
assert!(subject.replies[0].contains("forbidden"));
}
+24 -5
View File
@@ -9,6 +9,7 @@ fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
}
fn initial(root: &std::path::Path) -> Muc {
let mut m = Muc::new(root.into(), HOST.into());
m.set_component_options(json!({"muc_room_locking":false}), false);
m.set_defaults(json!({"persistent": true, "password":"private-pass"}));
send(
&mut m,
@@ -57,12 +58,18 @@ fn repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing() {
);
assert!(restored.replies.iter().all(|p| !p.contains("private-pass")));
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
// Simulate a later crash: no clean checkpoint. A consumed on-disk proof
// must not revive a prior moderator grant on another process start.
// The authenticated restoration itself committed fresh live membership,
// so a second crash recovers that current room state again.
drop(m);
let mut m = Muc::new(root.path().into(), HOST.into());
m.prepare_recovery().unwrap();
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
assert!(
m.restore_for_session(ALICE)
.unwrap()
.replies
.iter()
.any(|p| p.contains("110"))
);
}
#[test]
fn changed_configuration_invalidates_auto_recovery_without_disclosing_password() {
@@ -81,7 +88,13 @@ fn changed_configuration_invalidates_auto_recovery_without_disclosing_password()
m.prepare_recovery().unwrap();
let result = m.restore_for_session(ALICE).unwrap();
assert!(result.replies.is_empty() && result.deliveries.is_empty());
let denied = send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
let denied = send(
&mut m,
ALICE,
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
assert!(denied.replies.iter().any(|p| p.contains("not-authorized")));
assert!(
denied
@@ -95,7 +108,13 @@ fn changed_configuration_invalidates_auto_recovery_without_disclosing_password()
fn unrelated_disconnect_does_not_delete_dormant_temporary_room() {
let root = tempfile::tempdir().unwrap();
let mut m = Muc::new(root.path().into(), HOST.into());
send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
send(
&mut m,
ALICE,
&format!(
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
),
);
m.checkpoint_live().unwrap();
drop(m);
let mut m = Muc::new(root.path().into(), HOST.into());
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""Exercise durable MUC/SM hot paths over real STARTTLS with bounded fanout.
python3 tests/muc_sm_load_network.py --binary target/release/rust-xmpp --clients 500
Defaults to 100 resources; --clients 500 uses 100 accounts and 10 rooms of 50.
JSON contains observed latency/RSS/CPU, slot counts and exact fanout assertions.
All sockets are drained continuously. SIGKILL precedes socket cleanup; ten
representatives resume with unacknowledged room output and send without rejoin.
This is a loopback smoke measurement, not a production capacity guarantee.
"""
import argparse
import asyncio
import base64
import collections
import hashlib
import json
import pathlib
import signal
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'scripts'))
from load_smoke import Client as BaseClient, STREAM, distribution, process_metrics, proc_pid
SM = 'urn:xmpp:sm:3'
MUC = 'http://jabber.org/protocol/muc'
CLIENT = 'jabber:client'
def start(binary, config, port, log):
# Recovery of hundreds of durable resources legitimately exceeds the
# five-second deadline used by the tiny functional restart fixture.
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
assert server.poll() is None, 'server stopped during startup'
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
return server
except OSError:
time.sleep(.05)
server.kill()
server.wait()
raise AssertionError('server did not start within 60 seconds')
class Client(BaseClient):
def __init__(self, number, user, reader, writer):
super().__init__(number, user, reader, writer)
self.received = self.inputs = self.acked = 0
self.token = None
self.presences = {}
self.iqs = {}
self.controls = collections.defaultdict(list)
self.bodies = collections.Counter()
self.errors = []
self.changed = asyncio.Event()
self.auto_ack = True
self.task = None
self.failure = None
@classmethod
async def connect(cls, number, port, context, accounts, bind=True):
reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 60)
user = f'load-{number % accounts}'
client = cls(number, user, reader, writer)
try:
await client.send(STREAM)
assert b'starttls' in await client.until(b'</stream:features>')
await client.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
assert b'proceed' in await client.until(b'/>')
await writer.start_tls(context, server_hostname='localhost', ssl_handshake_timeout=60)
await client.send(STREAM)
await client.until(b'</stream:features>')
auth = base64.b64encode(f'\0{user}\0load-test-password'.encode()).decode()
await client.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{auth}</auth>")
assert b'success' in await client.until(b'/>')
await client.send(STREAM)
await client.until(b'</stream:features>')
if bind:
await client.send(f"<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>load-{number}</resource></bind></iq>")
assert client.jid.encode() in await client.until(b'</iq>')
client.task = asyncio.create_task(client.drain())
return client
except BaseException:
writer.close()
raise
async def stanza(self, text):
self.inputs = (self.inputs + 1) % (1 << 32)
await self.send(text)
async def drain(self):
parser = ET.XMLPullParser(events=('start', 'end'))
parser.feed("<root xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>")
root = next(parser.read_events())[1]
depth = 0
try:
while chunk := await self.reader.read(65536):
parser.feed(chunk)
if hasattr(parser, 'flush'):
parser.flush()
for event, node in parser.read_events():
depth += 1 if event == 'start' else -1
if event != 'end' or depth != 0:
continue
local = node.tag.split('}')[-1]
if node.tag in (f'{{{CLIENT}}}iq', f'{{{CLIENT}}}presence', f'{{{CLIENT}}}message'):
self.received = (self.received + 1) % (1 << 32)
if node.get('type') == 'error':
self.errors.append(ET.tostring(node).decode())
if local == 'presence':
self.presences[node.get('from')] = node
elif local == 'iq':
self.iqs[node.get('id')] = node
elif local == 'message':
for body in node.findall(f'{{{CLIENT}}}body'):
self.bodies[body.text] += 1
elif node.tag == f'{{{SM}}}r':
if self.auto_ack:
await self.ack()
else:
self.controls[node.tag].append(node)
root.remove(node)
self.changed.set()
raise EOFError('server closed stream')
except asyncio.CancelledError:
raise
except Exception as error:
self.failure = error
self.changed.set()
async def wait(self, predicate, description):
async def observe():
while True:
self.changed.clear()
result = predicate()
if isinstance(result, ET.Element) or result:
return result
if self.failure:
raise RuntimeError(f'client {self.number}: {description}: {self.failure}')
await self.changed.wait()
return await asyncio.wait_for(observe(), 120)
async def control(self, name):
key = f'{{{SM}}}{name}'
await self.wait(lambda: self.controls[key], name)
return self.controls[key].pop(0)
async def enable(self):
await self.send(f"<enable xmlns='{SM}' resume='true'/>")
node = await self.control('enabled')
assert node.get('resume') == 'true'
self.token = node.get('id')
assert self.token
async def fence(self, identifier):
await self.stanza(f"<iq type='get' to='localhost' id='{identifier}'><ping xmlns='urn:xmpp:ping'/></iq>")
result = await self.wait(lambda: self.iqs.pop(identifier, None), identifier)
assert result.get('type') == 'result', ET.tostring(result)
async def ack(self):
self.acked = self.received
await self.send(f"<a xmlns='{SM}' h='{self.acked}'/>")
async def handled(self):
await self.send(f"<r xmlns='{SM}'/>")
value = int((await self.control('a')).get('h'))
assert value == self.inputs, (self.number, value, self.inputs)
return value
async def close_raw(self):
if self.task:
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
self.writer.close()
try:
await asyncio.wait_for(self.writer.wait_closed(), 3)
except (OSError, ssl.SSLError, TimeoutError):
pass
def slots(root):
paths = list((root / 'data').rglob('rust_smacks/*.json'))
return {'count': len(paths), 'bytes': sum(p.stat().st_size for p in paths)}
async def exercise(args, root, config, port, log):
clients = []
server = await asyncio.to_thread(start, args.binary, config, port, log)
pid = proc_pid(server.pid)
samples = []
running = True
async def sample():
while running:
samples.append(process_metrics(pid))
await asyncio.sleep(.2)
sampler = asyncio.create_task(sample())
gate = asyncio.Semaphore(args.concurrency)
context = ssl.create_default_context(cafile=str(root / 'cert.pem'))
report = {'clients': args.clients, 'accounts': args.accounts, 'rooms': args.rooms,
'message_rounds': args.rounds, 'concurrency': args.concurrency,
'binary': args.binary, 'binary_sha256': hashlib.sha256(pathlib.Path(args.binary).read_bytes()).hexdigest(),
'before': process_metrics(pid)}
begun = time.perf_counter()
def save(phase):
report['phase'] = phase
report['status'] = 'running'
report['elapsed_seconds'] = round(time.perf_counter() - begun, 3)
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
print(f'MUC+SM load: {phase} ({report["elapsed_seconds"]}s)', file=sys.stderr, flush=True)
try:
async def setup(number):
async with gate:
started = time.perf_counter()
client = await Client.connect(number, port, context, args.accounts)
clients.append(client)
await client.enable()
await client.stanza('<presence/>')
await client.fence(f'connected-{number}')
await client.ack()
return time.perf_counter() - started
connect_times = await asyncio.gather(*(setup(i) for i in range(args.clients)))
clients.sort(key=lambda c: c.number)
report['connect_seconds'] = round(time.perf_counter() - begun, 3)
report['connect_latency'] = distribution(connect_times)
save('connected')
groups = [[c for c in clients if c.number % args.rooms == room] for room in range(args.rooms)]
started = time.perf_counter()
async def join(client):
async with gate:
room = f'load-{client.number % args.rooms}@rooms.localhost'
await client.stanza(f"<presence to='{room}/Nick-{client.number}'><x xmlns='{MUC}'><history maxstanzas='0'/></x></presence>")
result = await client.wait(lambda: client.presences.get(f'{room}/Nick-{client.number}'), 'self MUC join')
assert result.get('type') != 'error', ET.tostring(result)
await asyncio.gather(*(join(c) for c in clients))
# Each room has exactly N nicknames, including self, on every resource.
for group in groups:
for client in group:
expected = {f'load-{client.number % args.rooms}@rooms.localhost/Nick-{other.number}' for other in group}
await client.wait(lambda c=client,e=expected: e.issubset(c.presences), 'complete join fanout')
await asyncio.gather(*(c.fence(f'joined-{c.number}') for c in clients))
await asyncio.gather(*(c.ack() for c in clients))
await asyncio.gather(*(c.handled() for c in clients))
report['join_seconds'] = round(time.perf_counter() - started, 3)
report['joined'] = process_metrics(pid)
report['join_presence_deliveries'] = sum(len(g) ** 2 for g in groups)
report['slots_joined'] = slots(root)
assert report['slots_joined']['count'] == args.clients, report['slots_joined']
save('joined')
delivered = 0
started = time.perf_counter()
for turn in range(args.rounds):
for index, group in enumerate(groups):
sender = group[turn % len(group)]
text = f'durable-load-{turn}-{index}'
await sender.stanza(f"<message type='groupchat' to='load-{index}@rooms.localhost'><body>{text}</body></message>")
async def receive(client):
text = f'durable-load-{turn}-{client.number % args.rooms}'
await client.wait(lambda: client.bodies[text] >= 1, text)
assert client.bodies[text] == 1, (client.number, text, client.bodies[text])
await asyncio.gather(*(receive(c) for c in clients))
delivered += len(clients)
await asyncio.gather(*(c.fence(f'round-{turn}-{c.number}') for c in clients))
await asyncio.gather(*(c.ack() for c in clients))
report['message_seconds'] = round(time.perf_counter() - started, 3)
report['room_messages_sent'] = args.rounds * args.rooms
report['room_messages_delivered'] = delivered
assert delivered == args.clients * args.rounds
save('fanout_verified')
assert not [(c.number,c.errors) for c in clients if c.errors]
# Freeze acknowledgements, then deliberately receive one more message
# per room without acknowledging it. Its durable replay is mandatory.
representatives = [group[0] for group in groups]
for client in clients:
client.auto_ack = False
for index, sender in enumerate(representatives):
await sender.stanza(f"<message type='groupchat' to='load-{index}@rooms.localhost'><body>pending-{index}</body></message>")
await asyncio.gather(*(c.wait(lambda c=c: c.bodies[f'pending-{c.number % args.rooms}'] == 1, 'pending room message') for c in clients))
await asyncio.gather(*(c.fence(f'before-kill-{c.number}') for c in clients))
await asyncio.gather(*(c.handled() for c in clients))
expected = {c.number: {'token': c.token, 'acked': c.acked, 'handled': c.inputs} for c in representatives}
report['before_kill'] = process_metrics(pid)
report['slots_before_kill'] = slots(root)
save('before_sigkill')
server.send_signal(signal.SIGKILL)
assert await asyncio.to_thread(server.wait, 10) == -signal.SIGKILL
await asyncio.gather(*(c.close_raw() for c in clients))
clients.clear()
started = time.perf_counter()
server = await asyncio.to_thread(start, args.binary, config, port, log)
pid = proc_pid(server.pid)
report['restart_seconds'] = round(time.perf_counter() - started, 3)
async def recover(number):
async with gate:
old = expected[number]
client = await Client.connect(number, port, context, args.accounts, bind=False)
client.auto_ack = False
client.received = old['acked']
client.acked = old['acked']
client.inputs = old['handled']
client.token = old['token']
clients.append(client)
await client.send(f"<resume xmlns='{SM}' previd='{client.token}' h='{client.acked}'/>")
result = await client.control('resumed')
assert result.get('previd') == client.token
assert int(result.get('h')) == client.inputs, (number, ET.tostring(result),client.inputs)
room = number % args.rooms
await client.wait(lambda: client.bodies[f'pending-{room}'] == 1, 'unacknowledged room replay')
await client.wait(lambda: client.iqs.get(f'before-kill-{number}'), 'unacknowledged IQ replay')
# No available presence or MUC join is sent after SM resume.
text = f'after-resume-{room}'
await client.stanza(f"<message type='groupchat' to='load-{room}@rooms.localhost'><body>{text}</body></message>")
await client.wait(lambda: client.bodies[text] == 1, 'recovered membership groupchat echo')
await client.fence(f'after-resume-{number}')
await client.ack()
await client.handled()
assert not client.errors, client.errors
# No message already acknowledged before SIGKILL may replay.
assert not any(count for text,count in client.bodies.items() if text and text.startswith('durable-load-')), client.bodies
return number
recovered = await asyncio.gather(*(recover(c.number) for c in representatives))
report['resumed_resources'] = len(recovered)
report['replayed_pending_room_messages'] = len(recovered)
report['membership_groupchat_deliveries_after_resume'] = len(recovered)
report['after_resume'] = process_metrics(pid)
report['slots_after_resume'] = slots(root)
report['total_seconds'] = round(time.perf_counter() - begun, 3)
report['elapsed_seconds'] = report['total_seconds']
report['peak_rss_kib'] = max((s.get('rss_kib',0) for s in samples), default=0)
report['status'] = 'pass'
report['phase'] = 'completed'
return report
except BaseException as error:
report['status'] = 'fail'
report['failure'] = repr(error)
report['elapsed_seconds'] = round(time.perf_counter() - begun, 3)
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
raise
finally:
running = False
sampler.cancel()
await asyncio.gather(sampler, return_exceptions=True)
if server.poll() is None:
server.terminate()
try:
await asyncio.to_thread(server.wait, 20)
except subprocess.TimeoutExpired:
server.kill()
await asyncio.to_thread(server.wait)
await asyncio.gather(*(c.close_raw() for c in clients), return_exceptions=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', default='target/release/rust-xmpp')
parser.add_argument('--clients', type=int, default=100)
parser.add_argument('--accounts', type=int, default=100)
parser.add_argument('--rooms', type=int, default=10)
parser.add_argument('--rounds', type=int, default=3)
parser.add_argument('--concurrency', type=int, default=20)
parser.add_argument('--output', default='/tmp/muc-sm-load.json')
args = parser.parse_args()
args.binary = str(pathlib.Path(args.binary).resolve())
args.accounts = min(args.accounts,args.clients)
assert 1 <= args.rooms <= args.clients <= 4000
assert 1 <= args.accounts <= args.clients and args.rounds > 0 and args.concurrency > 0
with tempfile.TemporaryDirectory(prefix='xmpp-durable-muc-load-') as tmp:
root = pathlib.Path(tmp)
# Release builds/packaging may replace the source binary during a long
# run; this private executable is the same inode generation on restart.
shutil.copy2(args.binary, root / 'rust-xmpp')
args.binary = str(root / 'rust-xmpp')
subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:P-256',
'-nodes','-days','1','-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost',
'-addext','basicConstraints=critical,CA:FALSE','-keyout',str(root/'key.pem'),
'-out',str(root/'cert.pem')],check=True,capture_output=True)
with socket.socket() as probe:
probe.bind(('127.0.0.1',0))
port = probe.getsockname()[1]
cfg = {'global': {'data_path':str(root/'data'),'interfaces':['127.0.0.1'],
'c2s_ports':[port],'s2s_ports':[],'http_ports':[],'https_ports':[],
'smacks_hibernation_time':600,'muc_recovery_grace_period':600,
'modules_enabled':['tls','saslauth','smacks','disco','ping'],
'ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}},
'hosts': {'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256'}},
'rooms.localhost':{'component':'muc','options':{'muc_room_locking':False,
'muc_room_default_history_length':0,'max_history_messages':0}}}}
config = root/'config.json'
config.write_text(json.dumps(cfg))
for number in range(args.accounts):
subprocess.run([args.binary,'create-user',str(config),f'load-{number}','--password-stdin'],
input='load-test-password\n',text=True,check=True,capture_output=True)
with (root/'server.log').open('w+') as log:
try:
report = asyncio.run(exercise(args,root,config,port,log))
except BaseException:
log.flush();log.seek(0)
print(log.read(),file=sys.stderr)
raise
pathlib.Path(args.output).write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if __name__ == '__main__':
main()
+2 -1
View File
@@ -13,9 +13,10 @@ fn actual_prosody_restores_rust_checkpoint_and_rust_reads_native_freeze() {
let root = tempfile::tempdir().unwrap();
// Let the real room implementation create its normal configuration/index.
let mut muc = Muc::new(root.path().into(), DOMAIN.into());
muc.set_component_options(serde_json::json!({"muc_room_locking":false}), false);
muc.set_defaults(serde_json::json!({"persistent":true,"whois":"moderators"}));
let join = roxmltree::Document::parse(
"<presence to='room@rooms.example.org/Alice'><status>Rust desktop</status></presence>",
"<presence to='room@rooms.example.org/Alice'><x xmlns='http://jabber.org/protocol/muc'/><status>Rust desktop</status></presence>",
)
.unwrap();
muc.handle("alice@example.org/desktop", join.root_element())
+323
View File
@@ -0,0 +1,323 @@
//! Process-independent durable SM slots; network SIGKILL coverage lives in sm_restart_network.py.
use rust_xmpp::sessions::{ConflictPolicy, Hub};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[test]
fn restart_restores_owner_presence_and_only_unacknowledged_output() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, mut rx) = mpsc::channel(8);
let session = hub
.bind("a@x/mobile", "original", tx, Duration::from_secs(60))
.unwrap();
let token;
{
let mut state = session.lock().unwrap();
state.enable(true).unwrap();
token = state.resume_id.clone().unwrap();
state.available = true;
state.priority = 3;
state.presence = Some("<presence><show>away</show></presence>".into());
state.client_id = Some("mobile-client".into());
state.csi.set_inactive(true);
state.mark_handled().unwrap();
state.transmit("first".into()).unwrap();
state.transmit("second".into()).unwrap();
state.acknowledge(1).unwrap();
}
assert_eq!(rx.try_recv().unwrap(), "first");
drop(hub);
let recovered = Hub::persistent(dir.path()).unwrap();
let state = recovered.get("a@x/mobile").unwrap();
{
let state = state.lock().unwrap();
assert!(state.sender.is_none());
assert!(state.available);
assert_eq!(state.priority, 3);
assert_eq!(state.handled, 1);
assert_eq!(state.client_id.as_deref(), Some("mobile-client"));
assert_eq!(
state.pending.iter().cloned().collect::<Vec<_>>(),
["second"]
);
}
let (tx, _rx) = mpsc::channel(8);
assert!(
recovered
.resume("attacker@x", &token, 1, "bad", tx.clone())
.is_err()
);
assert!(
recovered
.resume("a@x", &token, 3, "bad", tx.clone())
.is_err()
);
let (resumed, replay) = recovered
.resume("a@x", &token, 1, "replacement", tx)
.unwrap();
assert!(replay[0].contains("h='1'"));
assert_eq!(replay[1], "second");
assert!(state.lock().unwrap().acknowledge(2).is_err());
resumed.lock().unwrap().acknowledge(2).unwrap();
drop(recovered);
let recovered = Hub::persistent(dir.path()).unwrap();
assert!(
recovered
.get("a@x/mobile")
.unwrap()
.lock()
.unwrap()
.pending
.is_empty()
);
}
#[test]
fn disconnected_deadline_is_fixed_across_recovery() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
session.lock().unwrap().enable(true).unwrap();
hub.disconnect("c", false);
let deadline = session.lock().unwrap().expires.unwrap();
hub.disconnect("c", false);
assert_eq!(session.lock().unwrap().expires.unwrap(), deadline);
drop(hub);
let recovered = Hub::persistent(dir.path()).unwrap();
let recovered_deadline = recovered
.get("a@x/r")
.unwrap()
.lock()
.unwrap()
.expires
.unwrap();
assert!(recovered_deadline <= deadline + Duration::from_millis(2));
assert!(recovered_deadline >= deadline - Duration::from_millis(5));
}
#[test]
fn graceful_close_conflict_account_purge_and_expiration_remove_tokens() {
for action in ["close", "conflict", "purge", "expire"] {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let session = hub
.bind("a@x/r", "c", tx.clone(), Duration::from_secs(60))
.unwrap();
session.lock().unwrap().enable(true).unwrap();
let token = session.lock().unwrap().resume_id.clone().unwrap();
match action {
"close" => {
hub.disconnect("c", true);
}
"purge" => hub.kick_owner("a@x"),
"conflict" => {
hub.bind_resolving(
"a@x/r",
"new",
tx.clone(),
Duration::from_secs(60),
ConflictPolicy::KickOld,
)
.unwrap();
}
_ => {
session.lock().unwrap().expires = Some(Instant::now());
hub.expired();
}
}
drop(hub);
let recovered = Hub::persistent(dir.path()).unwrap();
assert!(
recovered.resume("a@x", &token, 0, "after", tx).is_err(),
"{action}"
);
}
}
#[test]
fn store_has_one_process_owner_and_enforces_output_byte_bound() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
assert!(Hub::persistent(dir.path()).is_err());
let (tx, mut rx) = mpsc::channel(8);
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
let mut state = session.lock().unwrap();
state.enable(true).unwrap();
assert!(state.transmit("x".repeat(8 * 1024 * 1024 + 1)).is_err());
// Fits the payload limit but exceeds the serialized slot limit: persistence
// failure must also roll counters back and keep bytes off the transport.
assert!(state.transmit("x".repeat(8 * 1024 * 1024)).is_err());
assert_eq!(state.sent, 0);
assert!(state.pending.is_empty());
assert!(rx.try_recv().is_err());
}
#[test]
fn shutdown_freeze_retires_old_transport_before_cleanup() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let old = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
old.lock().unwrap().enable(true).unwrap();
old.lock().unwrap().transmit("pending".into()).unwrap();
let token = old.lock().unwrap().resume_id.clone().unwrap();
hub.freeze().unwrap();
assert!(old.lock().unwrap().acknowledge(1).is_err());
assert!(old.lock().unwrap().transmit("stale".into()).is_err());
assert!(hub.disconnect("c", true).is_empty());
drop(hub);
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let (_, replay) = hub.resume("a@x", &token, 0, "fresh", tx).unwrap();
assert_eq!(replay[1], "pending");
}
#[test]
fn durable_counters_wrap_without_accepting_stale_acknowledgements() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
{
let mut state = session.lock().unwrap();
state.enable(true).unwrap();
state.sent = u32::MAX;
state.acked = u32::MAX;
state.handled = u32::MAX;
state.mark_handled().unwrap();
state.transmit("wrapped".into()).unwrap();
}
drop(hub);
let hub = Hub::persistent(dir.path()).unwrap();
let session = hub.get("a@x/r").unwrap();
let mut state = session.lock().unwrap();
assert_eq!(state.handled, 0);
assert_eq!(state.sent, 0);
assert!(state.acknowledge(u32::MAX - 1).is_err());
state.acknowledge(0).unwrap();
assert!(state.pending.is_empty());
}
#[test]
#[cfg(unix)]
fn durable_tokens_and_private_pending_output_have_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
session.lock().unwrap().enable(true).unwrap();
assert_eq!(
std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777,
0o700
);
for entry in std::fs::read_dir(dir.path()).unwrap() {
assert_eq!(
entry.unwrap().metadata().unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn recovery_discards_owned_crash_temps_and_keeps_last_committed_slot() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let session = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
let token;
{
let mut state = session.lock().unwrap();
state.enable(true).unwrap();
state.transmit("committed".into()).unwrap();
token = state.resume_id.clone().unwrap();
}
drop(hub);
let interrupted = dir.path().join(format!("{token}.tmp"));
let orphan = dir.path().join(format!("{}.tmp", uuid::Uuid::new_v4()));
let unknown = dir.path().join("notes.tmp");
let unknown_dir = dir.path().join(format!("{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&interrupted, b"{\"pending\": [\"uncommitted").unwrap();
std::fs::write(&orphan, b"incomplete").unwrap();
std::fs::write(&unknown, b"preserve").unwrap();
std::fs::create_dir(&unknown_dir).unwrap();
let recovered = Hub::persistent(dir.path()).unwrap();
assert!(!interrupted.exists());
assert!(!orphan.exists());
assert!(unknown.exists());
assert!(unknown_dir.is_dir());
let session = recovered.get("a@x/r").unwrap();
let state = session.lock().unwrap();
assert_eq!(state.sent, 1);
assert_eq!(
state.pending.iter().cloned().collect::<Vec<_>>(),
["committed"]
);
}
#[test]
fn corrupt_committed_slot_fails_recovery_closed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(format!("{}.json", uuid::Uuid::new_v4())),
b"{incomplete",
)
.unwrap();
assert!(Hub::persistent(dir.path()).is_err());
}
#[test]
fn independent_hubs_with_identical_connection_names_keep_their_own_slots() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let first = Hub::persistent(first_dir.path()).unwrap();
let second = Hub::persistent(second_dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let a = first
.bind("a@x/r", "c", tx.clone(), Duration::from_secs(60))
.unwrap();
let b = second
.bind("a@x/r", "c", tx, Duration::from_secs(60))
.unwrap();
a.lock().unwrap().enable(true).unwrap();
b.lock().unwrap().enable(true).unwrap();
a.lock().unwrap().transmit("first hub".into()).unwrap();
b.lock().unwrap().transmit("second hub".into()).unwrap();
first.disconnect("c", true);
drop(first);
drop(second);
let first = Hub::persistent(first_dir.path()).unwrap();
let second = Hub::persistent(second_dir.path()).unwrap();
assert!(first.get("a@x/r").is_none());
let b = second.get("a@x/r").unwrap();
assert_eq!(
b.lock().unwrap().pending.front().map(String::as_str),
Some("second hub")
);
}
#[test]
fn session_reference_does_not_keep_store_locked_or_allow_unpersisted_writes() {
let dir = tempfile::tempdir().unwrap();
let hub = Hub::persistent(dir.path()).unwrap();
let (tx, _rx) = mpsc::channel(8);
let old = hub.bind("a@x/r", "c", tx, Duration::from_secs(60)).unwrap();
old.lock().unwrap().enable(true).unwrap();
drop(hub);
let recovered = Hub::persistent(dir.path()).unwrap();
assert!(old.lock().unwrap().checkpoint().is_err());
assert!(old.lock().unwrap().transmit("orphaned".into()).is_err());
assert_eq!(old.lock().unwrap().sent, 0);
assert!(
recovered
.get("a@x/r")
.unwrap()
.lock()
.unwrap()
.pending
.is_empty()
);
}
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Real STARTTLS/SASL SM recovery across abrupt and orderly process restarts.
Usage: python3 tests/sm_restart_network.py BINARY
Pending server stanzas are at-least-once: a received but unacknowledged stanza
must replay. This test does not assert exactly-once client stanza processing.
"""
import json
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from _wire_client import Client, local
from sasl2_bind import Client as InlineClient, SASL2
from muc_restart_network import start, stop, join, iq, occupant
SM = 'urn:xmpp:sm:3'
ROOM = 'durable@rooms.localhost'
def enable(client):
client.send(f"<enable xmlns='{SM}' resume='true'/>")
response = client.take(lambda e: e.tag == f'{{{SM}}}enabled')
assert response.get('resume') == 'true', ET.tostring(response)
assert response.get('id'), ET.tostring(response)
return response.get('id')
def handled(client):
client.send(f"<r xmlns='{SM}'/>")
return int(client.take(lambda e: e.tag == f'{{{SM}}}a').get('h'))
def ping(client, ident):
client.send(f"<iq type='get' id='{ident}'><ping xmlns='urn:xmpp:ping'/></iq>")
return client.iq(ident)
def count_output(client):
"""Track received SM-counted stanzas, including queued room broadcasts."""
client.received = 0
original = client.read
def read():
packet = original()
if local(packet) in ('iq', 'presence', 'message'):
client.received += 1
return packet
client.read = read
def resume(port, cert, token, h, clients, user='alice', inline=False, success=True, password='secret'):
request = f"<resume xmlns='{SM}' previd='{token}' h='{h}'/>"
if inline:
raw = InlineClient(port, cert)
response = raw.auth(request, user=user, password=password)
result = response.find(f'{{{SM}}}' + ('resumed' if success else 'failed'))
assert result is not None, ET.tostring(response)
if success:
assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost/desktop'
# Adapt the TLS socket to the independent stanza parser; inline success
# precedes replay, and the new stream features follow pending stanzas.
client = Client.__new__(Client)
client.sock, client.buf, client.queue = raw.sock, raw.buffer, []
client.jid = 'alice@localhost/desktop'
clients.append(client)
if success:
while b'</stream:features>' not in client.buf:
client.buf += client.sock.recv(65536)
before, after = client.buf.split(b'<stream:features', 1)
_, tail = after.split(b'</stream:features>', 1)
client.buf = before + tail
else:
client = Client(port, cert, user, password, bind=False)
clients.append(client)
client.send(request)
result = client.take(lambda e: e.tag in (f'{{{SM}}}resumed', f'{{{SM}}}failed'))
assert result.tag == f'{{{SM}}}' + ('resumed' if success else 'failed'), ET.tostring(result)
if success:
assert result.get('previd') == token, ET.tostring(result)
return client, result
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-sm-restart-') as tmp:
root = Path(tmp)
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj',
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
cert = root/'cert.pem'
config = root/'config.json'
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [],
'smacks_hibernation_time': 30,
'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'carbons',
'csi_simple', 'smacks', 'ping', 'disco', 'register']},
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed',
'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'),
'certificate': str(cert)}}}, 'rooms.localhost': {'component': 'muc', 'options': {}}}}
config.write_text(json.dumps(cfg))
for user in ('alice', 'bob'):
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'],
input='secret\n', text=True, check=True, capture_output=True)
clients, server = [], None
with (root/'server.log').open('w+') as log:
def connect(user='alice', resource='desktop'):
client = Client(port, cert, user, 'secret', resource)
clients.append(client)
return client
def restart(how):
nonlocal server
if how == 'kill':
server.send_signal(signal.SIGKILL)
assert server.wait(timeout=10) == -signal.SIGKILL
else:
stop(server)
for client in clients:
client.close()
clients.clear()
server = start(binary, config, port, log)
try:
server = start(binary, config, port, log)
bob = connect(user='bob')
bob.available()
alice = connect()
token = enable(alice)
ping(alice, 'acknowledged')
alice.send(f"<a xmlns='{SM}' h='1'/>")
ping(alice, 'pending')
# A handled incoming stanza without a response is preserved too.
alice.send("<message to='bob@localhost'><body>incoming before crash</body></message>")
bob.body('incoming before crash')
assert handled(alice) == 3
restart('kill')
for user, ident, h in [('bob', token, 1), ('alice', 'unknown-token', 1), ('alice', token, 99)]:
resume(port, cert, ident, h, clients, user=user, success=False)
alice, result = resume(port, cert, token, 1, clients)
assert result.get('h') == '3', ET.tostring(result)
replay = alice.iq('pending')
assert replay.get('id') == 'pending'
alice.send(f"<a xmlns='{SM}' h='2'/>")
assert handled(alice) == 3
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
restart('term')
alice, result = resume(port, cert, token, 2, clients, inline=True)
assert result.get('h') == '3', ET.tostring(result)
ping(alice, 'after-orderly')
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
alice.send(f"<a xmlns='{SM}' h='3'/>")
assert handled(alice) == 4
restart('kill')
alice, result = resume(port, cert, token, 3, clients)
assert result.get('h') == '4', ET.tostring(result)
ping(alice, 'after-second-crash')
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
# Only one transport owns a token: the next claim displaces it.
old = alice
alice, result = resume(port, cert, token, 4, clients)
old.sock.settimeout(2)
assert b'conflict' in old.until(b'</stream:stream>')
assert handled(alice) == 5
# Explicitly replacing the same resource invalidates its token.
replacement = connect()
resume(port, cert, token, 4, clients, success=False)
replacement.close()
# SM resumption itself activates recovered MUC membership. No
# fresh available presence or MUC join is sent after restart.
alice = connect(resource='room')
count_output(alice)
token = enable(alice)
occupant(join(alice, ROOM, 'Alice'), 'moderator', 'owner')
iq(alice, 'unlock', f"<query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'/></query>", ROOM)
bob = connect(user='bob', resource='room')
count_output(bob)
bob_token = enable(bob)
occupant(join(bob, ROOM, 'Bob'), 'participant', 'none')
alice.send(f"<message to='{ROOM}' type='groupchat'><body>before room crash</body></message>")
alice.body('before room crash')
bob.body('before room crash')
ping(alice, 'room-fence')
ping(bob, 'bob-room-fence')
alice_count, bob_count = alice.received, bob.received
alice.send(f"<a xmlns='{SM}' h='{alice_count}'/>")
bob.send(f"<a xmlns='{SM}' h='{bob_count}'/>")
assert handled(alice) == 4
assert handled(bob) == 2
restart('kill')
alice, result = resume(port, cert, token, alice_count, clients)
assert result.get('h') == '4'
# Bob's logical SM session remains joined while his transport
# is offline. Alice's fanout must queue before Bob authenticates.
alice.send(f"<message to='{ROOM}' type='groupchat'><body>after room crash</body></message>")
assert alice.body('after room crash').get('from') == ROOM + '/Alice'
assert handled(alice) == 5
# Inspect the committed slot before any Bob authentication.
# A history replay on rejoining is not offline SM delivery.
slots = list((root/'data').rglob(bob_token + '.json'))
assert len(slots) == 1, slots
record = json.loads(slots[0].read_text())
assert record['jid'] == 'bob@localhost/room', record
queued = [ET.fromstring(xml) for xml in record['pending']]
assert any(packet.get('from') == ROOM + '/Alice'
and any(local(node) == 'body' and node.text == 'after room crash'
for node in packet)
for packet in queued), ('room fanout missing from Bob durable pending before authentication', record)
bob, result = resume(port, cert, bob_token, bob_count, clients, user='bob')
assert result.get('h') == '2'
packet = bob.body('after room crash')
assert packet.get('from') == ROOM + '/Alice'
assert packet.find('.//{urn:xmpp:delay}delay') is None, ET.tostring(packet)
restart('term')
# Expired detached sessions stay expired through repeated boot.
cfg['global']['smacks_hibernation_time'] = 1
stop(server)
config.write_text(json.dumps(cfg))
server = start(binary, config, port, log)
short = connect(resource='short')
expired = enable(short)
short.close()
time.sleep(1.3)
restart('kill')
resume(port, cert, expired, 0, clients, success=False)
restart('term')
resume(port, cert, expired, 0, clients, success=False)
# Account deletion must deliver its successful IQ response
# before closing the stream and revoke every old resume token.
stop(server)
cfg['global']['smacks_hibernation_time'] = 300
config.write_text(json.dumps(cfg))
server = start(binary, config, port, log)
removed = connect(resource='remove-account')
revoked = enable(removed)
removed.send("<iq type='set' id='remove-account'><query xmlns='jabber:iq:register'><remove/></query></iq>")
removed.iq('remove-account')
stop(server)
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
input='new-secret\n', text=True, check=True, capture_output=True)
server = start(binary, config, port, log)
resume(port, cert, revoked, 0, clients, password='new-secret', success=False)
finally:
if server is not None and server.poll() is None:
stop(server)
for client in clients:
client.close()
if sys.exc_info()[0] is not None:
log.seek(0)
print(log.read(), file=sys.stderr)
print('PASS: TLS legacy/SASL2 durable SM after SIGKILL/SIGTERM, counters, ack pruning, at-least-once pending replay, owner/token/h rejection, transport/resource conflict, MUC activation/offline fanout, grace expiry and account deletion/recreation revocation')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()))