ролей, членства и ограниченной истории без 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).
126 lines
4.3 KiB
Rust
126 lines
4.3 KiB
Rust
use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage};
|
|
use serde_json::json;
|
|
const HOST: &str = "rooms.example.org";
|
|
const ROOM: &str = "live@rooms.example.org";
|
|
const ALICE: &str = "alice@example.org/laptop";
|
|
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 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,
|
|
ALICE,
|
|
&format!(
|
|
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'><password>private-pass</password></x><status>saved status</status></presence>"
|
|
),
|
|
);
|
|
m
|
|
}
|
|
#[test]
|
|
fn repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let mut m = initial(root.path());
|
|
m.checkpoint_live().unwrap();
|
|
let before = rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap();
|
|
drop(m);
|
|
let mut m = Muc::new(root.path().into(), HOST.into());
|
|
m.prepare_recovery().unwrap();
|
|
assert!(
|
|
m.restore_for_session("alice@example.org/phone")
|
|
.unwrap()
|
|
.replies
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
m.restore_for_session("eve@example.org/laptop")
|
|
.unwrap()
|
|
.replies
|
|
.is_empty()
|
|
);
|
|
m.checkpoint_live().unwrap();
|
|
assert_eq!(
|
|
rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap(),
|
|
before
|
|
);
|
|
drop(m);
|
|
let mut m = Muc::new(root.path().into(), HOST.into());
|
|
m.prepare_recovery().unwrap();
|
|
let restored = m.restore_for_session(ALICE).unwrap();
|
|
assert!(
|
|
restored
|
|
.replies
|
|
.iter()
|
|
.any(|p| p.contains("saved status") && p.contains("110"))
|
|
);
|
|
assert!(restored.replies.iter().all(|p| !p.contains("private-pass")));
|
|
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
|
|
// 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
|
|
.iter()
|
|
.any(|p| p.contains("110"))
|
|
);
|
|
}
|
|
#[test]
|
|
fn changed_configuration_invalidates_auto_recovery_without_disclosing_password() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let mut m = initial(root.path());
|
|
m.checkpoint_live().unwrap();
|
|
drop(m);
|
|
storage::update(root.path(), HOST, Some("live"), "config", |v| {
|
|
let mut data = v.get("_data").cloned().unwrap();
|
|
data.set("password", "new-private-password".into());
|
|
v.set("_data", data);
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
let mut m = Muc::new(root.path().into(), HOST.into());
|
|
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'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
|
),
|
|
);
|
|
assert!(denied.replies.iter().any(|p| p.contains("not-authorized")));
|
|
assert!(
|
|
denied
|
|
.replies
|
|
.iter()
|
|
.all(|p| !p.contains("new-private-password"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
|
),
|
|
);
|
|
m.checkpoint_live().unwrap();
|
|
drop(m);
|
|
let mut m = Muc::new(root.path().into(), HOST.into());
|
|
m.prepare_recovery().unwrap();
|
|
m.disconnect("unrelated@example.org/mobile");
|
|
let restored = m.restore_for_session(ALICE).unwrap();
|
|
assert!(restored.replies.iter().any(|p| p.contains("110")));
|
|
}
|