- 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:
1 parent
b30fc524d8
commit
7f60661eef
41 files changed
+9804
-452
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# 0.8.0
|
||||
|
||||
- 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).
|
||||
Generated
+1
-1
@@ -1111,7 +1111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-xmpp"
|
||||
version = "0.7.9"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-xmpp"
|
||||
version = "0.7.9"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
description = "Experimental Rust XMPP server with Prosody-compatible storage"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Rust XMPP 0.7.9
|
||||
# Rust XMPP 0.8.0
|
||||
|
||||
Сервер на Rust, разрабатываемый для замены **Prosody 13.0 nightly 100,
|
||||
cafd74b9c5ea** с предоставленными модулями. Читает существующий Lua-конфиг,
|
||||
@@ -16,7 +16,9 @@ PEP/pubsub, закладки, offline, carbons, SM, push, TLS-федерация
|
||||
Нестандартные HTTP-пути и TLS reverse proxy: [docs/HTTP-PROXY.md](docs/HTTP-PROXY.md).
|
||||
Пример для слияния с действующим конфигом: `examples/reverse-proxy.fragment.yaml`.
|
||||
|
||||
MUC-расширения и условия восстановления участников: [docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).
|
||||
MUC и восстановление после сбоя, включая SM resume:
|
||||
[docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).
|
||||
Изменения 0.8.0: [CHANGELOG-0.8.0.md](CHANGELOG-0.8.0.md).
|
||||
|
||||
Встроенный http_altconnect: [docs/HTTP-ALTCONNECT.md](docs/HTTP-ALTCONNECT.md).
|
||||
|
||||
@@ -150,7 +152,8 @@ inline SM/CSI/Carbons и MAM metadata. Добавьте его в modules_enable
|
||||
с `sasl2` только если эта функция нужна. Исходный конфиг по-прежнему работает
|
||||
без включения нового модуля. Ресурс генерируется случайно; прежние соединения
|
||||
того же аккаунта с тем же валидным UUIDv4 user-agent завершаются после успешного
|
||||
входа. Inline возобновление SM через SASL2 пока не объявляется.
|
||||
входа. Inline возобновление SM через SASL2 поддерживается, в том числе после
|
||||
перезапуска при наличии действующей сохранённой SM-сессии.
|
||||
|
||||
## PEP, push и pre-authenticated roster
|
||||
|
||||
@@ -206,3 +209,26 @@ gateway с multipart streaming описаны в [docs/HTTP-UPLOAD.md](docs/HTTP
|
||||
карантин неопределённого completion. Реальные 10 GiB через MinIO проверены с
|
||||
описанной адаптацией тестовой среды. Изменения и границы подтверждения —
|
||||
[CHANGELOG-0.7.9.md](CHANGELOG-0.7.9.md).
|
||||
|
||||
## MUC и SM после сбоя в 0.8.0
|
||||
|
||||
MUC фиксирует конфигурацию, роли, членство и ограниченную историю без MAM
|
||||
до публикации соответствующего ответа. Последний сохранённый журнал позволяет
|
||||
восстановить persistent и временные комнаты после аварийной остановки.
|
||||
Heartbeat обновляет живое состояние; срок уже ожидающего восстановления
|
||||
не продлевается повторным запуском. `muc_recovery_grace_period` задаётся
|
||||
в секундах: по умолчанию 300, пределы 1..604800.
|
||||
|
||||
Действующие приватные SM-записи восстанавливают логические сессии и MUC
|
||||
до открытия listeners, поэтому offline-доставка может попасть в сохранённую
|
||||
очередь. XEP-0198 resume через legacy SASL или SASL2 требует нового
|
||||
TLS/SASL-транспорта и токена того же владельца; TCP/TLS-сокеты не сохраняются.
|
||||
Без действующей SM-записи MUC ожидает новую аутентификацию точного полного JID
|
||||
и available presence либо обычный явный MUC-вход.
|
||||
|
||||
Native-файлы экспортируются для Prosody; источником согласованного crash
|
||||
recovery Rust служит собственный журнал. Перед откатом ознакомьтесь с
|
||||
[порядком переключения](docs/MUC-RECOVERY.md). Произвольные community Lua-модули
|
||||
не выполняются; неизвестные явно включённые MUC-модули отклоняются при запуске.
|
||||
Результаты проверки — в
|
||||
[docs/VALIDATION-0.8.0.md](docs/VALIDATION-0.8.0.md).
|
||||
+29
-4
@@ -2,8 +2,10 @@
|
||||
|
||||
Цель — конкретная Prosody `cafd74b9c5ea` и предоставленные community/custom
|
||||
модули ревизии `dddb6c34b964`, а не все существующие модули Prosody.
|
||||
Реализация содержит рабочие обработчики всех явно включённых имён модулей
|
||||
из конфига. Это не означает полного совпадения их поведения.
|
||||
Известные встроенные и выбранные модули предоставленной установки отображаются
|
||||
на обработчики Rust. Произвольные Lua/community-модули не исполняются;
|
||||
неизвестные явно включённые MUC-модули отклоняются при запуске.
|
||||
Это не означает полного совпадения поведения с Prosody.
|
||||
|
||||
| Область | Что реализовано | Существенные пределы |
|
||||
|---|---|---|
|
||||
@@ -12,10 +14,10 @@
|
||||
| Аккаунты, saslauth | internal_hashed SHA-1/SHA-256, SASLprep, PLAIN внутри TLS, SCRAM и PLUS с tls-exporter/tls-server-end-point, SSDP, SASL2 | Только internal_hashed; опциональный sasl2_bind2 (alias bind2) поддерживает inline bind/SM/CSI/Carbons. SASL2 inline SM resume и takeover живого транспорта реализованы; MAM metadata при Bind2 читает весь архив синхронно |
|
||||
| Сообщения и presence | локальная маршрутизация, приоритеты ресурсов, контакты/подписки/probes, offline, carbons, блокировки | Полная совместимость всех пограничных сценариев маршрутизации ещё не подтверждена |
|
||||
| MAM | личный и комнатный MAM2, prefs, формы, RSM, фильтры времени/контакта/ID, extended queries, metadata, stanza-id, expiry | Нет доказательства совпадения каждой ошибки и порядка результатов при всех комбинациях запроса |
|
||||
| MUC | комнаты, несколько ресурсов, роли/affiliations, owner/admin, конфигурация, приглашения, история без MAM и MAM, occupant-id/avatar, hats, язык, PM policies, presence broadcast, native metadata/state checkpoints и восстановление после штатной остановки | Восстановление после повторной аутентификации точного полного JID в300-секундном grace и при неизменной конфигурации. Нет сохранения TCP/SM между процессами и checkpoint на SIGKILL. Произвольные community-расширения не запускаются; MUC-RECOVERY.md |
|
||||
| MUC | комнаты, несколько ресурсов, роли/affiliations, owner/admin, конфигурация, приглашения, история без MAM и MAM, occupant-id/avatar, hats, PM/presence policies, регистрация, административные ad-hoc команды; журнал конфигурации, членства и истории до ответа, crash recovery persistent/temporary комнат | Rust-журнал авторитетен, native config/state — экспорт. Без SM восстановление требует новой аутентификации точного полного JID; grace по умолчанию 300 с, `muc_recovery_grace_period` 1..604800. Действующая приватная SM-запись восстанавливает логическое членство до listeners. Произвольные Lua-модули не запускаются; [MUC-RECOVERY.md](MUC-RECOVERY.md) |
|
||||
| PEP/pubsub | persistent nodes/items, ACL, subscriptions, publish/retract/purge/delete, owner/configuration, caps, OMEMO XML как payload | Не все необязательные политики узлов Prosody реализованы; OMEMO шифруют клиенты, криптография OMEMO не часть сервера |
|
||||
| private/bookmarks/vCard | private XML, старые и новые закладки, vCard/temp-vCard4 и avatar-переходы | Межклиентное поведение нужно дополнительно проверить настоящими клиентами |
|
||||
| SM/push | acknowledgements, resumable sessions, replay, ограниченные очереди, native регистрации push и ответы сервисов | Есть coalescing и watchdog первого push; полный цикл с настоящим APNs/FCM шлюзом не проверен. Состояние живых сессий не переживает перезапуск процесса |
|
||||
| SM/push | acknowledgements после обработки, resumable XEP-0198 sessions, replay, ограниченные очереди, приватный durable snapshot, heartbeat и shutdown checkpoint; legacy SASL/SASL2 resume после restart; native регистрации push | Нужны новый TLS/SASL-транспорт и токен того же владельца; TCP/TLS-сокеты не сохраняются. SM-снимок не native Prosody: 256 MiB суммарно, 8 MiB/4096 stanza на сессию. При неопределённой отправке возможен replay. Есть coalescing и watchdog первого push; реальный APNs/FCM цикл не подтверждён |
|
||||
| CSI | active/inactive, ограниченный буфер, native importance rules, delay, flush при клиентской активности и grace period | Событие socket drain приближено завершением записи; необязательные расширенные XPath-фильтры поддержаны не полностью |
|
||||
| Федерация | входящие/исходящие STARTTLS, CA/DNS-ID, SASL EXTERNAL, DNS SRV, проверка домена каждого stanza, ограниченный пул соединений | Нет dialback/DANE/OtherName-only/SRV-ID/bidi/direct TLS |
|
||||
| HTTP upload | Несколько XEP-0363 components, Host/path isolation, local/S3 gateway, multipart/SigV4, streaming PUT, GET/HEAD/range/conditional, квоты/expiry, native metadata/JWT; offline local↔S3/S3↔S3 migration с SHA-256/resume; intent/exact-key recovery и quarantine; реальный 10 GiB PUT/GET через MinIO проверен | MinIO single-node с описанным netlink adapter тестовой среды; AWS/distributed deployment не проверены. Миграция требует остановки сервера и временного места под один файл; source сохраняется. Потерянные ownership metadata и неопределённый Complete требуют отдельного разбора; HTTP-UPLOAD.md |
|
||||
@@ -141,3 +143,26 @@ HTTPS учитывает https_interfaces (включая* → IPv4+IPv6), HTTP
|
||||
PEP production причина по-прежнему не доказана; новая диагностика различает
|
||||
parser-valid IDs и форму уведомлений. Нет подтверждения Android UI, живого
|
||||
production WSS, DNSSEC signing или полной замены Prosody.
|
||||
|
||||
## Восстановление MUC и SM в 0.8.0
|
||||
|
||||
MUC-журнал хранит последнее согласованное поколение конфигурации, ролей,
|
||||
членства и ограниченной истории, включая временные комнаты без MAM.
|
||||
Неудачная фиксация откатывает изменение комнаты; внешний MAM-архив и прочие
|
||||
побочные эффекты не составляют единую транзакцию с журналом. Heartbeat обновляет
|
||||
живое состояние, но не продлевает deadline уже ожидающих записей.
|
||||
|
||||
При действующей сохранённой SM-сессии bootstrap восстанавливает логическую
|
||||
сессию и связанное MUC-членство до начала приёма соединений. Offline-доставка
|
||||
попадает в SM-очередь, а не в несуществующий сокет. Для сетевого resume нужны
|
||||
новая TLS/SASL-аутентификация и действующий токен владельца. Без такой SM-записи
|
||||
действует обычное повторное подтверждение полного JID и MUC admission.
|
||||
|
||||
Учтены `muc_max_rooms`, `restrict_room_creation`,
|
||||
`allow_unaffiliated_register`, `enforce_registered_nickname`,
|
||||
`muc_registration_include_form`, `muc_tombstone_expiry` и component-admin
|
||||
ad-hoc destroy/set-affiliation. Общие и host `modules_disabled` применяются
|
||||
к vCard и occupant-id. Эти встроенные обработчики не запускают Lua-плагины.
|
||||
Ограничения журнала, native-экспорта и отката:
|
||||
[MUC-RECOVERY.md](MUC-RECOVERY.md). Итоги проверки:
|
||||
[VALIDATION-0.8.0.md](VALIDATION-0.8.0.md).
|
||||
+129
-55
@@ -1,68 +1,142 @@
|
||||
# MUC: расширения и восстановление0.7.6
|
||||
# MUC: протокол и восстановление 0.8.0
|
||||
|
||||
Сопоставление выполнено с предоставленной Prosody cafd74b9c5ea: plugins/muc
|
||||
и mod_muc_mam.lua. Поддержка относится к встроенным расширениям этой ревизии,
|
||||
а не к произвольным community Lua plugins.
|
||||
Реализация сопоставлена с предоставленной Prosody `cafd74b9c5ea`, прежде всего
|
||||
`plugins/muc` и `mod_muc_mam.lua`. Известные встроенные возможности перенесены
|
||||
в Rust; произвольный Lua-код и community-модули Prosody не исполняются.
|
||||
Неизвестный явно включённый MUC-модуль отклоняется при запуске, а не молча
|
||||
игнорируется. Список `modules_disabled` применяется с учётом общих и host-настроек,
|
||||
в том числе для `muc_vcard` и `muc_occupant_id`.
|
||||
|
||||
Добавлены hats из affiliation data, язык комнаты, политики PM/модераторских PM,
|
||||
фильтрация presence по ролям и формы конфигурации. Поддельные occupant-id/hats
|
||||
и MUC user identity из клиентского сообщения удаляются. muc_vcard и
|
||||
muc_occupant_id можно отключить. Настройки MAM включают muc_log_all_rooms,
|
||||
muc_log_presences, muc_log_include_total; presence хранится отдельными событиями,
|
||||
обычный MAM-запрос выбирает groupchat сообщения как в native.
|
||||
## Что сохраняется
|
||||
|
||||
История работает без MAM: ограниченный буфер, maxstanzas/maxchars/seconds/since,
|
||||
delay и раздельный subject. Архив MAM продолжает поддерживать историю persistent
|
||||
комнат. Удаление комнаты очищает её MAM-архив.
|
||||
MUC теперь записывает единый журнал компонента `rust_muc_checkpoint`:
|
||||
конфигурацию комнат, affiliations, текущие nick и роли участников, безопасные
|
||||
поля presence и ограниченную историю обсуждения. Это относится к persistent
|
||||
и временным комнатам и работает без MAM. Журнал содержит согласованное поколение
|
||||
состояния; запись через временный файл, синхронизацию и rename предшествует
|
||||
возврату результата операции и публикации соответствующих MUC-ответов.
|
||||
При ошибке сохранения изменение состояния комнаты откатывается в памяти.
|
||||
|
||||
## Как восстанавливается членство
|
||||
Сохранение выполняется при изменениях членства, конфигурации, affiliations,
|
||||
ролей и истории, а также при штатной остановке. Поэтому восстановление больше
|
||||
не зависит только от SIGTERM: после SIGKILL используется последнее успешно
|
||||
зафиксированное поколение. Heartbeat обновляет отметки живых участников даже
|
||||
в тихой комнате. Неудачный heartbeat фиксируется в журнале сервера; состояние,
|
||||
которое не удалось сохранить, нельзя считать защищённым от следующего сбоя.
|
||||
|
||||
При SIGTERM/Ctrl-C сервер сначала сохраняет метаданные комнаты и native
|
||||
`state/<roomnode>.dat`, затем завершает соединения. Сохраняются nick, полный JID,
|
||||
роль, show/status/priority и последнее сообщение истории. Это относится также
|
||||
к временным комнатам с участниками. Транспортное отключение во время shutdown
|
||||
не считается добровольным выходом из комнаты и не удаляет checkpoint.
|
||||
Обычная история без MAM сохраняет порядок, время сообщений и ограничения
|
||||
`maxstanzas`, `maxchars`, `seconds`, `since`; subject хранится отдельно в
|
||||
конфигурации комнаты. MAM остаётся самостоятельным архивом и не превращается
|
||||
в часть одной транзакции со всеми внешними эффектами. Presence-события MAM
|
||||
и groupchat-запросы остаются раздельными.
|
||||
|
||||
После запуска сохранённые участники ожидают восстановления до300 секунд.
|
||||
Они не становятся активными адресатами маршрутизации автоматически. Когда
|
||||
тот же полный JID проходит SASL, bind и отправляет общую available presence,
|
||||
сервер повторно проверяет комнату и восстанавливает членство, nick, роль и
|
||||
presence. Одного совпадения bare JID или resource недостаточно.
|
||||
Клиент может по-прежнему явно войти обычной MUC presence.
|
||||
## Возвращение участников
|
||||
|
||||
Отпечаток конфигурации должен совпасть с сохранённым: изменение пароля или
|
||||
прав комнаты отменяет автоматическое восстановление; требуется обычный вход
|
||||
с актуальными параметрами. В presence state не записываются MUC-пароли и
|
||||
произвольные расширения. Пароль комнаты остаётся в исходной native config,
|
||||
как и до этого. Тот же checkpoint не продлевает grace при повторных перезапусках. Успешно использованная запись восстановления
|
||||
погашается на диске до отправки результата клиенту, чтобы авария не вернула
|
||||
старую роль после выхода или снятия модератора.
|
||||
Неактивированные временные комнаты очищаются после истечения срока.
|
||||
Без сохранённой SM-сессии после запуска членство становится ожидающей записью, а не активным
|
||||
адресатом маршрутизации. Автоматический MUC-вход возможен после новой
|
||||
аутентификации, bind того же полного JID и общей available presence. Bare JID
|
||||
или чужой resource не заменяют эту проверку. Обычный явный вход с MUC `<x/>`
|
||||
тоже доступен; актуальные ограничения комнаты проверяются повторно.
|
||||
|
||||
Native формат читается/пишется и проверяется настоящей Prosody в обе стороны.
|
||||
Для автоматического восстановления используются checkpoint с отпечатком и
|
||||
временем Rust; старый native state без этих полей сам по себе не является
|
||||
разрешением на автоматический вход. Обычное повторное присоединение доступно.
|
||||
Срок ожидания задаёт `muc_recovery_grace_period` в секундах: по умолчанию `300`,
|
||||
диапазон `1..604800` (значения ограничиваются этими пределами). Живое членство
|
||||
получает свежую отметку при фиксации и heartbeat. Уже ожидающая запись имеет
|
||||
фиксированный срок: запросы, неудачные попытки и повторные перезапуски его
|
||||
не продлевают. Неактивированные временные комнаты удаляются после истечения
|
||||
срока.
|
||||
|
||||
Это восстановление XMPP-членства в комнате: старые TCP/TLS-сокеты после
|
||||
остановки процесса не существуют. SM resume между процессами не реализован.
|
||||
Аварийный SIGKILL/отказ питания не создаёт новый checkpoint: гарантируется
|
||||
проверенный штатный shutdown, а не полный crash recovery.
|
||||
Смена пароля, affiliations или иной конфигурации отменяет старые ожидающие
|
||||
разрешения. Подтверждённые изменения роли, явный выход и удаление комнаты
|
||||
попадают в журнал до ответа, поэтому следующая авария не должна возвращать
|
||||
предыдущую роль или прежний nick. Успешное восстановление создаёт текущее
|
||||
членство: при следующей аварии восстанавливается его последняя сохранённая
|
||||
версия, а не навсегда погашенная старая запись.
|
||||
|
||||
История ограничена `max_history_messages`: по умолчанию100, допустимый внутренний
|
||||
предел4096. Состояние комнаты ограничено4096 ресурсами. Необязательные presence
|
||||
поля слишком большого размера и последнее сообщение больше16KiB не включаются
|
||||
в checkpoint; это не мешает сохранить членство. Текущие сообщения доставляются
|
||||
по обычным транспортным лимитам.
|
||||
В checkpoint presence входят только `show`, `status`, `priority`.
|
||||
MUC-пароли и произвольные расширения presence туда не копируются. При живом
|
||||
обмене обычные caps, avatar и idle сохраняются; присланные клиентом MUC identity,
|
||||
occupant-id и hats фильтруются. Пароль комнаты остаётся в её конфигурации.
|
||||
|
||||
## Проверка и границы
|
||||
Если имеется действующая приватная SM-запись Rust, логическая сессия продолжает
|
||||
членство без транспорта. При запуске повторно проверяются наличие и состояние
|
||||
аккаунта и текущие ограничения комнаты. Маршрутизация восстанавливается до
|
||||
приёма клиентов: сообщения для ещё не подключившегося участника попадают в его
|
||||
сохранённую SM-очередь. Синтетические join presence и история повторно не
|
||||
отправляются. Доставка по сети требует новой TLS/SASL-аутентификации и корректного
|
||||
SM-токена того же владельца; native MUC-файлы сами по себе этого не разрешают.
|
||||
|
||||
Автоматические тесты используют отдельные временные данные. Сетевой сценарий
|
||||
останавливает процесс с живыми клиентскими socket, запускает его повторно,
|
||||
проверяет persistent и temporary комнаты, запрет чужих identity/resource,
|
||||
восстановленные роли/presence и доставку сообщений. Native oracle проверяет
|
||||
перенос state с несколькими ресурсами туда и обратно.
|
||||
## Native-файлы и переход между серверами
|
||||
|
||||
Полная production-миграция, непрерывные федеративные reconnect и все community
|
||||
расширения не проверены. Конкретные результаты — VALIDATION.md; общие пределы
|
||||
сервера — COMPATIBILITY.md. Реальные клиенты должны повторно аутентифицироваться.
|
||||
Native config, persistent index и `state/<roomnode>.dat` читаются и экспортируются
|
||||
для совместимости с Prosody. При работе Rust источником согласованного
|
||||
восстановления служит его журнал, а native-файлы являются экспортом после commit.
|
||||
Устаревший экспорт после сбоя не должен возвращать предыдущее членство.
|
||||
Native state без подтверждающих Rust-метаданных сам по себе не разрешает
|
||||
автоматический вход.
|
||||
|
||||
Редактирование native-конфигурации администратором обнаруживается по отпечатку:
|
||||
старые разрешения восстановления и история, привязанная к прежней политике,
|
||||
отбрасываются; удаление ранее экспортированной конфигурации отменяет её
|
||||
восстановление. Это не универсальное слияние параллельных записей двух серверов.
|
||||
|
||||
Для отката на Prosody остановите Rust и сохраните резервную копию всего
|
||||
`data_path`. Prosody использует native-экспорт и не читает Rust-журнал. Перед
|
||||
возвратом с Prosody на Rust согласуйте данные и устраните устаревший
|
||||
`rust_muc_checkpoint` в остановленном экземпляре: иначе неизменившиеся native
|
||||
файлы могут быть приняты за экспорт предыдущего поколения Rust. Удаление
|
||||
журнала означает отказ от его crash recovery, включая историю временных комнат.
|
||||
Не запускайте оба сервера с общей записываемой директорией.
|
||||
|
||||
## Поддерживаемые политики
|
||||
|
||||
- Доступны hats из affiliation data, язык комнаты, роли presence broadcast,
|
||||
политики PM и модераторских PM, формы конфигурации и offline affiliation presence.
|
||||
- `muc_vcard` и `muc_occupant_id` управляют соответствующими возможностями.
|
||||
MAM учитывает `muc_log_all_rooms`, `muc_log_presences`, `muc_log_include_total`.
|
||||
- `restrict_room_creation`: `false` разрешает создание по умолчанию, `true`
|
||||
ограничивает его настроенными администраторами, `"local"` — локальными
|
||||
зарегистрированными пользователями и администраторами. `muc_max_rooms`
|
||||
ограничивает число комнат; существующее членство не требует повторного
|
||||
разрешения на создание.
|
||||
- Регистрация учитывает `allow_unaffiliated_register`,
|
||||
`enforce_registered_nickname`, `muc_registration_include_form`.
|
||||
Срок tombstone задаёт `muc_tombstone_expiry` (по умолчанию 31 день).
|
||||
- Администраторам компонента доступны ad-hoc команды
|
||||
`http://prosody.im/protocol/muc#destroy` и
|
||||
`http://prosody.im/protocol/muc#set-affiliation`. Полномочия проверяются
|
||||
на каждом шаге; изменения проходят обычный механизм фиксации MUC.
|
||||
|
||||
Presence probe не создаёт участника и возвращает сведения только допущенному
|
||||
участнику. Новый вход без MUC `<x/>` отклоняется; неизвестные типы presence
|
||||
не превращаются во вход. Presence errors исключают соответствующую сессию;
|
||||
message errors делают это для native-набора ошибок доставки. Последняя сессия
|
||||
получает статус `333`; остальные ошибки приватного сообщения могут быть
|
||||
переданы адресату. Сообщение с `subject` и `body` либо `thread` остаётся обычным
|
||||
сообщением и не меняет тему комнаты.
|
||||
|
||||
## Границы и проверка
|
||||
|
||||
Старые TCP/TLS-сокеты после остановки процесса не существуют. Восстановление
|
||||
членства MUC и XEP-0198 resume — отдельные механизмы. В 0.8.0 Hub сохраняет resumable
|
||||
SM-сессии, счётчики и очередь неподтверждённых stanza. Resume проходит на новом
|
||||
защищённом аутентифицированном транспорте, включая legacy SASL и SASL2;
|
||||
успешный SM resume восстанавливает MUC без дополнительной available presence.
|
||||
Входящая stanza учитывается в handled-счётчике после обработки. Heartbeat и
|
||||
штатная остановка сохраняют снимок; offline deadline не продлевается простыми
|
||||
проверками или повторными запусками.
|
||||
|
||||
SM-снимок приватный: предел 256 MiB суммарно, 8 MiB и 4096 stanza на сессию.
|
||||
Он не является native-форматом Prosody. Нельзя обещать сохранение сокета или
|
||||
ровно одну доставку при неопределённом результате сетевой отправки.
|
||||
|
||||
Буфер истории ограничен `max_history_messages` (по умолчанию 100, предел 4096),
|
||||
состояние комнаты — 4096 ресурсами. Большие необязательные поля presence
|
||||
исключаются из checkpoint; отдельные сообщения истории больше 16 KiB не
|
||||
восстанавливаются. Общий checkpoint компонента ограничен 16 MiB: превышение
|
||||
лимита приводит к ошибке записи, а не к успешному подтверждению несохранённого
|
||||
изменения. Долговечность предполагает исправный диск и файловую систему,
|
||||
соблюдающую синхронизацию записи.
|
||||
|
||||
Команды и результаты проверки версии 0.8.0 публикуются в
|
||||
[VALIDATION-0.8.0.md](VALIDATION-0.8.0.md). Полный прогон завершён успешно. Общие границы совместимости:
|
||||
[COMPATIBILITY.md](COMPATIBILITY.md).
|
||||
+25
-16
@@ -14,13 +14,13 @@ b1018e1b279c89f6cf14b4017e4f1b14e9165afef2f668cec6ac8bbe0c710d85 scripts/integr
|
||||
e08ac6136cce27cda2ddbd510da0f25750766ab0150a9f4b8f87c953276d16e9 scripts/prosody_compat/oracle.lua
|
||||
2c7a2c946885205e9df38b97305073a8ac72f58ac8ff84fc6f9af320c35bbc96 scripts/prosody_compat/verify.sh
|
||||
1b9ed063daf3e0320819b4fe0135c96dd27e49877044aa2f5093b8ab4fbda41d scripts/smoke.py
|
||||
0b2f6209a36fe9783b83d43396da26862c89cad1da1483ae49a79800e31982c1 scripts/verify-local.sh
|
||||
caa74bdd83f0b952b875f613cdbd238d7f903408c2176d54178ce18994b53e95 scripts/verify-local.sh
|
||||
a2e47f547ab9349f84ee6c4f8e51c147b7110b41a82c17cdb938027a84d82ef3 src/account_modules.rs
|
||||
5637f816586f0613b83e14d0ed478bd539f325b70ccbfc9fdf893adbe494453d src/admin.rs
|
||||
89d7e29d8f8daab614c9c836864b166fdaa324ca035c2d3d5f95639c24b2a9e1 src/archive.rs
|
||||
e15b079a420227ee59e3b7ac305783c51eb59d76a8fb5dd12760547b0f812107 src/auth.rs
|
||||
83ac35a4c9d20debf006fe80fba8e423c09806fb2526ff384b188ca28d612a5e src/config.rs
|
||||
6574ad70d5c60c4acf7e01e66dadaa7cd2d09d3b184a611418aeb271f27ebc3c src/csi.rs
|
||||
55f8e68d0d41a81d2254a0c5aef4146488127f771c5b71ccd2e23d17dd1e8e79 src/csi.rs
|
||||
834ebe4a1f2d46ab932c14c0a2c7be3375873a71ef56026d372c86e60ef041fc src/data_lock.rs
|
||||
1c8aa5cb816a32a0f9f24589ac00beba27544a83c3d9aad771523a1d1cbcd4c8 src/extras.rs
|
||||
dca8959effb123b94e1fbd42c561a79c721178c4b612a004db9f9fccb3b8950d src/federation.rs
|
||||
@@ -29,23 +29,25 @@ d561a6bd4db079cfb1ef3bb7ae75f23e6080c5d5705583774f6c5f1ca6ed4420 src/http_confi
|
||||
3fa73ae129fe552bd7de7afca0617a1c18bf4d3b36a39023bfec036bca17ffc5 src/http_proxy.rs
|
||||
f4f87f5c20cb0602d803e86a22c209756c3b1f22c532743ec7e0dbf5bb174a0e src/http_service.rs
|
||||
c604a0a764ffac6bae0c9aa15c9ef7022d35cb1f8c9159eb66257114445621c5 src/http_transport.rs
|
||||
c5b7bcc0d08e48fd3805d6571428bb83b7fdc6f6e23a65bf689f079ad87bc32a src/lib.rs
|
||||
5b27b6c719537af919bd6fe089574eba5e2c7bbe0d32249164914a4bb57a1427 src/lib.rs
|
||||
a21e1119ea0271b63935e92eb106ab6076f0035e6d3a06c21a352f152b9b37db src/logging.rs
|
||||
58ce2a9daa36ac26b39c8f263a16f4fa8cc4e57c91bbbf5b24a6aad3f1a326e0 src/lua.rs
|
||||
a264e2765a31c8c096462551aca229530cad356e39e338370d1a0bdd97860680 src/main.rs
|
||||
71d5d185900b9cde190e10e2f28371cd5042d98b5900b4385d823f724234fced src/mam.rs
|
||||
0f780d95523b41c4ae242bd4a1fb6b65ff57826dca6d23ac73a1a1459609ec0a src/muc.rs
|
||||
d8263eb28f5cdff362512a6b13217aa8f7574f4a3d54b7bcb6ba7930f8c353d8 src/muc_history.rs
|
||||
4c151ffcdc36e4a3e680f859d4dc7ba16b0162d4f1fe6fb470df4c5e21197e5a src/muc_recovery.rs
|
||||
d8b69775bcdb01597bcaffa6123c636a226922f349beaa274fe77ff1e36bc5ef src/muc.rs
|
||||
c7ba46a140b9dce65cc573176265d6f77049ae657a70ffe59611a1c0f3ef89af src/muc_admin.rs
|
||||
ed2f07105ffeb80396aaf91c7a1fe9a8dc88d17235d03a0796372c1797c1506a src/muc_history.rs
|
||||
636f6ea7ad7cc98390ac775a93b469d2f3e0f9e138be5bd1e256cad5037c7183 src/muc_recovery.rs
|
||||
3252884252158e1c4bd58922b97733f434c7d28baf059e63267632a3290f660e src/object_store.rs
|
||||
7d7114a22ef3bf02f9caaf578f0a50d4f75b4dcd4b335b4cb8425d9716c97e71 src/pep_diagnostics.rs
|
||||
f09a6479b76c9c601a169b5f1f9cf1364e82df5ae27e4bab8d1b1428b5cad457 src/preauth.rs
|
||||
a7333b31a57780c57f2d30620cdfaeb7b1215808865960bb7e5b595d3ad98f7c src/protocol.rs
|
||||
761e29d6431fac5c2a4a24bcfca1f393949a5c55585fd6b0dd7a4f6e9f94a10d src/pubsub.rs
|
||||
0d7fec83ff188f9092b1945951f2879d846d5f6aef1e657671e8c4eeb4beb226 src/protocol.rs
|
||||
8dbb7a6b43a5f0562a775df42a959ab8badc466135a8b32da86cd79615b5acbf src/pubsub.rs
|
||||
913cd033f60a68f6c7cf9b3adbaf76db1c5c9bc88a0599196d373b16b64c0a1e src/push.rs
|
||||
bec477f2520af6a9d7ca54e43eb12158b53e3cf09057653e504dfdeab956885e src/sasl.rs
|
||||
10f24a2e8e776c7aa2475913e15b01b0f4e57f7a9de92660bafeddd912101d26 src/server.rs
|
||||
894f11389bfc3fddc3508396d106146261376999f7adcef5a049458f6b4028cf src/sessions.rs
|
||||
9cfef150f88a08ee0cfb33b63b621e5ecd058daa08f19119379c099f9c7f7e7a src/server.rs
|
||||
c7450f9058528e3030df559840f87dae591a2b88be8503872956e9b78435d916 src/session_store.rs
|
||||
a4e6faac3b5ab9b7e6e2c586387cb32d18a01fbd1ef65302cbc92ccbce934044 src/sessions.rs
|
||||
71d7ce1036a091d8c27016ea7ce4cffedf904dfca9d1cc4891b7dc66a1e06a4e src/storage.rs
|
||||
462b00edb2c1e02dd34524bafe4f82b5ef902028b44310ba31b085d31f4dc2aa src/tls_binding.rs
|
||||
a78592aa79d8f01c22af1457d0c3f5922863ccd3e8ebb1aa05e043cc44579b8a src/upload_migration.rs
|
||||
@@ -74,16 +76,21 @@ da2fc65e931ec74fbcdcd26d04f2cb073e2dc1e354139135166c79580973a477 tests/iq_error
|
||||
05160f17f8098466a67c9b069c165dc9d94a84ab03a49896169f110baf8e869a tests/logging.rs
|
||||
dfc3429e3e755d4c99ec1bd8ee6fb0f1f6aaf86414d5adaca7e9e3b18c7fe3f7 tests/logging_network.py
|
||||
329ccbd4faee6cbbfa92d2fa0248844721b7ffc033baae4ec2570e9893f5df9f tests/message_routing.rs
|
||||
dafc2dd1e1d9e860482eb5bdf98c293842dea6a2790d27b94bf1885be3324690 tests/muc_access.rs
|
||||
c28c7e9128cd1483df90dbccc7af0d756a5edc813bdff604b4560a349a844754 tests/muc_domain_bans.rs
|
||||
b7d16e15e069649b9f0b055f0e0227117bb6d167c3a76cc713bd99fa19a6a60e tests/muc_extensions.rs
|
||||
59561f914274b437497ff35be00ed535ea012d18c99b589c8f944ea365b7112d tests/muc_flow.rs
|
||||
1134fefd5bb75884b096b8542c4f9745e64fd5dfcf87a6bba8d665aad2185a8a tests/muc_access.rs
|
||||
8120c66b85c0884619e39eea0b8761e11b41f2ed14c5be00146e35ca4714a9a0 tests/muc_crash_network.py
|
||||
168a8eb6ab892a379722dab0230efffb6585aaba639ad4691ec017626e1640d2 tests/muc_crash_state.rs
|
||||
2150cc7f4dccf8f1277783f5ccc94d6d626f8cb34ae6d4a05bf249863af29939 tests/muc_domain_bans.rs
|
||||
1d06b769b59012732ba04497a995f4f54df127f58c5f4f264a5df99589f31a94 tests/muc_extensions.rs
|
||||
a8a01faf51bf82bce6cbb2616cdaabb6337047d1c5f17d5f39975c0528a1d5ae tests/muc_flow.rs
|
||||
b24bd920f096576c2009ff1ccc0366eb8734c5f0b2feb39f04ad7087268a94b9 tests/muc_history.rs
|
||||
1f3df5f3f39143adc2b2a419fac0cd74b2229337ffa86570261d1f6ac61137a1 tests/muc_native.rs
|
||||
3df2736ccfd25a6cab290df80ceb7474ea44bb02af6cf768a9216d6b8f7f8630 tests/muc_optional_admin.rs
|
||||
5dc93407a658b45e504b89e26cfc8f495cd28ad90d0a308b7d228a472435ff9f tests/muc_protocol.rs
|
||||
ac1caa62a3cd91e74bd7100a5e542c404e97aec61a34e9627900b458c2ee3806 tests/muc_recovery.rs
|
||||
b5c2e179ee61287a8ec8b6bc593d8c960ca4f87cc71f2230e0c033fadd701ed3 tests/muc_restart_network.py
|
||||
e89a1ec8bfd4608795f6457710622b12a1520628184bc3f033cc6062e01c25d4 tests/muc_restart_state.rs
|
||||
13349e35ebaf65242cfb0bdbbccf0ea83acd80a1251312975141c2b74cccc1d7 tests/muc_state_native.rs
|
||||
5a742e53aa4707b4a060eeb26242006a34344ab57b8cad36668190a8dc5d0674 tests/muc_restart_state.rs
|
||||
7d8cc2b55bfccc92095c08b68dd6853b4ff2b7bfa460faf6c122e7fca09cca39 tests/muc_sm_load_network.py
|
||||
2e718aaa0328ac2a5f5bffbef5cd758bbfc49301d31edc77db316df8c7610acf tests/muc_state_native.rs
|
||||
cd13d1481dba7725986c47b5e5eb606241c8bbc32ab05d53f536f4431d6f7a3d tests/pep_client_interop.rs
|
||||
741cbcbf5552a8c3e5e7757a29de76ecd29da58f9d1184a35a075f1ed7aa5af9 tests/pep_client_network.py
|
||||
dd68c50d7d7cfd7871f69331a2787e0336dcab73762a82c599cee2a39920ae95 tests/pep_diagnostics.rs
|
||||
@@ -110,6 +117,8 @@ ddd9d5b46dc943138a334c64477fb11970b3d45e4cfbeaafc7348754f1e5e29e tests/sasl_acc
|
||||
a7d1519f4f44cc7bfad3d20bcfdf455e92abdd084feed493ffc9e0267a387740 tests/sasl_network.py
|
||||
4748fa227743686f78a0fa0364735d2dda5995f8d70a79ad03c1aa1decb42978 tests/sasl_retry_network.py
|
||||
d8882b8aabd6ff603c81fbbc259eefa68772afb2b3eec2e003ac29efd65d146b tests/sasl_web_network.py
|
||||
d9eef5c37fcd99404619d61dbf7a2bf7673087924d93e69b08769e716afe297b tests/sm_durable_slots.rs
|
||||
0b74b2ff47b8ae681b5613bc87e914fdf8e5033156180df06ff2174df2781a5a tests/sm_restart_network.py
|
||||
72f2d60e47e5295b82d497289b78925a64a1d986c70c277015ae228d211d32bb tests/storage_accounts.rs
|
||||
5c38afd5f77b5774552d12c461902ffaae21de11735560c6f0088e3dda8afa58 tests/storage_compat.rs
|
||||
8724991fdf22471952cf1a0fac863abe837069c93d3c8d2fa751af4f6bbc3e38 tests/tls_binding.rs
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Проверка 0.8.0
|
||||
|
||||
Linux x86_64, Rust1.99.1, 2026-10-20. Итоговый полный прогон:
|
||||
[verify.txt](validation-logs-0.8.0/verify.txt).
|
||||
Структурированный отчёт: [validation-results.json](validation-results.json).
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| Rust tests | 293 passed, 0 failed, 11 ignored |
|
||||
| fmt / Clippy all-targets -D warnings / release | Успешно |
|
||||
| MUC SIGKILL через настоящий STARTTLS | temporary/persistent комнаты, несколько ресурсов, show/status/roles, порядок истории, подтверждённые leave/kick/demotion/password/config, фиксированный/custom grace — успешно |
|
||||
| SM между процессами | SIGKILL/SIGTERM, legacy SASL/SASL2, counters, ack/replay, неверные owner/token/h, resource conflict — успешно |
|
||||
| MUC offline fanout | До аутентификации Bob новое сообщение Alice уже в приватной durable SM-очереди; после resume доставка без history delay — успешно |
|
||||
| Отзыв SM | Удаление аккаунта подтверждается; после повторного создания старый SM-токен отклоняется |
|
||||
| MUC+SM нагрузка | 100 аккаунтов, 500 TLS-ресурсов, 10 комнат, 25000 join presence; SIGKILL и resume представителей всех комнат — успешно |
|
||||
| Native reference | 8 module/storage + 1 TLS/SASL EXTERNAL federation — успешно |
|
||||
| Остальные сетевые регрессии | Android SASL, WSS/BOSH/proxy/altconnect, PEP/push/preauth, resource policies, upload/S3/migration — успешно |
|
||||
|
||||
Нагрузка MUC+SM: connect 5.193 с; join 46.703 с;
|
||||
полный сценарий 78.857 с; выборочный peak RSS 41.26 MiB.
|
||||
Детали fanout, CPU, slots и replay: [muc-sm-load.json](validation-logs-0.8.0/muc-sm-load.json).
|
||||
Это loopback smoke measurement, а не гарантия production capacity.
|
||||
|
||||
SHA-256 итоговой release-сборки: `74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a`.
|
||||
Основной прогон: `XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh`; native env описан
|
||||
в scripts/prosody_compat/README.md. В этой среде дополнительно применены
|
||||
CARGO_INCREMENTAL=0, CARGO_PROFILE_DEV_DEBUG=0, CARGO_PROFILE_TEST_DEBUG=0.
|
||||
|
||||
Первый основной прогон остановился на пятисекундном readiness timeout нового
|
||||
нагрузочного теста после успешных обычных и сетевых проверок. В самом тесте
|
||||
таймаут увеличен до60секунд; нагрузка500 повторена с той же release-сборкой,
|
||||
а native-проверки выполнены отдельно. verify.txt объединяет успешные части
|
||||
этих запусков; первоначальный лог сохранён в initial-load-timeout.txt.
|
||||
|
||||
В процессе проверки исправлены потеря сохранённых presence/history, защита
|
||||
истёкших recovery claims, отсутствие успешного ответа при удалении аккаунта,
|
||||
учёт stanza до SM enable и потеря MUC fanout до подключения другого участника.
|
||||
Первоначальный тест последнего случая принимал позднюю историю за доставку;
|
||||
итоговый тест проверяет именно private pending queue до аутентификации.
|
||||
|
||||
Реальные TCP/TLS-сокеты после завершения процесса отсутствуют: требуется новое
|
||||
защищённое аутентифицированное соединение. SM допускает повторную доставку при
|
||||
неопределённом сетевом результате; это не exactly-once транзакция между всеми
|
||||
хранилищами и сетью. Известные встроенные возможности MUC перенесены, произвольные
|
||||
community Lua-модули не исполняются. Пределы записей и порядок восстановления:
|
||||
[MUC-RECOVERY.md](MUC-RECOVERY.md). Полная замена всей установки и проверка
|
||||
реальными клиентами этим не заявляются: [COMPATIBILITY.md](COMPATIBILITY.md).
|
||||
|
||||
10GiB upload в0.8.0 не повторялся. Исторические испытания local/MinIO находятся
|
||||
в VALIDATION-0.7.8.md и VALIDATION-0.7.9.md; их результаты не приписываются новой сборке.
|
||||
+38
-47
@@ -1,59 +1,50 @@
|
||||
# Проверка 0.7.9
|
||||
# Проверка 0.8.0
|
||||
|
||||
Linux x86_64, Rust 1.99.1, 2026-10-20. Полный итоговый прогон:
|
||||
validation-logs-0.7.9/verify.txt. Структурированный отчёт: validation-results.json.
|
||||
Linux x86_64, Rust1.99.1, 2026-10-20. Итоговый полный прогон:
|
||||
[verify.txt](validation-logs-0.8.0/verify.txt).
|
||||
Структурированный отчёт: [validation-results.json](validation-results.json).
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| cargo test --locked | 265 passed, 0 failed, 11 ignored |
|
||||
| Rust tests | 293 passed, 0 failed, 11 ignored |
|
||||
| fmt / Clippy all-targets -D warnings / release | Успешно |
|
||||
| Upload migration | local → S3 A → S3 B → local, SHA-256, прежние URL/JWT/HEAD, native metadata/source, dry run, lock, прерывание/resume и startup barrier — успешно |
|
||||
| S3 recovery | Intent до initiation, отсутствие UploadId, pagination и exact-key cleanup без удаления чужого prefix, ambiguous completion, quarantine и late commit — успешно |
|
||||
| Конкурентный PUT | Регрессия: захват слота читает свежие completion/quarantine flags атомарно — успешно |
|
||||
| Реальная передача через MinIO | 10,737,418,240 байт PUT и полный GET; SHA-256 совпал; объект: 1280 multipart parts |
|
||||
| Время большого файла | PUT 48.453 с; GET 13.710 с |
|
||||
| Выборочный RSS каждые 50 мс | Rust 101.14 MiB; клиент 23.21 MiB; MinIO 475.84 MiB |
|
||||
| MinIO дополнительные проверки | Range за 4/5 GiB и хвост, restart/read/reuse409, SIGKILL после сохранённой части и abort при recovery — успешно |
|
||||
| MUC SIGKILL через настоящий STARTTLS | temporary/persistent комнаты, несколько ресурсов, show/status/roles, порядок истории, подтверждённые leave/kick/demotion/password/config, фиксированный/custom grace — успешно |
|
||||
| SM между процессами | SIGKILL/SIGTERM, legacy SASL/SASL2, counters, ack/replay, неверные owner/token/h, resource conflict — успешно |
|
||||
| MUC offline fanout | До аутентификации Bob новое сообщение Alice уже в приватной durable SM-очереди; после resume доставка без history delay — успешно |
|
||||
| Отзыв SM | Удаление аккаунта подтверждается; после повторного создания старый SM-токен отклоняется |
|
||||
| MUC+SM нагрузка | 100 аккаунтов, 500 TLS-ресурсов, 10 комнат, 25000 join presence; SIGKILL и resume представителей всех комнат — успешно |
|
||||
| Native reference | 8 module/storage + 1 TLS/SASL EXTERNAL federation — успешно |
|
||||
| Нагрузка | 100 аккаунтов, 500 TLS клиентов, 5000 ping,5000 сообщений, 10000 MAM; failures=[] |
|
||||
| Остальные сетевые регрессии | Android SASL, WSS/BOSH/proxy/altconnect, PEP/push/preauth, resource policies, upload/S3/migration — успешно |
|
||||
|
||||
Итоговая сборка SHA-256: 24c0dca637e7ce4e238ef6becad16bb5a18a33546554b0eecf310fd8927f021c. MinIO report автоматически записывает
|
||||
тот же hash. Большая передача повторена после последних изменений сервера.
|
||||
Полный прогон включает исправление гонки устаревшего snapshot при захвате слота.
|
||||
Отдельные первые прогоны нашли и помогли исправить нестабильный fingerprint
|
||||
миграции и пропуск немедленного reconciliation под ещё активным claim.
|
||||
Финальный результат не содержит этих ошибок. RSS — выборочный максимум, а не
|
||||
гарантия точного пика для любой нагрузки. Временные 10 GiB объекты после проверки удалены.
|
||||
Нагрузка MUC+SM: connect 5.193 с; join 46.703 с;
|
||||
полный сценарий 78.857 с; выборочный peak RSS 41.26 MiB.
|
||||
Детали fanout, CPU, slots и replay: [muc-sm-load.json](validation-logs-0.8.0/muc-sm-load.json).
|
||||
Это loopback smoke measurement, а не гарантия production capacity.
|
||||
|
||||
## Точная граница MinIO проверки
|
||||
SHA-256 итоговой release-сборки: `74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a`.
|
||||
Основной прогон: `XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh`; native env описан
|
||||
в scripts/prosody_compat/README.md. В этой среде дополнительно применены
|
||||
CARGO_INCREMENTAL=0, CARGO_PROFILE_DEV_DEBUG=0, CARGO_PROFILE_TEST_DEBUG=0.
|
||||
|
||||
Использован настоящий upstream MinIO RELEASE.2025-04-22T22-12-26Z, собранный
|
||||
Go1.24.3 из официального GitHub archive. Немодифицированная сборка в этой среде
|
||||
не запускается: net.Interfaces/netlinkrib запрещён с EPERM. Тестовая сборка
|
||||
добавляет только fallback на 127.0.0.1 для этой ошибки перечисления интерфейсов.
|
||||
S3, authentication, multipart и storage code не изменены. Это не qualification
|
||||
немодифицированного MinIO в обычной production среде.
|
||||
Первый основной прогон остановился на пятисекундном readiness timeout нового
|
||||
нагрузочного теста после успешных обычных и сетевых проверок. В самом тесте
|
||||
таймаут увеличен до60секунд; нагрузка500 повторена с той же release-сборкой,
|
||||
а native-проверки выполнены отдельно. verify.txt объединяет успешные части
|
||||
этих запусков; первоначальный лог сохранён в initial-load-timeout.txt.
|
||||
|
||||
Точный diff, hashes исходного архива, обеих сборок и adapter:
|
||||
validation-logs-0.7.9/minio-sandbox-compat.patch и minio-provenance.json.
|
||||
Полный результат и 1280-part ETag: validation-logs-0.7.9/minio.json.
|
||||
Проверен single-node loopback с HTTP к MinIO и CA-verified HTTPS к gateway.
|
||||
AWS account, distributed MinIO и production сеть не проверены.
|
||||
В процессе проверки исправлены потеря сохранённых presence/history, защита
|
||||
истёкших recovery claims, отсутствие успешного ответа при удалении аккаунта,
|
||||
учёт stanza до SM enable и потеря MUC fanout до подключения другого участника.
|
||||
Первоначальный тест последнего случая принимал позднюю историю за доставку;
|
||||
итоговый тест проверяет именно private pending queue до аутентификации.
|
||||
|
||||
## Повторение
|
||||
Реальные TCP/TLS-сокеты после завершения процесса отсутствуют: требуется новое
|
||||
защищённое аутентифицированное соединение. SM допускает повторную доставку при
|
||||
неопределённом сетевом результате; это не exactly-once транзакция между всеми
|
||||
хранилищами и сетью. Известные встроенные возможности MUC перенесены, произвольные
|
||||
community Lua-модули не исполняются. Пределы записей и порядок восстановления:
|
||||
[MUC-RECOVERY.md](MUC-RECOVERY.md). Полная замена всей установки и проверка
|
||||
реальными клиентами этим не заявляются: [COMPATIBILITY.md](COMPATIBILITY.md).
|
||||
|
||||
Полный прогон: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh с native env
|
||||
согласно scripts/prosody_compat/README.md. В этой среде дополнительно применены
|
||||
CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0.
|
||||
MinIO отдельно: python3 tests/upload_minio_network.py --binary target/release/rust-xmpp
|
||||
--minio /path/to/minio --report /tmp/minio-report.json. При test adapter передайте
|
||||
--minio-compat-patch /path/to/patch, чтобы он попал в результат. Можно задать
|
||||
MINIO_BINARY перед verify-local.sh для opt-in передачи10 GiB; нужны место и MinIO executable.
|
||||
|
||||
Инструкции migration/recovery: HTTP-UPLOAD.md. Source всегда сохраняется;
|
||||
незавершённая миграция блокирует startup, неопределённый Complete quarantines
|
||||
только свой slot. Потеря ownership metadata не позволяет безопасно удалить
|
||||
произвольные чужие multiparts; bucket lifecycle остаётся дополнительной защитой.
|
||||
Сетевой сбой с HEAD404 не выдаётся за доказанную неудачу удалённого commit.
|
||||
Полная замена Prosody, настоящие production клиенты и DNSSEC этим не заявляются;
|
||||
общие границы — COMPATIBILITY.md.
|
||||
10GiB upload в0.8.0 не повторялся. Исторические испытания local/MinIO находятся
|
||||
в VALIDATION-0.7.8.md и VALIDATION-0.7.9.md; их результаты не приписываются новой сборке.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
PASS: recovery-history
|
||||
PASS: leave
|
||||
PASS: kick
|
||||
PASS: demotion
|
||||
PASS: password
|
||||
PASS: configuration
|
||||
PASS: restart-grace
|
||||
PASS: custom-grace
|
||||
PASS: unknown-module
|
||||
PASS: destroy-tombstone
|
||||
PASS: actual STARTTLS SIGKILL MUC recovery, history, acknowledged barriers and fixed grace
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"clients": 500,
|
||||
"accounts": 100,
|
||||
"rooms": 10,
|
||||
"message_rounds": 3,
|
||||
"concurrency": 20,
|
||||
"binary": "/tmp/xmpp-durable-muc-load-g0crm4ik/rust-xmpp",
|
||||
"binary_sha256": "74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a",
|
||||
"before": {
|
||||
"rss_kib": 9696,
|
||||
"cpu_seconds": 0.0,
|
||||
"fd_count": 12
|
||||
},
|
||||
"connect_seconds": 5.193,
|
||||
"connect_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 204.391,
|
||||
"p95_ms": 270.035,
|
||||
"max_ms": 332.503
|
||||
},
|
||||
"phase": "completed",
|
||||
"status": "pass",
|
||||
"elapsed_seconds": 78.857,
|
||||
"join_seconds": 46.703,
|
||||
"joined": {
|
||||
"rss_kib": 42248,
|
||||
"cpu_seconds": 17.77,
|
||||
"fd_count": 513
|
||||
},
|
||||
"join_presence_deliveries": 25000,
|
||||
"slots_joined": {
|
||||
"count": 500,
|
||||
"bytes": 309790
|
||||
},
|
||||
"message_seconds": 10.861,
|
||||
"room_messages_sent": 30,
|
||||
"room_messages_delivered": 1500,
|
||||
"before_kill": {
|
||||
"rss_kib": 42128,
|
||||
"cpu_seconds": 23.12,
|
||||
"fd_count": 514
|
||||
},
|
||||
"slots_before_kill": {
|
||||
"count": 500,
|
||||
"bytes": 514030
|
||||
},
|
||||
"restart_seconds": 10.559,
|
||||
"resumed_resources": 10,
|
||||
"replayed_pending_room_messages": 10,
|
||||
"membership_groupchat_deliveries_after_resume": 10,
|
||||
"after_resume": {
|
||||
"rss_kib": 21168,
|
||||
"cpu_seconds": 5.03,
|
||||
"fd_count": 23
|
||||
},
|
||||
"slots_after_resume": {
|
||||
"count": 500,
|
||||
"bytes": 671070
|
||||
},
|
||||
"total_seconds": 78.857,
|
||||
"peak_rss_kib": 42252
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1,846 @@
|
||||
Finished `test` profile [unoptimized] target(s) in 0.09s
|
||||
Running unittests src/lib.rs (target/debug/deps/rust_xmpp-c18b5fae509f7258)
|
||||
|
||||
running 46 tests
|
||||
test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok
|
||||
test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok
|
||||
test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok
|
||||
test http_config::tests::external_prefix_and_standard_port ... ok
|
||||
test http_config::tests::invalid_paths_types_urls_and_conflicts_are_errors ... ok
|
||||
test federation::tests::stream_restarts_compare_canonical_domains ... ok
|
||||
test http_config::tests::mounts_inherit_per_app_and_expand_virtual_host ... ok
|
||||
test http_config::tests::literal_pchars_and_percent_encoding_are_supported ... ok
|
||||
test http_config::tests::routes_hosts_and_default_without_overriding_known_hosts ... ok
|
||||
test http_proxy::tests::config_validation_and_network_boundaries ... ok
|
||||
test http_proxy::tests::empty_native_lua_table_disables_proxy_trust ... ok
|
||||
test http_proxy::tests::legacy_chain_uses_rightmost_untrusted_and_tls_is_preserved ... ok
|
||||
test http_proxy::tests::untrusted_socket_ignores_spoofed_headers ... ok
|
||||
test http_proxy::tests::malformed_forwarded_and_unknown_identifiers_are_ignored ... ok
|
||||
test http_proxy::tests::forwarded_mode_stops_at_first_untrusted_hop ... ok
|
||||
test http_proxy::tests::forwarded_tls_termination_cannot_upgrade_a_plaintext_chain_hop ... ok
|
||||
test http_transport::tests::bosh_processing_capacity_is_bounded ... ok
|
||||
test http_transport::tests::independent_framing ... ok
|
||||
test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok
|
||||
test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok
|
||||
test http_transport::tests::configured_mount_host_origin_and_secure_dispatch ... ok
|
||||
test sasl::tests::reject_malformed_and_downgrade ... ok
|
||||
test sasl::tests::rfc5802_sha1_vector ... ok
|
||||
test server::authorization_tests::http_interfaces_follow_secure_override_wildcards_and_global_fallback ... ok
|
||||
test server::authorization_tests::pep_diagnostics_count_only_first_item_devices_without_logging_ids ... ok
|
||||
test server::authorization_tests::pep_diagnostics_exclude_payloads_and_identifiers ... ok
|
||||
test http_service::tests::upload_claim_refreshes_completion_state_after_an_earlier_snapshot ... ok
|
||||
test sessions::push_timeout_tests::first_important_push_shortens_extended_hibernation_once ... ok
|
||||
test sessions::tests::acknowledgement_wraps ... ok
|
||||
test sessions::tests::graceful_disconnect_is_not_resumable ... ok
|
||||
test sessions::tests::resume_replays_only_unacked_and_checks_owner ... ok
|
||||
test sessions::tests::traffic_before_sm_enable_is_not_counted ... ok
|
||||
test upload_migration::tests::local_spool_checks_size_and_hash_and_preserves_source ... ok
|
||||
test upload_migration::tests::pending_and_corrupt_journals_block_startup ... ok
|
||||
test wire::tests::arbitrary_prefix_and_inherited_namespaces ... ok
|
||||
test wire::tests::cdata_and_comments_frame_without_counting_brackets ... ok
|
||||
test wire::tests::declarations_rejected ... ok
|
||||
test wire::tests::fragmented_and_pipelined ... ok
|
||||
test wire::tests::invalid_namespace_and_declarations_rejected ... ok
|
||||
test http_service::tests::upload_internal_mount_is_independent_of_advertised_proxy_prefix ... ok
|
||||
test sasl::tests::sha256_independent_python_vector ... ok
|
||||
test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok
|
||||
test sasl::tests::plus_channel_binding_and_ssdp_are_authenticated ... ok
|
||||
test server::authorization_tests::upload_requires_registered_local_account_or_explicit_remote_access ... ok
|
||||
test sasl::tests::wrong_proof_nonce_binding_and_duplicates ... ok
|
||||
test admin::tests::real_admin_xml_protocol_over_duplex ... ok
|
||||
|
||||
test result: ok. 46 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/rust_xmpp-c4e45dd69a11a11d)
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/admin_socket.rs (target/debug/deps/admin_socket-61170eb8eabf7ff1)
|
||||
|
||||
running 1 test
|
||||
test prosody_wire_commands_password_prompt_and_safe_errors ... ignored, requires AF_UNIX socket permission; run on target host with --ignored
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/archive_mam.rs (target/debug/deps/archive_mam-66e5d9709846757b)
|
||||
|
||||
running 14 tests
|
||||
test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly
|
||||
test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok
|
||||
test mam_preferences_persist ... ok
|
||||
test legacy_records_binary_cache_and_roundtrip ... ok
|
||||
test append_is_incremental_and_recovers_torn_tail ... ok
|
||||
test archive_roundtrip_preserves_default_namespace_resets ... ok
|
||||
test equal_cursors_and_normalized_query_jids ... ok
|
||||
test muc_archive_filters_stanza_kind_instead_of_sender_jid ... ok
|
||||
test smart_enable_and_message_selection ... ok
|
||||
test real_archive_sid_and_sender_privacy ... ok
|
||||
test zero_page_dates_and_invalid_forms ... ok
|
||||
test mam_paging_filters_and_forbidden ... ok
|
||||
test extended_ids_ranges_flip_and_metadata ... ok
|
||||
test namespaces_expiry_and_concurrent_append ... ok
|
||||
|
||||
test result: ok. 13 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.04s
|
||||
|
||||
Running tests/auth.rs (target/debug/deps/auth-3fecc5005dc5e6c7)
|
||||
|
||||
running 3 tests
|
||||
test traversal_and_malicious_account_records_rejected ... ok
|
||||
test create_and_read_hashed_accounts ... ok
|
||||
test authentication_prepares_names_without_relaxing_account_management ... ok
|
||||
|
||||
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.11s
|
||||
|
||||
Running tests/cli.rs (target/debug/deps/cli-a1c004101734b0cf)
|
||||
|
||||
running 2 tests
|
||||
test server_cannot_be_mistaken_for_running_service ... ok
|
||||
test conversion_preserves_source_and_refuses_overwrite ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
||||
|
||||
Running tests/config.rs (target/debug/deps/config-bb0cdd36fb8d5839)
|
||||
|
||||
running 12 tests
|
||||
test include_cycles_rejected ... ok
|
||||
test component_can_explicitly_enable_global_modules ... ok
|
||||
test storage_paths_encode_like_prosody ... ok
|
||||
test include_has_own_global_scope_and_parent_scope_is_restored ... ok
|
||||
test declarations_and_overrides ... ok
|
||||
test tables_cannot_recurse_forever ... ok
|
||||
test supplied_configuration_scopes_arithmetic_and_modules ... ok
|
||||
test runtime_capabilities_are_unavailable ... ok
|
||||
test all_formats_round_trip_actual_config ... ok
|
||||
test supplied_config_matches_original_prosody_loader ... ok
|
||||
test malformed_values_rejected ... ok
|
||||
test loops_are_bounded ... ok
|
||||
|
||||
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/csi.rs (target/debug/deps/csi-ef5a8b30cab0cbf7)
|
||||
|
||||
running 2 tests
|
||||
test bounded_queue_flush_delay_activity_and_grace ... ok
|
||||
test native_importance_rules ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/csi_native.rs (target/debug/deps/csi_native-7a0bbbead7a19df9)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_csi_importance_matches ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/data_lock.rs (target/debug/deps/data_lock-897044c51991a534)
|
||||
|
||||
running 2 tests
|
||||
test child_process_probe ... ok
|
||||
test excludes_another_process_and_releases_on_drop ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-3c721d03a5eecf02)
|
||||
|
||||
running 1 test
|
||||
test debug_empty_legacy_bind ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
|
||||
|
||||
Running tests/federation_tls.rs (target/debug/deps/federation_tls-6dc128df24b929d8)
|
||||
|
||||
running 3 tests
|
||||
test native_prosody_tls_external_bidirectional ... ignored, requires native Prosody cafd74b9c5ea and Lua TLS dependencies
|
||||
test mutual_tls_external_delivery_and_certificate_identity_rejection ... ok
|
||||
test reuses_connections_serializes_writes_and_retires_idle_or_closed_peers ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 1.48s
|
||||
|
||||
Running tests/gajim_features.rs (target/debug/deps/gajim_features-f6c3a49b9451f82c)
|
||||
|
||||
running 6 tests
|
||||
test policy_catalog_selects_default_policy_and_maps_display_and_explicit_label ... ok
|
||||
test security_catalog_default_is_a_real_label_and_module_can_be_disabled ... ok
|
||||
test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... ok
|
||||
test unsupported_security_policy_configuration_returns_explicit_error ... ok
|
||||
test security_catalogs_serve_both_versions_and_preserve_requested_target ... ok
|
||||
test malformed_or_unknown_policy_catalog_schemas_are_rejected ... ok
|
||||
|
||||
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/http_altconnect.rs (target/debug/deps/http_altconnect-9136542484c4f432)
|
||||
|
||||
running 3 tests
|
||||
test native_link_order_schemes_and_loaded_module_gates ... ok
|
||||
test public_prefix_and_host_paths_inherit_independently ... ok
|
||||
test bad_advertisement_types_and_route_collisions_are_rejected ... ok
|
||||
|
||||
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/http_transport.rs (target/debug/deps/http_transport-ba31449c82cc1657)
|
||||
|
||||
running 10 tests
|
||||
test configured_origin_is_enforced ... ok
|
||||
test bosh_preserves_namespace_inherited_from_body ... ok
|
||||
test transport_paths_and_cors_preflight_match_prosody ... ok
|
||||
test bosh_session_retries_restart_termination ... ok
|
||||
test bosh_termination_waits_for_graceful_cleanup_before_reconnect ... ok
|
||||
test bosh_reorders_two_requests_without_replaying_stanzas ... ok
|
||||
test websocket_frames_are_independent_xml ... ok
|
||||
test websocket_close_allows_graceful_engine_cleanup ... ok
|
||||
test websocket_disconnect_delivers_eof_without_graceful_close ... ok
|
||||
test bosh_retry_survives_aborted_http_request_without_replaying_stanzas ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
|
||||
|
||||
Running tests/http_upload.rs (target/debug/deps/http_upload-ff5554e98fd3b948)
|
||||
|
||||
running 5 tests
|
||||
test https_endpoint_uses_real_tls ... ok
|
||||
test percent_escape_case_and_equivalent_url_spelling ... ok
|
||||
test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... ok
|
||||
test expired_upload_releases_quota ... ok
|
||||
test upload_authorization_streaming_ranges_and_restart ... ok
|
||||
|
||||
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
|
||||
|
||||
Running tests/iq_errors.rs (target/debug/deps/iq_errors-f6728f43bbee20cf)
|
||||
|
||||
running 2 tests
|
||||
test pep_errors_identify_the_account_and_echo_the_request ... ok
|
||||
test server_errors_without_target_remain_unaddressed_and_escape_attributes ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/logging.rs (target/debug/deps/logging-49936a1290966f86)
|
||||
|
||||
running 10 tests
|
||||
test native_console_shorthands_are_accepted ... ok
|
||||
test rejects_malformed_configuration ... ok
|
||||
test json_records_and_bounded_ranges ... ok
|
||||
test failed_reopen_keeps_all_original_handles ... ok
|
||||
test filters_appends_escapes_and_redacts ... ok
|
||||
test reopen_rotated_files_updates_clones ... ok
|
||||
test explicit_sink_levels_select_exactly_and_deduplicate ... ok
|
||||
test shared_sink_unions_levels_and_preserves_distinct_destinations ... ok
|
||||
test overlapping_shorthand_destinations_receive_each_record_once ... ok
|
||||
test cloned_loggers_serialize_records ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
|
||||
|
||||
Running tests/message_routing.rs (target/debug/deps/message_routing-e8cb56e9c75084cc)
|
||||
|
||||
running 2 tests
|
||||
test unknown_resource_falls_back_to_available_bare_jid ... ok
|
||||
test headline_uses_all_nonnegative_available_resources_and_errors_never_fallback ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/muc_access.rs (target/debug/deps/muc_access-3d45cfc19a946c63)
|
||||
|
||||
running 3 tests
|
||||
test binary_occupant_id_salt_survives_saves_and_restarts ... ok
|
||||
test moderators_cannot_kick_or_demote_other_moderators ... ok
|
||||
test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok
|
||||
|
||||
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/muc_crash_state.rs (target/debug/deps/muc_crash_state-f6de95e841b62f79)
|
||||
|
||||
running 7 tests
|
||||
test safe_presence_checkpoint_sanitization_is_idempotent ... ok
|
||||
test foreign_stanzas_do_not_initialize_or_rewrite_muc_checkpoint ... ok
|
||||
test durable_disconnect_removes_last_temporary_room_without_shutdown_export ... ok
|
||||
test journal_failure_returns_no_routing_and_rolls_back_role_mutation ... ok
|
||||
test acknowledged_kick_and_room_destruction_cannot_resurrect_membership ... ok
|
||||
test dormant_deadline_stays_fixed_when_another_occupant_recovers_and_sends ... ok
|
||||
test temporary_membership_and_full_bounded_history_survive_ungraceful_loss ... ok
|
||||
|
||||
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
|
||||
|
||||
Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-bf16a8c751b147a4)
|
||||
|
||||
running 2 tests
|
||||
test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok
|
||||
test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
||||
|
||||
Running tests/muc_extensions.rs (target/debug/deps/muc_extensions-8aa1a3ac3d2930b2)
|
||||
|
||||
running 10 tests
|
||||
test disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted ... ok
|
||||
test broadcast_roles_hide_visitors_and_configuration_announces_transition ... ok
|
||||
test messages_cannot_spoof_room_identity_hats_or_occupant_identifiers ... ok
|
||||
test mam_all_rooms_presence_logging_public_queries_and_optional_totals ... ok
|
||||
test large_optional_history_does_not_prevent_checkpointing_membership ... ok
|
||||
test restricted_private_messages_allow_self_and_optional_moderators ... ok
|
||||
test offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames ... ok
|
||||
test language_config_sections_discovery_and_native_affiliation_hats ... ok
|
||||
test recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims ... ok
|
||||
test explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
|
||||
|
||||
Running tests/muc_flow.rs (target/debug/deps/muc_flow-358a28db146dbe13)
|
||||
|
||||
running 18 tests
|
||||
test configured_lock_timeout_expires_without_owner_submission ... ok
|
||||
test iq_response_requires_expected_sender ... ok
|
||||
test multiple_resources_share_nick_and_private_delivery ... ok
|
||||
test join_lock_chat_leave ... ok
|
||||
test authorization_and_conflict ... ok
|
||||
test nick_invite_destroy_and_local_creation ... ok
|
||||
test occupant_id_is_stable_and_spoofed_ids_removed ... ok
|
||||
test preserves_unknown_binary_mixed_room_fields ... ok
|
||||
test restored_temporary_room_is_deleted_after_last_disconnect ... ok
|
||||
test native_muc_archive_type_and_anonymity_are_preserved ... ok
|
||||
test persistence_roundtrip ... ok
|
||||
test maintenance_expires_idle_archives_and_tombstones ... ok
|
||||
test persistent_destroy_creates_prosody_tombstone ... ok
|
||||
test switching_to_members_only_removes_unaffiliated_users ... ok
|
||||
test archive_history_and_permissions ... ok
|
||||
test room_vcard_owner_only_and_avatar_presence ... ok
|
||||
test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok
|
||||
test reserved_nickname_blocks_other_users ... ok
|
||||
|
||||
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
|
||||
|
||||
Running tests/muc_history.rs (target/debug/deps/muc_history-f67a7a69e0bfd957)
|
||||
|
||||
running 10 tests
|
||||
test capacity_changes_trim_and_zero_disables_history ... ok
|
||||
test only_direct_history_children_are_parsed ... ok
|
||||
test maxchars_counts_transmitted_utf8_bytes_including_address_and_delay ... ok
|
||||
test explicit_count_and_zero_limit ... ok
|
||||
test snapshot_restores_last_message_without_adding_another_delay ... ok
|
||||
test replay_carries_room_delay_and_recipient ... ok
|
||||
test ordinary_history_is_bounded_and_returns_oldest_first ... ok
|
||||
test subjects_and_hints_follow_native_history_eligibility ... ok
|
||||
test since_and_seconds_use_stricter_cutoff_inclusive ... ok
|
||||
test default_count_applies_only_without_valid_explicit_constraints ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/muc_native.rs (target/debug/deps/muc_native-f792fb43c05f2836)
|
||||
|
||||
running 1 test
|
||||
test actual_prosody_muc_mam_reads_rust_and_writes_native_archive ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/muc_optional_admin.rs (target/debug/deps/muc_optional_admin-cda5bde07d3a924e)
|
||||
|
||||
running 4 tests
|
||||
test default_registration_denies_unaffiliated_users ... ok
|
||||
test configured_tombstone_expiry_allows_immediate_recreation ... ok
|
||||
test optional_registration_grants_membership_and_enforces_reserved_nickname ... ok
|
||||
test administration_commands_recheck_authority_bind_sessions_and_update_live_rooms ... ok
|
||||
|
||||
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
|
||||
|
||||
Running tests/muc_protocol.rs (target/debug/deps/muc_protocol-5f42030bfe1d6522)
|
||||
|
||||
running 5 tests
|
||||
test ordinary_presence_extensions_survive_but_client_identity_is_filtered ... ok
|
||||
test subject_with_body_remains_an_ordinary_groupchat_message ... ok
|
||||
test probes_and_invalid_presence_cannot_join_or_rename ... ok
|
||||
test error_messages_kick_only_on_delivery_failure_and_presence_errors_kick ... ok
|
||||
test room_creation_policies_apply_only_to_new_rooms ... ok
|
||||
|
||||
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/muc_recovery.rs (target/debug/deps/muc_recovery-226d89017c6b58d8)
|
||||
|
||||
running 7 tests
|
||||
test claim_is_one_time_and_does_not_change_untrusted_role ... ok
|
||||
test pending_snapshot_keeps_original_checkpoint_time ... ok
|
||||
test recovery_claim_requires_exact_authenticated_resource_and_expires ... ok
|
||||
test native_last_message_roundtrip_preserves_namespaces_and_rejects_other_rooms ... ok
|
||||
test native_checkpoint_roundtrip_filters_sensitive_extensions ... ok
|
||||
test oversized_status_never_prevents_membership_checkpoint ... ok
|
||||
test configuration_fingerprint_is_independent_of_lua_table_iteration_order ... ok
|
||||
|
||||
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
||||
|
||||
Running tests/muc_restart_state.rs (target/debug/deps/muc_restart_state-5cb216a0bea9ced1)
|
||||
|
||||
running 3 tests
|
||||
test unrelated_disconnect_does_not_delete_dormant_temporary_room ... ok
|
||||
test changed_configuration_invalidates_auto_recovery_without_disclosing_password ... ok
|
||||
test repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing ... ok
|
||||
|
||||
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
|
||||
|
||||
Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-e120e43e0108ea7f)
|
||||
|
||||
running 1 test
|
||||
test actual_prosody_restores_rust_checkpoint_and_rust_reads_native_freeze ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/pep_client_interop.rs (target/debug/deps/pep_client_interop-1553fc66a4e090e1)
|
||||
|
||||
running 7 tests
|
||||
test native_component_notification_type_applies_to_events_and_subscription_replay ... ok
|
||||
test missing_own_omemo_bundle_returns_recoverable_item_not_found ... ok
|
||||
test publish_options_match_implicit_notification_defaults_on_old_nodes ... ok
|
||||
test migrated_native_configuration_can_roundtrip_access_model_edit ... ok
|
||||
test avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... ok
|
||||
test native_itemreply_controls_publisher_visibility_in_reads_and_replays ... ok
|
||||
test native_open_and_subscriber_publish_models_preserve_outcast_and_blocking_checks ... ok
|
||||
|
||||
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s
|
||||
|
||||
Running tests/pep_diagnostics.rs (target/debug/deps/pep_diagnostics-98b92a168aa66315)
|
||||
|
||||
running 6 tests
|
||||
test nonempty_xml_can_be_an_empty_android_device_list ... ok
|
||||
test avatar_classification_does_not_inspect_or_expose_avatar_data ... ok
|
||||
test arbitrary_node_payload_and_wrong_namespace_do_not_leak ... ok
|
||||
test only_first_direct_item_is_parsed_and_protocols_are_distinct ... ok
|
||||
test payloadless_notification_is_visible_without_identifiers ... ok
|
||||
test signed_integer_limits_and_duplicates_match_java_parser ... ok
|
||||
|
||||
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/pep_discovery.rs (target/debug/deps/pep_discovery-3e3fb8c04ba0a062)
|
||||
|
||||
running 4 tests
|
||||
test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok
|
||||
test account_disco_advertises_pep_without_requiring_a_node ... ok
|
||||
test account_discovery_does_not_intercept_server_or_resource_caps ... ok
|
||||
test node_discovery_keeps_node_identity_errors_and_access_checks ... ok
|
||||
|
||||
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
|
||||
|
||||
Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-77f6b485fd7811b4)
|
||||
|
||||
running 2 tests
|
||||
test native_prosody_orders_retained_pep_items_newest_first ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
test first_pep_item_is_current_device_list_even_with_retained_old_items ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.10s
|
||||
|
||||
Running tests/preauth.rs (target/debug/deps/preauth-4dd44275632445e0)
|
||||
|
||||
running 10 tests
|
||||
test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok
|
||||
test standard_adhoc_command_returns_contact_uri ... ok
|
||||
test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok
|
||||
test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok
|
||||
test concurrent_redemption_has_exactly_one_winner ... ok
|
||||
test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok
|
||||
test identity_scope_does_not_burn_on_wrong_sender_or_leak_to_prosody ... ok
|
||||
test invitations_expire_are_single_use_and_owner_scoped ... ok
|
||||
test valid_token_autoapproves_and_sends_reciprocal_request ... ok
|
||||
test local_invitation_updates_both_rosters_without_granting_reverse_subscription ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
|
||||
|
||||
Running tests/preauth_native.rs (target/debug/deps/preauth_native-36f66c5afb8f3b7f)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_invitation_tokens_roundtrip ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-5bfe7997d9ab4681)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_pep_reads_rust_and_rust_reads_native ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/pubsub_persistence.rs (target/debug/deps/pubsub_persistence-96d57930ef04b71a)
|
||||
|
||||
running 18 tests
|
||||
test modern_bookmarks_notify_legacy_capability ... ok
|
||||
test native_string_max_items_satisfies_numeric_publish_precondition ... ok
|
||||
test configurable_limits_component_creation_and_publisher ... ok
|
||||
test configure_requires_owner ... ok
|
||||
test capability_discovery_replays_and_filters_notifications ... ok
|
||||
test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok
|
||||
test open_model_and_notifications ... ok
|
||||
test reads_prosody_legacy_fixture ... ok
|
||||
test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok
|
||||
test pep_persists_namespaces_and_enforces_owner ... ok
|
||||
test publish_options_conflict_is_recoverable_and_preserves_item ... ok
|
||||
test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok
|
||||
test roster_presence_access_and_foreign_subscription_denied ... ok
|
||||
test remote_caps_replay_and_open_node_privacy_revoke ... ok
|
||||
test subscription_options_disable_events_and_require_subscriber ... ok
|
||||
test payload_policy_applies_to_subscription_and_caps_replays ... ok
|
||||
test native_notification_fields_and_retract_policy_are_independent ... ok
|
||||
test component_default_retention_matches_prosody_twenty_items ... ok
|
||||
|
||||
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s
|
||||
|
||||
Running tests/push.rs (target/debug/deps/push-e74594c0e96b1321)
|
||||
|
||||
running 7 tests
|
||||
test native_push_body_lookup_excludes_extensions ... ignored, requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA
|
||||
test foreign_account_cannot_register ... ok
|
||||
test queue_coalesces_and_importance_matches_native ... ok
|
||||
test push_summary_never_copies_foreign_namespace_body ... ok
|
||||
test reenable_cancels_old_errors_and_failure_counts_are_per_account ... ok
|
||||
test stable_client_registration_and_targeted_notification ... ok
|
||||
test registrations_notifications_and_authenticated_errors ... ok
|
||||
|
||||
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.12s
|
||||
|
||||
Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-fe62c7cdfcb258f2)
|
||||
|
||||
running 1 test
|
||||
test real_prosody_storage_and_auth_roundtrip ... ignored, requires native supplied Prosody source; run scripts/prosody_compat/verify.sh
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/resource_sessions.rs (target/debug/deps/resource_sessions-c00bb0104e05dfa7)
|
||||
|
||||
running 7 tests
|
||||
test increment_skips_occupied_suffixes_and_preserves_sessions ... ok
|
||||
test invalid_resume_owner_or_ack_does_not_evict_active_session ... ok
|
||||
test kick_new_leaves_existing_session_unchanged ... ok
|
||||
test replacement_revokes_hibernating_resume_and_survives_old_cleanup ... ok
|
||||
test random_conflicts_get_distinct_resources_for_same_owner ... ok
|
||||
test concurrent_default_binds_replace_atomically ... ok
|
||||
test active_resume_preserves_state_and_replaces_transport_notifier ... ok
|
||||
|
||||
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
||||
|
||||
Running tests/sasl_accounts.rs (target/debug/deps/sasl_accounts-c2c6690c56d78954)
|
||||
|
||||
running 1 test
|
||||
test disabled_legacy_and_unicode_accounts ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s
|
||||
|
||||
Running tests/sm_durable_slots.rs (target/debug/deps/sm_durable_slots-cf591ebfb421691b)
|
||||
|
||||
running 11 tests
|
||||
test corrupt_committed_slot_fails_recovery_closed ... ok
|
||||
test durable_tokens_and_private_pending_output_have_owner_only_permissions ... ok
|
||||
test recovery_discards_owned_crash_temps_and_keeps_last_committed_slot ... ok
|
||||
test session_reference_does_not_keep_store_locked_or_allow_unpersisted_writes ... ok
|
||||
test disconnected_deadline_is_fixed_across_recovery ... ok
|
||||
test durable_counters_wrap_without_accepting_stale_acknowledgements ... ok
|
||||
test shutdown_freeze_retires_old_transport_before_cleanup ... ok
|
||||
test independent_hubs_with_identical_connection_names_keep_their_own_slots ... ok
|
||||
test restart_restores_owner_presence_and_only_unacknowledged_output ... ok
|
||||
test graceful_close_conflict_account_purge_and_expiration_remove_tokens ... ok
|
||||
test store_has_one_process_owner_and_enforces_output_byte_bound ... ok
|
||||
|
||||
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s
|
||||
|
||||
Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-1d75e90b4e9212f9)
|
||||
|
||||
running 20 tests
|
||||
test account_purge_refuses_symlink_store_without_touching_other_files ... ok
|
||||
test broken_roster_is_not_overwritten ... ok
|
||||
test inbound_subscription_requires_pending_to_grant_to ... ok
|
||||
test blocklist_enforcement_api ... ok
|
||||
test domain_roster_and_block_jid_normalization ... ok
|
||||
test malformed_roster_iq_never_mutates ... ok
|
||||
test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok
|
||||
test private_and_vcard_authorization ... ok
|
||||
test privacy_migration_only_default_deny_jids ... ok
|
||||
test reads_timestamp_blocklist_and_keeps_metadata ... ok
|
||||
test roster_empty_name_and_foreign_group_ignored ... ok
|
||||
test roster_preserves_subscription_and_metadata ... ok
|
||||
test purge_account_exact_files_preserves_others_and_extras ... ok
|
||||
test subscription_handshake_and_cancel ... ok
|
||||
test legacy_bookmarks_respect_host_limit_and_read_over_256 ... ok
|
||||
test bookmarks_private_pep_bidirectional_and_private ... ok
|
||||
test legacy_migration_clears_source_after_success ... ok
|
||||
test vcard_pep_conversion_and_avatar ... ok
|
||||
test password_change_after_purge_never_recreates_account ... ok
|
||||
test creation_and_purge_are_serialized ... ok
|
||||
|
||||
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.36s
|
||||
|
||||
Running tests/storage_compat.rs (target/debug/deps/storage_compat-147836efd1c208ca)
|
||||
|
||||
running 6 tests
|
||||
test empty_namespace_preserved ... ok
|
||||
test mixed_binary_roundtrip ... ok
|
||||
test stanza_roundtrip ... ok
|
||||
test path_and_atomic_update ... ok
|
||||
test rejects_cycles_code_and_bad_files ... ok
|
||||
test concurrent_updates_do_not_lose_entries ... ok
|
||||
|
||||
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/tls_binding.rs (target/debug/deps/tls_binding-5e699c7900a4d099)
|
||||
|
||||
running 4 tests
|
||||
test malformed_der_and_pss_parameters_fail_closed ... ok
|
||||
test unknown_and_eddsa_algorithms_fail_closed ... ok
|
||||
test ecdsa_uses_certificate_signature_hash ... ok
|
||||
test rsa_signature_digest_and_weak_hash_upgrade ... ok
|
||||
|
||||
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s
|
||||
|
||||
Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-b82a1b9340e268e4)
|
||||
|
||||
running 2 tests
|
||||
test disabling_plus_hides_bindings_and_accepts_scram_y_with_mechanisms_only_ssdp ... ok
|
||||
test starttls_scram_plus_binds_real_tls_and_authenticates_ssdp_sasl1_and_sasl2 ... ok
|
||||
|
||||
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.37s
|
||||
|
||||
Doc-tests rust_xmpp
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Finished `dev` profile [unoptimized] target(s) in 0.10s
|
||||
Finished `release` profile [optimized] target(s) in 0.11s
|
||||
PASS: integrated TCP roster/subscriptions, client IQ routing, CSI buffering/keepalive flush, MAM, blocking, OMEMO PEP, MUC, offline replay and SM resumption
|
||||
PASS: graceful SIGTERM shutdown
|
||||
PASS: independent SCRAM-SHA-1 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection
|
||||
PASS: independent SCRAM-SHA-256 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection
|
||||
PASS: Android TLS1.2 selector, TLS1.2/1.3 SCRAM SHA1/256 PLUS, SASL1/2, SSDP on/off and invalid binding rejection
|
||||
PASS: JSON file logging, TLS/auth/bind correlation, rejection reasons, secret redaction and SIGHUP rotation
|
||||
PASS: 20 TLS1.2/1.3 SASL1/2 cases: prepared PLAIN identities and canonical binds, invalid authz rejection, SCRAM abort/retry and active exchange restart
|
||||
PASS: WSS SCRAM-SHA-256 SASL1/SASL2 with GS2 y, absent CB/PLUS, SSDP without CB separator, server signatures, bind and ping
|
||||
PASS: HTTPS-only configured interfaces (127.0.0.1,::1), verified TLS, reproduced original Origin 403 then allowed Converse Origin 101, xmpp subprotocol, both WS paths, RFC 7395 open/features/close and private HTTP diagnostics
|
||||
PASS: custom WS/BOSH routes, default-route 404, exact Origin policy, verified TLS proxy rewrite, trusted HTTPS metadata, untrusted/spoofed metadata denial, IPv6 when available, authenticated WS/BOSH bind/ping in legacy and RFC7239 modes and cached BOSH RID retry
|
||||
PASS: XEP-0156 XRD/JRD parity, CORS, safe methods, ignored JSONP, host checks, secure URL preference, custom mounts/public prefixes, module and advertisement gates, HTTP fallback and verified WSS framing
|
||||
PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback
|
||||
PASS: bind2-disabled, SSDP=False, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: bind2-disabled, SSDP=True, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: bind2-enabled, SSDP=False, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: bind2-enabled, SSDP=True, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: sm-disabled, SSDP=False, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: sm-disabled, SSDP=True, SCRAM-SHA-1/PLAIN success and wrong-password rejection
|
||||
PASS: enabled-False-registration-False
|
||||
PASS: enabled-False-registration-True
|
||||
PASS: enabled-True-registration-False
|
||||
PASS: enabled-True-registration-True
|
||||
PASS: Gajim domain/account discovery, both label catalogs, authenticated password changes, IBR registration gates and fresh logins
|
||||
PASS: SASL2 inline SM active/EOF resume, authenticated owner/id/h checks, replay ordering, full JID, Bind2 fallback and malformed requests
|
||||
PASS: TLS account/domain discovery with PEP and push enabled/disabled; real push enable, publish options, per-node and service-wide disable
|
||||
PASS: TLS Monocles/Conversations PEP first-run, no-to publishing, owner config recovery, migrated metadata, device item selectors and restart persistence
|
||||
PASS: Android first-item device-list parsing sees latest publish and replacement before and after restart
|
||||
PASS: STARTTLS PEP replay/live diagnostics, legacy/OMEMO2, payloadless notifications with intact IQ payloads, and private values excluded
|
||||
PASS: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation
|
||||
PASS: STARTTLS MUC SIGTERM restart restores live temporary/persistent rooms, exact full-JID membership, roles, presence and groupchat routing; rejects automatic cross-resource/account claims and nickname theft
|
||||
PASS: recovery-history
|
||||
PASS: leave
|
||||
PASS: kick
|
||||
PASS: demotion
|
||||
PASS: password
|
||||
PASS: configuration
|
||||
PASS: restart-grace
|
||||
PASS: custom-grace
|
||||
PASS: unknown-module
|
||||
PASS: actual STARTTLS SIGKILL MUC recovery, history, acknowledged barriers and fixed grace
|
||||
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
|
||||
PASS: TLS conflict_resolve=default, live and hibernating SM resources
|
||||
PASS: TLS conflict_resolve=kick_old, live and hibernating SM resources
|
||||
PASS: TLS conflict_resolve=kick_new, live and hibernating SM resources
|
||||
PASS: TLS conflict_resolve=increment, live and hibernating SM resources
|
||||
PASS: TLS conflict_resolve=random, live and hibernating SM resources
|
||||
PASS: integrated WSS + HTTPS BOSH SASL/bind, CSI, inherited BOSH namespaces, roster, bidirectional chat, upload IQ/PUT/GET/reuse, graceful SM close and same-resource relogin under kick_new
|
||||
PASS: integrated WSS + HTTPS BOSH SASL/bind, CSI, inherited BOSH namespaces, roster, bidirectional chat, upload IQ/PUT/GET/reuse, graceful SM close and same-resource relogin under kick_new
|
||||
PASS: configured upload HTTP path with $host through integrated listener
|
||||
PASS: multiple upload hosts/shared-host paths, discovery, quotas, JWT isolation, persistence, transport composition and conflict validation
|
||||
PASS upload S3 protocol fixture: SigV4, multipart, abort, range, restart, expiry and backend isolation
|
||||
PASS upload migration: local -> S3 -> S3 -> local, hashes, original URLs/JWT/HEAD, lock, dry-run, fail-closed resume
|
||||
{
|
||||
"total_load_wall_seconds": 6.57,
|
||||
"requested_resources": 500,
|
||||
"authenticated_bound_resources": 500,
|
||||
"connection_setup_concurrency": 50,
|
||||
"setup_wall_seconds": 0.617,
|
||||
"setup_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 57.355,
|
||||
"p95_ms": 75.79,
|
||||
"max_ms": 93.594
|
||||
},
|
||||
"ping_rounds": 10,
|
||||
"ping_round_trip": {
|
||||
"samples": 5000,
|
||||
"median_ms": 12.184,
|
||||
"p95_ms": 25.508,
|
||||
"max_ms": 28.647
|
||||
},
|
||||
"accounts": 100,
|
||||
"message_rounds": 10,
|
||||
"messages_sent": 5000,
|
||||
"messages_received": 5000,
|
||||
"message_batch_seconds": 3.668,
|
||||
"metrics_before": {
|
||||
"rss_kib": 8532,
|
||||
"cpu_seconds": 0.0,
|
||||
"fd_count": 11
|
||||
},
|
||||
"metrics_connected": {
|
||||
"rss_kib": 27360,
|
||||
"cpu_seconds": 1.06,
|
||||
"fd_count": 511
|
||||
},
|
||||
"metrics_after_workload": {
|
||||
"rss_kib": 28192,
|
||||
"cpu_seconds": 8.11,
|
||||
"fd_count": 511
|
||||
},
|
||||
"metrics_two_seconds_after_disconnect": {
|
||||
"rss_kib": 28192,
|
||||
"cpu_seconds": 8.13,
|
||||
"fd_count": 11
|
||||
},
|
||||
"sampled_peak_rss_kib": 28192,
|
||||
"fd_delta_after_disconnect": 0,
|
||||
"failures": [],
|
||||
"scope": "One loopback host, 100 accounts with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks.",
|
||||
"mam": {
|
||||
"archive_files": 100,
|
||||
"records": 10000,
|
||||
"expected_records": 10000
|
||||
}
|
||||
}
|
||||
|
||||
# Repeated MUC+SM load after correcting test readiness timeout (same release binary)
|
||||
MUC+SM load: connected (5.193s)
|
||||
MUC+SM load: joined (51.901s)
|
||||
MUC+SM load: fanout_verified (62.762s)
|
||||
MUC+SM load: before_sigkill (67.577s)
|
||||
{
|
||||
"clients": 500,
|
||||
"accounts": 100,
|
||||
"rooms": 10,
|
||||
"message_rounds": 3,
|
||||
"concurrency": 20,
|
||||
"binary": "/tmp/xmpp-durable-muc-load-g0crm4ik/rust-xmpp",
|
||||
"binary_sha256": "74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a",
|
||||
"before": {
|
||||
"rss_kib": 9696,
|
||||
"cpu_seconds": 0.0,
|
||||
"fd_count": 12
|
||||
},
|
||||
"connect_seconds": 5.193,
|
||||
"connect_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 204.391,
|
||||
"p95_ms": 270.035,
|
||||
"max_ms": 332.503
|
||||
},
|
||||
"phase": "completed",
|
||||
"status": "pass",
|
||||
"elapsed_seconds": 67.577,
|
||||
"join_seconds": 46.703,
|
||||
"joined": {
|
||||
"rss_kib": 42248,
|
||||
"cpu_seconds": 17.77,
|
||||
"fd_count": 513
|
||||
},
|
||||
"join_presence_deliveries": 25000,
|
||||
"slots_joined": {
|
||||
"count": 500,
|
||||
"bytes": 309790
|
||||
},
|
||||
"message_seconds": 10.861,
|
||||
"room_messages_sent": 30,
|
||||
"room_messages_delivered": 1500,
|
||||
"before_kill": {
|
||||
"rss_kib": 42128,
|
||||
"cpu_seconds": 23.12,
|
||||
"fd_count": 514
|
||||
},
|
||||
"slots_before_kill": {
|
||||
"count": 500,
|
||||
"bytes": 514030
|
||||
},
|
||||
"restart_seconds": 10.559,
|
||||
"resumed_resources": 10,
|
||||
"replayed_pending_room_messages": 10,
|
||||
"membership_groupchat_deliveries_after_resume": 10,
|
||||
"after_resume": {
|
||||
"rss_kib": 21168,
|
||||
"cpu_seconds": 5.03,
|
||||
"fd_count": 23
|
||||
},
|
||||
"slots_after_resume": {
|
||||
"count": 500,
|
||||
"bytes": 671070
|
||||
},
|
||||
"total_seconds": 78.857,
|
||||
"peak_rss_kib": 42252
|
||||
}
|
||||
|
||||
# Native reference checks
|
||||
Finished `test` profile [unoptimized] target(s) in 0.09s
|
||||
Running tests/csi_native.rs (target/debug/deps/csi_native-7a0bbbead7a19df9)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_csi_importance_matches ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/muc_native.rs (target/debug/deps/muc_native-f792fb43c05f2836)
|
||||
|
||||
running 1 test
|
||||
test actual_prosody_muc_mam_reads_rust_and_writes_native_archive ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
|
||||
|
||||
Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-e120e43e0108ea7f)
|
||||
|
||||
running 1 test
|
||||
test actual_prosody_restores_rust_checkpoint_and_rust_reads_native_freeze ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
|
||||
|
||||
Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-77f6b485fd7811b4)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_orders_retained_pep_items_newest_first ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.13s
|
||||
|
||||
Running tests/preauth_native.rs (target/debug/deps/preauth_native-36f66c5afb8f3b7f)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_invitation_tokens_roundtrip ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
|
||||
|
||||
Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-5bfe7997d9ab4681)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_pep_reads_rust_and_rust_reads_native ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
|
||||
|
||||
Running tests/push.rs (target/debug/deps/push-e74594c0e96b1321)
|
||||
|
||||
running 1 test
|
||||
test native_push_body_lookup_excludes_extensions ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.01s
|
||||
|
||||
Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-fe62c7cdfcb258f2)
|
||||
|
||||
running 1 test
|
||||
PROSODY_NATIVE_ROUNDTRIP_OK SHA-1
|
||||
PROSODY_NATIVE_FINAL_OK SHA-1
|
||||
PROSODY_NATIVE_ROUNDTRIP_OK SHA-256
|
||||
PROSODY_NATIVE_FINAL_OK SHA-256
|
||||
test real_prosody_storage_and_auth_roundtrip ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.03s
|
||||
|
||||
Finished `test` profile [unoptimized] target(s) in 0.09s
|
||||
Running tests/federation_tls.rs (target/debug/deps/federation_tls-6dc128df24b929d8)
|
||||
|
||||
running 1 test
|
||||
test native_prosody_tls_external_bidirectional ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.88s
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"version": "0.7.9",
|
||||
"date_utc": "2026-10-20",
|
||||
"ordinary_tests": {
|
||||
"passed": 265,
|
||||
"failed": 0,
|
||||
"ignored": 11
|
||||
},
|
||||
"fmt": "passed",
|
||||
"clippy": "passed",
|
||||
"release": "passed",
|
||||
"native_module_tests": 8,
|
||||
"native_federation_tests": 1,
|
||||
"release_binary_sha256": "24c0dca637e7ce4e238ef6becad16bb5a18a33546554b0eecf310fd8927f021c",
|
||||
"upload": {
|
||||
"minio_10gib": {
|
||||
"ok": true,
|
||||
"minio_version": "minio-sandbox-compat version DEVELOPMENT.GOGET (commit-id=DEVELOPMENT.GOGET)\nRuntime: go1.24.3 linux/amd64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-0000 MinIO, Inc.",
|
||||
"minio_binary_sha256": "8bbb9bccfbac69869ab499c39cfd8731b2b770b86ccf37cca1c8267556ad9bf0",
|
||||
"binary": "/workspace/scratch/362577cdf2e0/recovered/rust-xmpp/target/release/rust-xmpp",
|
||||
"binary_version": "rust-xmpp 0.7.9 — experimental XMPP runtime",
|
||||
"binary_sha256": "24c0dca637e7ce4e238ef6becad16bb5a18a33546554b0eecf310fd8927f021c",
|
||||
"size": 10737418240,
|
||||
"qualifies_over_5gib": true,
|
||||
"minio_test_only_compatibility_patch": {
|
||||
"sha256": "e60592a65273f28a1375eb6ecbc0966f7fbd2ba7567d2ffedc13ffd95ed224f5",
|
||||
"diff": "--- a/cmd/net.go\n+++ b/cmd/net.go\n@@ -24,6 +24,7 @@\n \t\"runtime\"\n \t\"sort\"\n \t\"strings\"\n+\t\"syscall\"\n \n \t\"github.com/minio/minio-go/v7/pkg/set\"\n \t\"github.com/minio/minio/internal/config\"\n@@ -54,6 +55,10 @@\n // mustGetLocalIPs returns IPs of local interface\n func mustGetLocalIPs() (ipList []net.IP) {\n \tifs, err := net.Interfaces()\n+\t// TEST ONLY: the sandbox denies netlink enumeration; all listeners bind loopback.\n+\tif errors.Is(err, syscall.EPERM) {\n+\t\treturn []net.IP{net.ParseIP(\"127.0.0.1\")}\n+\t}\n \tlogger.FatalIf(err, \"Unable to get IP addresses of this host\")\n \n \tfor _, interf := range ifs {\n",
|
||||
"limitation": "Modified upstream MinIO: EPERM interface enumeration uses loopback; all S3/auth/storage code remains upstream."
|
||||
},
|
||||
"max_rss_bytes": {
|
||||
"server": 106057728,
|
||||
"client": 24338432,
|
||||
"minio": 498958336
|
||||
},
|
||||
"put_bytes": 10737418240,
|
||||
"put_seconds": 48.45253416699961,
|
||||
"sha256": "e4c296c41d3a9605f0348961e41ed835b5f42c220751ed2269a726951546c935",
|
||||
"minio_etag": "958f42d583ac40ff209eb865c5eb4bd6-1280",
|
||||
"multipart_parts": 1280,
|
||||
"minio_allocated_bytes": 10747953152,
|
||||
"free_disk_after_put_bytes": 15180328960,
|
||||
"get_bytes": 10737418240,
|
||||
"get_sha256": "e4c296c41d3a9605f0348961e41ed835b5f42c220751ed2269a726951546c935",
|
||||
"get_seconds": 13.709708632000911,
|
||||
"range_offsets": [
|
||||
0,
|
||||
4294967289,
|
||||
5368709113,
|
||||
10737418208
|
||||
],
|
||||
"restart_range_ok": true,
|
||||
"restart_reuse_status": 409,
|
||||
"crash_multipart_cleanup_ok": true,
|
||||
"minio_upstream_provenance": {
|
||||
"source_url": "https://github.com/minio/minio/archive/refs/tags/RELEASE.2025-04-22T22-12-26Z.tar.gz",
|
||||
"upstream_release": "RELEASE.2025-04-22T22-12-26Z",
|
||||
"build": "Go1.24.3 go build -p4; no dependency changes",
|
||||
"sha256": {
|
||||
"/tmp/minio-source.tar.gz": "1a63209927b9d1fb239b2a776b7b01d1902eff19379a108f4bc7005edcc0968c",
|
||||
"/tmp/minio-genuine": "3dd5d6756ad17c599fbc1c6f6a2f5b590242c049522e848c700949dbf8e60ad9",
|
||||
"/tmp/minio-sandbox-compat": "8bbb9bccfbac69869ab499c39cfd8731b2b770b86ccf37cca1c8267556ad9bf0",
|
||||
"/tmp/minio-sandbox-compat.patch": "e60592a65273f28a1375eb6ecbc0966f7fbd2ba7567d2ffedc13ffd95ed224f5"
|
||||
},
|
||||
"compatibility_limitation": "Only net.Interfaces EPERM becomes loopback127.0.0.1; all S3/auth/multipart/storage source unchanged."
|
||||
}
|
||||
},
|
||||
"migration_network": "passed: local -> S3 A -> S3 B -> local, dry-run, lock, native metadata/source preservation, interrupted copy/resume, native final file with incomplete cache",
|
||||
"s3_fault_network": "passed: SigV4, paginated exact-key orphan cleanup, foreign prefix safety, missing UploadId crash recovery, conditional Complete, ambiguous completion quarantine and late recovery",
|
||||
"claim_race_regression": "passed: atomic fresh snapshot and active claim retains latest completion/quarantine flags"
|
||||
},
|
||||
"load": {
|
||||
"total_load_wall_seconds": 7.094,
|
||||
"requested_resources": 500,
|
||||
"authenticated_bound_resources": 500,
|
||||
"connection_setup_concurrency": 50,
|
||||
"setup_wall_seconds": 0.671,
|
||||
"setup_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 62.192,
|
||||
"p95_ms": 83.883,
|
||||
"max_ms": 114.563
|
||||
},
|
||||
"ping_rounds": 10,
|
||||
"ping_round_trip": {
|
||||
"samples": 5000,
|
||||
"median_ms": 13.502,
|
||||
"p95_ms": 19.31,
|
||||
"max_ms": 24.054
|
||||
},
|
||||
"accounts": 100,
|
||||
"message_rounds": 10,
|
||||
"messages_sent": 5000,
|
||||
"messages_received": 5000,
|
||||
"message_batch_seconds": 4.134,
|
||||
"metrics_before": {
|
||||
"rss_kib": 8048,
|
||||
"cpu_seconds": 0.0,
|
||||
"fd_count": 11
|
||||
},
|
||||
"metrics_connected": {
|
||||
"rss_kib": 26804,
|
||||
"cpu_seconds": 1.02,
|
||||
"fd_count": 511
|
||||
},
|
||||
"metrics_after_workload": {
|
||||
"rss_kib": 27400,
|
||||
"cpu_seconds": 8.85,
|
||||
"fd_count": 511
|
||||
},
|
||||
"metrics_two_seconds_after_disconnect": {
|
||||
"rss_kib": 27464,
|
||||
"cpu_seconds": 8.9,
|
||||
"fd_count": 11
|
||||
},
|
||||
"sampled_peak_rss_kib": 27464,
|
||||
"fd_delta_after_disconnect": 0,
|
||||
"failures": [],
|
||||
"scope": "One loopback host, 100 accounts with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks.",
|
||||
"mam": {
|
||||
"archive_files": 100,
|
||||
"records": 10000,
|
||||
"expected_records": 10000
|
||||
}
|
||||
},
|
||||
"limitations": [
|
||||
"MinIO upstream RELEASE.2025-04-22T22-12-26Z with disclosed five-line EPERM loopback interface enumeration adapter; S3/auth/storage/multipart code unchanged",
|
||||
"Single-node loopback MinIO, HTTP gateway-to-store and verified HTTPS client-to-gateway; no AWS account or distributed/production storage qualification",
|
||||
"Missing/corrupt ownership metadata cannot identify arbitrary bucket orphans; lifecycle remains defense in depth",
|
||||
"Ambiguous completion with HEAD404 quarantines slot until a trustworthy remote outcome; no automatic destructive reset",
|
||||
"Migration is offline, source preserving, requires temporary disk up to one largest file; no live migration",
|
||||
"Previous full Prosody parity / production client / DNSSEC limits unchanged"
|
||||
]
|
||||
}
|
||||
+67
-112
@@ -1,133 +1,88 @@
|
||||
{
|
||||
"version": "0.7.9",
|
||||
"version": "0.8.0",
|
||||
"date_utc": "2026-10-20",
|
||||
"ordinary_tests": {
|
||||
"passed": 265,
|
||||
"passed": 293,
|
||||
"failed": 0,
|
||||
"ignored": 11
|
||||
},
|
||||
"fmt": "passed",
|
||||
"clippy": "passed",
|
||||
"release": "passed",
|
||||
"release_binary_sha256": "74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a",
|
||||
"native_module_tests": 8,
|
||||
"native_federation_tests": 1,
|
||||
"release_binary_sha256": "24c0dca637e7ce4e238ef6becad16bb5a18a33546554b0eecf310fd8927f021c",
|
||||
"upload": {
|
||||
"minio_10gib": {
|
||||
"ok": true,
|
||||
"minio_version": "minio-sandbox-compat version DEVELOPMENT.GOGET (commit-id=DEVELOPMENT.GOGET)\nRuntime: go1.24.3 linux/amd64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-0000 MinIO, Inc.",
|
||||
"minio_binary_sha256": "8bbb9bccfbac69869ab499c39cfd8731b2b770b86ccf37cca1c8267556ad9bf0",
|
||||
"binary": "/workspace/scratch/362577cdf2e0/recovered/rust-xmpp/target/release/rust-xmpp",
|
||||
"binary_version": "rust-xmpp 0.7.9 — experimental XMPP runtime",
|
||||
"binary_sha256": "24c0dca637e7ce4e238ef6becad16bb5a18a33546554b0eecf310fd8927f021c",
|
||||
"size": 10737418240,
|
||||
"qualifies_over_5gib": true,
|
||||
"minio_test_only_compatibility_patch": {
|
||||
"sha256": "e60592a65273f28a1375eb6ecbc0966f7fbd2ba7567d2ffedc13ffd95ed224f5",
|
||||
"diff": "--- a/cmd/net.go\n+++ b/cmd/net.go\n@@ -24,6 +24,7 @@\n \t\"runtime\"\n \t\"sort\"\n \t\"strings\"\n+\t\"syscall\"\n \n \t\"github.com/minio/minio-go/v7/pkg/set\"\n \t\"github.com/minio/minio/internal/config\"\n@@ -54,6 +55,10 @@\n // mustGetLocalIPs returns IPs of local interface\n func mustGetLocalIPs() (ipList []net.IP) {\n \tifs, err := net.Interfaces()\n+\t// TEST ONLY: the sandbox denies netlink enumeration; all listeners bind loopback.\n+\tif errors.Is(err, syscall.EPERM) {\n+\t\treturn []net.IP{net.ParseIP(\"127.0.0.1\")}\n+\t}\n \tlogger.FatalIf(err, \"Unable to get IP addresses of this host\")\n \n \tfor _, interf := range ifs {\n",
|
||||
"limitation": "Modified upstream MinIO: EPERM interface enumeration uses loopback; all S3/auth/storage code remains upstream."
|
||||
},
|
||||
"max_rss_bytes": {
|
||||
"server": 106057728,
|
||||
"client": 24338432,
|
||||
"minio": 498958336
|
||||
},
|
||||
"put_bytes": 10737418240,
|
||||
"put_seconds": 48.45253416699961,
|
||||
"sha256": "e4c296c41d3a9605f0348961e41ed835b5f42c220751ed2269a726951546c935",
|
||||
"minio_etag": "958f42d583ac40ff209eb865c5eb4bd6-1280",
|
||||
"multipart_parts": 1280,
|
||||
"minio_allocated_bytes": 10747953152,
|
||||
"free_disk_after_put_bytes": 15180328960,
|
||||
"get_bytes": 10737418240,
|
||||
"get_sha256": "e4c296c41d3a9605f0348961e41ed835b5f42c220751ed2269a726951546c935",
|
||||
"get_seconds": 13.709708632000911,
|
||||
"range_offsets": [
|
||||
0,
|
||||
4294967289,
|
||||
5368709113,
|
||||
10737418208
|
||||
],
|
||||
"restart_range_ok": true,
|
||||
"restart_reuse_status": 409,
|
||||
"crash_multipart_cleanup_ok": true,
|
||||
"minio_upstream_provenance": {
|
||||
"source_url": "https://github.com/minio/minio/archive/refs/tags/RELEASE.2025-04-22T22-12-26Z.tar.gz",
|
||||
"upstream_release": "RELEASE.2025-04-22T22-12-26Z",
|
||||
"build": "Go1.24.3 go build -p4; no dependency changes",
|
||||
"sha256": {
|
||||
"/tmp/minio-source.tar.gz": "1a63209927b9d1fb239b2a776b7b01d1902eff19379a108f4bc7005edcc0968c",
|
||||
"/tmp/minio-genuine": "3dd5d6756ad17c599fbc1c6f6a2f5b590242c049522e848c700949dbf8e60ad9",
|
||||
"/tmp/minio-sandbox-compat": "8bbb9bccfbac69869ab499c39cfd8731b2b770b86ccf37cca1c8267556ad9bf0",
|
||||
"/tmp/minio-sandbox-compat.patch": "e60592a65273f28a1375eb6ecbc0966f7fbd2ba7567d2ffedc13ffd95ed224f5"
|
||||
},
|
||||
"compatibility_limitation": "Only net.Interfaces EPERM becomes loopback127.0.0.1; all S3/auth/multipart/storage source unchanged."
|
||||
}
|
||||
},
|
||||
"migration_network": "passed: local -> S3 A -> S3 B -> local, dry-run, lock, native metadata/source preservation, interrupted copy/resume, native final file with incomplete cache",
|
||||
"s3_fault_network": "passed: SigV4, paginated exact-key orphan cleanup, foreign prefix safety, missing UploadId crash recovery, conditional Complete, ambiguous completion quarantine and late recovery",
|
||||
"claim_race_regression": "passed: atomic fresh snapshot and active claim retains latest completion/quarantine flags"
|
||||
},
|
||||
"load": {
|
||||
"total_load_wall_seconds": 7.094,
|
||||
"requested_resources": 500,
|
||||
"authenticated_bound_resources": 500,
|
||||
"connection_setup_concurrency": 50,
|
||||
"setup_wall_seconds": 0.671,
|
||||
"setup_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 62.192,
|
||||
"p95_ms": 83.883,
|
||||
"max_ms": 114.563
|
||||
},
|
||||
"ping_rounds": 10,
|
||||
"ping_round_trip": {
|
||||
"samples": 5000,
|
||||
"median_ms": 13.502,
|
||||
"p95_ms": 19.31,
|
||||
"max_ms": 24.054
|
||||
},
|
||||
"muc_crash_network": "passed: real TLS, SIGKILL, temporary/persistent rooms, multiple resources, saved presence/roles, ordered history, acknowledged leave/kick/demotion/password/config, fixed grace, unsupported module rejection; destroy/tombstone separate log",
|
||||
"sm_restart_network": "passed: SIGKILL/SIGTERM, legacy/SASL2, counter/replay/ack pruning, owner/token/h isolation, resource conflict, account deletion/recreation revocation, MUC offline fanout durable before recipient authentication",
|
||||
"muc_sm_load": {
|
||||
"clients": 500,
|
||||
"accounts": 100,
|
||||
"message_rounds": 10,
|
||||
"messages_sent": 5000,
|
||||
"messages_received": 5000,
|
||||
"message_batch_seconds": 4.134,
|
||||
"metrics_before": {
|
||||
"rss_kib": 8048,
|
||||
"rooms": 10,
|
||||
"message_rounds": 3,
|
||||
"concurrency": 20,
|
||||
"binary": "/tmp/xmpp-durable-muc-load-g0crm4ik/rust-xmpp",
|
||||
"binary_sha256": "74882319fcb467dd1e1813ab61e85441ffd3dae2f9f7961b28fd98ec5da7c89a",
|
||||
"before": {
|
||||
"rss_kib": 9696,
|
||||
"cpu_seconds": 0.0,
|
||||
"fd_count": 11
|
||||
"fd_count": 12
|
||||
},
|
||||
"metrics_connected": {
|
||||
"rss_kib": 26804,
|
||||
"cpu_seconds": 1.02,
|
||||
"fd_count": 511
|
||||
"connect_seconds": 5.193,
|
||||
"connect_latency": {
|
||||
"samples": 500,
|
||||
"median_ms": 204.391,
|
||||
"p95_ms": 270.035,
|
||||
"max_ms": 332.503
|
||||
},
|
||||
"metrics_after_workload": {
|
||||
"rss_kib": 27400,
|
||||
"cpu_seconds": 8.85,
|
||||
"fd_count": 511
|
||||
"phase": "completed",
|
||||
"status": "pass",
|
||||
"elapsed_seconds": 78.857,
|
||||
"join_seconds": 46.703,
|
||||
"joined": {
|
||||
"rss_kib": 42248,
|
||||
"cpu_seconds": 17.77,
|
||||
"fd_count": 513
|
||||
},
|
||||
"metrics_two_seconds_after_disconnect": {
|
||||
"rss_kib": 27464,
|
||||
"cpu_seconds": 8.9,
|
||||
"fd_count": 11
|
||||
"join_presence_deliveries": 25000,
|
||||
"slots_joined": {
|
||||
"count": 500,
|
||||
"bytes": 309790
|
||||
},
|
||||
"sampled_peak_rss_kib": 27464,
|
||||
"fd_delta_after_disconnect": 0,
|
||||
"failures": [],
|
||||
"scope": "One loopback host, 100 accounts with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks.",
|
||||
"mam": {
|
||||
"archive_files": 100,
|
||||
"records": 10000,
|
||||
"expected_records": 10000
|
||||
}
|
||||
"message_seconds": 10.861,
|
||||
"room_messages_sent": 30,
|
||||
"room_messages_delivered": 1500,
|
||||
"before_kill": {
|
||||
"rss_kib": 42128,
|
||||
"cpu_seconds": 23.12,
|
||||
"fd_count": 514
|
||||
},
|
||||
"slots_before_kill": {
|
||||
"count": 500,
|
||||
"bytes": 514030
|
||||
},
|
||||
"restart_seconds": 10.559,
|
||||
"resumed_resources": 10,
|
||||
"replayed_pending_room_messages": 10,
|
||||
"membership_groupchat_deliveries_after_resume": 10,
|
||||
"after_resume": {
|
||||
"rss_kib": 21168,
|
||||
"cpu_seconds": 5.03,
|
||||
"fd_count": 23
|
||||
},
|
||||
"slots_after_resume": {
|
||||
"count": 500,
|
||||
"bytes": 671070
|
||||
},
|
||||
"total_seconds": 78.857,
|
||||
"peak_rss_kib": 42252
|
||||
},
|
||||
"upload": "S3 fixture/migration/network regression passed; large 10GiB transfer retained as historical0.7.9 validation, not repeated0.8.0",
|
||||
"limitations": [
|
||||
"MinIO upstream RELEASE.2025-04-22T22-12-26Z with disclosed five-line EPERM loopback interface enumeration adapter; S3/auth/storage/multipart code unchanged",
|
||||
"Single-node loopback MinIO, HTTP gateway-to-store and verified HTTPS client-to-gateway; no AWS account or distributed/production storage qualification",
|
||||
"Missing/corrupt ownership metadata cannot identify arbitrary bucket orphans; lifecycle remains defense in depth",
|
||||
"Ambiguous completion with HEAD404 quarantines slot until a trustworthy remote outcome; no automatic destructive reset",
|
||||
"Migration is offline, source preserving, requires temporary disk up to one largest file; no live migration",
|
||||
"Previous full Prosody parity / production client / DNSSEC limits unchanged"
|
||||
"New TLS/SASL connection required; TCP/TLS sockets cannot survive process exit",
|
||||
"At-least-once SM replay, not cross-store/network exactly-once transaction",
|
||||
"Known builtin MUC features ported; arbitrary community Lua modules not executed",
|
||||
"Private durable SM format is Rust-specific, bounded queues and component checkpoint; see MUC-RECOVERY.md",
|
||||
"Loopback smoke tests, not production capacity or real client qualification",
|
||||
"Full Prosody parity remains subject to COMPATIBILITY.md"
|
||||
]
|
||||
}
|
||||
@@ -27,6 +27,8 @@ python3 tests/pep_order_network.py target/release/rust-xmpp
|
||||
python3 tests/pep_notifications_network.py target/release/rust-xmpp
|
||||
python3 tests/preauth_network.py target/release/rust-xmpp
|
||||
python3 tests/muc_restart_network.py target/release/rust-xmpp
|
||||
python3 tests/muc_crash_network.py target/release/rust-xmpp
|
||||
python3 tests/sm_restart_network.py target/release/rust-xmpp
|
||||
python3 tests/resource_rebind.py target/release/rust-xmpp
|
||||
python3 scripts/integration_http.py
|
||||
python3 scripts/integration_http.py --custom-upload-path
|
||||
@@ -39,6 +41,7 @@ if [[ -n ${MINIO_BINARY:-} ]]; then
|
||||
fi
|
||||
if [[ ${XMPP_LOAD_CHECK:-0} == 1 ]]; then
|
||||
python3 scripts/load_smoke.py --clients 500 --accounts 100 --rounds 10 --message-rounds 10 --archive
|
||||
python3 tests/muc_sm_load_network.py --binary target/release/rust-xmpp --clients 500
|
||||
fi
|
||||
if [[ -n ${PROSODY_REFERENCE_SOURCE:-} ]]; then
|
||||
RUSTUP_TOOLCHAIN=${RUSTUP_TOOLCHAIN:-1.99.1} scripts/prosody_compat/verify.sh \
|
||||
|
||||
+29
@@ -11,7 +11,36 @@ pub struct Buffer {
|
||||
pub resume_delay: Duration,
|
||||
pub important_payloads: Vec<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct DurableBuffer {
|
||||
inactive: bool,
|
||||
queue: Vec<String>,
|
||||
limit: usize,
|
||||
resume_delay_ms: u64,
|
||||
important_payloads: Vec<String>,
|
||||
}
|
||||
impl Buffer {
|
||||
pub(crate) fn durable(&self) -> DurableBuffer {
|
||||
DurableBuffer {
|
||||
inactive: self.inactive,
|
||||
queue: self.queue.clone(),
|
||||
limit: self.limit,
|
||||
resume_delay_ms: self.resume_delay.as_millis().min(u64::MAX as u128) as u64,
|
||||
important_payloads: self.important_payloads.clone(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn from_durable(value: DurableBuffer) -> Self {
|
||||
Self {
|
||||
inactive: value.inactive,
|
||||
flushing_until: None,
|
||||
bytes: value.queue.iter().map(String::len).sum(),
|
||||
queue: value.queue,
|
||||
limit: value.limit,
|
||||
resume_delay: Duration::from_millis(value.resume_delay_ms),
|
||||
important_payloads: value.important_payloads,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, limit: usize, delay: Duration, payloads: Vec<String>) {
|
||||
self.limit = limit.max(1);
|
||||
self.resume_delay = delay;
|
||||
|
||||
@@ -51,3 +51,5 @@ pub mod http_altconnect;
|
||||
pub mod object_store;
|
||||
|
||||
pub mod upload_migration;
|
||||
|
||||
pub mod session_store;
|
||||
+790
-76
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,319 @@
|
||||
//! The two native MUC component administration commands. Authority belongs to
|
||||
//! configured component administrators and is checked again on every step.
|
||||
use super::*;
|
||||
const COMMANDS: &str = "http://jabber.org/protocol/commands";
|
||||
const DESTROY: &str = "http://prosody.im/protocol/muc#destroy";
|
||||
const AFFILIATION: &str = "http://prosody.im/protocol/muc#set-affiliation";
|
||||
const INFO: &str = "http://jabber.org/protocol/disco#info";
|
||||
const ITEMS: &str = "http://jabber.org/protocol/disco#items";
|
||||
|
||||
pub(super) fn period(value: Option<&Value>) -> Option<f64> {
|
||||
let seconds = if let Some(number) = value.and_then(Value::as_f64) {
|
||||
number
|
||||
} else {
|
||||
let text = value?.as_str()?.trim();
|
||||
let split = text
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '+' || c == '-'))
|
||||
.unwrap_or(text.len());
|
||||
let number: f64 = text[..split].trim().parse().ok()?;
|
||||
let unit = text[split..].trim();
|
||||
number
|
||||
* match unit {
|
||||
"" | "s" | "second" | "seconds" => 1.0,
|
||||
"m" | "minute" | "minutes" => 60.0,
|
||||
"h" | "hour" | "hours" => 3600.0,
|
||||
"d" | "day" | "days" => 86400.0,
|
||||
"w" | "week" | "weeks" => 604800.0,
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
(seconds.is_finite() && seconds >= 0.0).then_some(seconds)
|
||||
}
|
||||
pub(super) fn registration_form(nick: Option<&str>) -> String {
|
||||
format!(
|
||||
"<x xmlns='jabber:x:data' type='{}'><field var='FORM_TYPE' type='hidden'><value>http://jabber.org/protocol/muc#register</value></field><field var='muc#register_roomnick' type='text-single'>{}<required/></field></x>",
|
||||
if nick.is_some() { "result" } else { "form" },
|
||||
nick.map(|v| format!("<value>{}</value>", escape(v)))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
fn command_reply(
|
||||
stanza: roxmltree::Node,
|
||||
node: &str,
|
||||
session: &str,
|
||||
status: &str,
|
||||
body: &str,
|
||||
) -> ModuleResult {
|
||||
ModuleResult::reply(iq_result(
|
||||
stanza,
|
||||
&format!(
|
||||
"<command xmlns='{COMMANDS}' node='{}' sessionid='{}' status='{status}'>{body}</command>",
|
||||
escape(node),
|
||||
escape(session)
|
||||
),
|
||||
))
|
||||
}
|
||||
fn form(muc: &Muc, node: &str) -> String {
|
||||
let fields = if node == DESTROY {
|
||||
let rooms = muc
|
||||
.rooms
|
||||
.keys()
|
||||
.map(|room| {
|
||||
format!(
|
||||
"<option label='{}'><value>{}</value></option>",
|
||||
escape(room),
|
||||
escape(room)
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
format!(
|
||||
"<field var='rooms' type='list-multi' label='Rooms to destroy'><required/>{rooms}</field>"
|
||||
)
|
||||
} else {
|
||||
"<field var='room' type='jid-single' label='Room'><required/></field><field var='jid' type='jid-single' label='JID'><required/></field><field var='affiliation' type='list-single' label='Affiliation'><required/><option><value>owner</value></option><option><value>admin</value></option><option><value>member</value></option><option><value>none</value></option><option><value>outcast</value></option></field><field var='reason' type='text-single' label='Reason'/>".to_owned()
|
||||
};
|
||||
format!(
|
||||
"<actions execute='complete'><complete/></actions><x xmlns='jabber:x:data' type='form'><title>{}</title><field var='FORM_TYPE' type='hidden'><value>{node}</value></field>{fields}</x>",
|
||||
if node == DESTROY {
|
||||
"Destroy rooms"
|
||||
} else {
|
||||
"Set affiliation"
|
||||
}
|
||||
)
|
||||
}
|
||||
pub(super) fn handle(
|
||||
muc: &mut Muc,
|
||||
jid: &str,
|
||||
stanza: roxmltree::Node,
|
||||
) -> Result<Option<ModuleResult>> {
|
||||
if stanza.tag_name().name() != "iq" {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(query) = stanza.children().find(|n| n.is_element()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let ns = query.tag_name().namespace().unwrap_or("");
|
||||
let node = query.attribute("node").unwrap_or("");
|
||||
let is_admin = muc
|
||||
.component_admins
|
||||
.iter()
|
||||
.any(|admin| bare(admin) == bare(jid));
|
||||
if matches!(ns, INFO | ITEMS)
|
||||
&& (node == COMMANDS || matches!(node, DESTROY | AFFILIATION))
|
||||
&& stanza.attribute("type") == Some("get")
|
||||
{
|
||||
let body = if ns == ITEMS && node == COMMANDS {
|
||||
if is_admin {
|
||||
format!(
|
||||
"<item jid='{}' node='{DESTROY}' name='Destroy Rooms'/><item jid='{}' node='{AFFILIATION}' name='Set affiliation in room'/>",
|
||||
escape(&muc.domain),
|
||||
escape(&muc.domain)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else if !is_admin && node != COMMANDS {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden"))));
|
||||
} else if ns == INFO {
|
||||
format!(
|
||||
"<identity category='automation' type='{}'/><feature var='{COMMANDS}'/><feature var='jabber:x:data'/>",
|
||||
if node == COMMANDS {
|
||||
"command-list"
|
||||
} else {
|
||||
"command-node"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return Ok(Some(ModuleResult::reply(iq_result(
|
||||
stanza,
|
||||
&format!("<query xmlns='{ns}' node='{}'>{body}</query>", escape(node)),
|
||||
))));
|
||||
}
|
||||
if ns != COMMANDS || query.tag_name().name() != "command" {
|
||||
return Ok(None);
|
||||
}
|
||||
if !is_admin {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden"))));
|
||||
}
|
||||
if stanza.attribute("type") != Some("set") {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
if !matches!(node, DESTROY | AFFILIATION) {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(
|
||||
stanza,
|
||||
"item-not-found",
|
||||
))));
|
||||
}
|
||||
muc.command_sessions
|
||||
.retain(|_, (_, _, started)| started.elapsed() < std::time::Duration::from_secs(600));
|
||||
let session = query.attribute("sessionid");
|
||||
let action = query.attribute("action").unwrap_or("execute");
|
||||
if session.is_none() {
|
||||
if action != "execute"
|
||||
|| query
|
||||
.children()
|
||||
.any(|n| n.has_tag_name(("jabber:x:data", "x")))
|
||||
{
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
if muc.command_sessions.len() >= 1000 {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(
|
||||
stanza,
|
||||
"resource-constraint",
|
||||
))));
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
muc.command_sessions.insert(
|
||||
id.clone(),
|
||||
(
|
||||
bare(jid).to_owned(),
|
||||
node.to_owned(),
|
||||
std::time::Instant::now(),
|
||||
),
|
||||
);
|
||||
return Ok(Some(command_reply(
|
||||
stanza,
|
||||
node,
|
||||
&id,
|
||||
"executing",
|
||||
&form(muc, node),
|
||||
)));
|
||||
}
|
||||
let session = session.unwrap();
|
||||
if !muc
|
||||
.command_sessions
|
||||
.get(session)
|
||||
.is_some_and(|(owner, command, _)| owner == bare(jid) && command == node)
|
||||
{
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
if action == "cancel" {
|
||||
muc.command_sessions.remove(session);
|
||||
return Ok(Some(command_reply(stanza, node, session, "canceled", "")));
|
||||
}
|
||||
if !matches!(action, "execute" | "complete") {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
let Some(data) = query
|
||||
.children()
|
||||
.find(|n| n.has_tag_name(("jabber:x:data", "x")) && n.attribute("type") == Some("submit"))
|
||||
else {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
};
|
||||
let values = |name: &str| {
|
||||
data.children()
|
||||
.filter(|f| {
|
||||
f.has_tag_name(("jabber:x:data", "field")) && f.attribute("var") == Some(name)
|
||||
})
|
||||
.flat_map(|f| {
|
||||
f.children()
|
||||
.filter(|v| v.has_tag_name(("jabber:x:data", "value")))
|
||||
.filter_map(|v| v.text())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if values("FORM_TYPE") != vec![node] {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
let mut out = ModuleResult::default();
|
||||
if node == DESTROY {
|
||||
let rooms = values("rooms");
|
||||
if rooms.is_empty()
|
||||
|| rooms
|
||||
.iter()
|
||||
.any(|room| bare(room) != *room || !room.ends_with(&format!("@{}", muc.domain)))
|
||||
{
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
for name in rooms {
|
||||
muc.load(name)?;
|
||||
if !muc.rooms.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
// Only this internal, ACL-checked command bypasses room ownership.
|
||||
let packet = format!(
|
||||
"<iq type='set' to='{}'><query xmlns='{OWNER}'><destroy/></query></iq>",
|
||||
escape(name)
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&packet)?;
|
||||
if let Some(mut destroyed) =
|
||||
muc.handle_inner_authorized(jid, doc.root_element(), true)?
|
||||
{
|
||||
out.deliveries.append(&mut destroyed.deliveries);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let rooms = values("room");
|
||||
let targets = values("jid");
|
||||
let affiliations = values("affiliation");
|
||||
if rooms.len() != 1 || targets.len() != 1 || affiliations.len() != 1 {
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
let name = rooms[0];
|
||||
let target = targets[0];
|
||||
let affiliation = affiliations[0];
|
||||
if bare(name) != name
|
||||
|| !name.ends_with(&format!("@{}", muc.domain))
|
||||
|| crate::account_modules::normalize_bare_jid(bare(target)).is_err()
|
||||
|| !matches!(
|
||||
affiliation,
|
||||
"none" | "member" | "admin" | "owner" | "outcast"
|
||||
)
|
||||
{
|
||||
return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request"))));
|
||||
}
|
||||
muc.load(name)?;
|
||||
let Some(room) = muc.rooms.get_mut(name) else {
|
||||
return Ok(Some(command_reply(
|
||||
stanza,
|
||||
node,
|
||||
session,
|
||||
"canceled",
|
||||
"<note type='error'>No such room</note>",
|
||||
)));
|
||||
};
|
||||
let reason = values("reason").first().copied().unwrap_or("");
|
||||
let packet = format!(
|
||||
"<iq type='set'><query xmlns='{ADMIN}'><item jid='{}' affiliation='{affiliation}'><reason>{}</reason></item></query></iq>",
|
||||
escape(&crate::account_modules::normalize_bare_jid(bare(target))?),
|
||||
escape(reason)
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&packet)?;
|
||||
let iq = doc.root_element();
|
||||
let mut applied = ModuleResult::default();
|
||||
admin_with_authority(
|
||||
room,
|
||||
name,
|
||||
jid,
|
||||
iq,
|
||||
iq.children().find(|n| n.is_element()).unwrap(),
|
||||
&mut applied,
|
||||
true,
|
||||
);
|
||||
if applied.replies.iter().any(|p| p.contains("type='error'")) {
|
||||
return Ok(Some(command_reply(
|
||||
stanza,
|
||||
node,
|
||||
session,
|
||||
"canceled",
|
||||
"<note type='error'>Affiliation change failed</note>",
|
||||
)));
|
||||
}
|
||||
out.deliveries.append(&mut applied.deliveries);
|
||||
muc.save(name)?;
|
||||
}
|
||||
muc.command_sessions.remove(session);
|
||||
out.replies.append(
|
||||
&mut command_reply(
|
||||
stanza,
|
||||
node,
|
||||
session,
|
||||
"completed",
|
||||
"<note type='info'>Command completed</note>",
|
||||
)
|
||||
.replies,
|
||||
);
|
||||
Ok(Some(out))
|
||||
}
|
||||
+59
-5
@@ -66,14 +66,14 @@ pub struct RoomHistory {
|
||||
impl RoomHistory {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
capacity: capacity.min(4096),
|
||||
default_messages: 20,
|
||||
entries: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
pub fn set_capacity(&mut self, capacity: usize) {
|
||||
self.capacity = capacity;
|
||||
while self.entries.len() > capacity {
|
||||
self.capacity = capacity.min(4096);
|
||||
while self.entries.len() > self.capacity {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ impl RoomHistory {
|
||||
/// Add one room broadcast, before per-recipient routing. Subject-only changes
|
||||
/// are sent separately on join and do not consume discussion history slots.
|
||||
pub fn push(&mut self, stanza: &str, timestamp: i64) {
|
||||
if self.capacity == 0 {
|
||||
if self.capacity == 0 || stanza.len() > 16 * 1024 {
|
||||
return;
|
||||
}
|
||||
let Ok(doc) = roxmltree::Document::parse(stanza) else {
|
||||
@@ -132,7 +132,7 @@ impl RoomHistory {
|
||||
}
|
||||
/// Restore a snapshot stanza that already carries its original delay.
|
||||
pub fn restore_last_message(&mut self, stanza: &str, timestamp: i64) {
|
||||
if self.capacity == 0 {
|
||||
if self.capacity == 0 || stanza.len() > 16 * 1024 {
|
||||
return;
|
||||
}
|
||||
let Ok(doc) = roxmltree::Document::parse(stanza) else {
|
||||
@@ -148,6 +148,60 @@ impl RoomHistory {
|
||||
});
|
||||
}
|
||||
|
||||
/// Lossless bounded crash snapshot, including original delay stamps.
|
||||
pub fn checkpoint(&self) -> crate::storage::LuaValue {
|
||||
use crate::storage::LuaValue as V;
|
||||
let mut value = V::table();
|
||||
for (i, entry) in self.entries.iter().enumerate() {
|
||||
let mut item = V::table();
|
||||
item.set("stanza", entry.stanza.clone().into());
|
||||
item.set("timestamp", V::Integer(entry.timestamp));
|
||||
value.set_key(V::Integer(i as i64 + 1), item);
|
||||
}
|
||||
value
|
||||
}
|
||||
pub fn restore_checkpoint(&mut self, value: &crate::storage::LuaValue) {
|
||||
self.entries.clear();
|
||||
// Lua pairs iteration is not sequence order, including numeric keys.
|
||||
let mut ordered = value
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|(key, item)| {
|
||||
key.as_i64()
|
||||
.filter(|index| *index > 0)
|
||||
.map(|index| (index, item))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ordered.sort_by_key(|(index, _)| *index);
|
||||
for (_, item) in ordered.into_iter().take(4096) {
|
||||
let Some(stanza) = item
|
||||
.get("stanza")
|
||||
.and_then(crate::storage::LuaValue::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(timestamp) = item
|
||||
.get("timestamp")
|
||||
.and_then(crate::storage::LuaValue::as_i64)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if stanza.len() > 16 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let Ok(doc) = roxmltree::Document::parse(stanza) else {
|
||||
continue;
|
||||
};
|
||||
if is_historic(doc.root_element()) {
|
||||
self.entries.push_back(Entry {
|
||||
stanza: stanza.into(),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.set_capacity(self.capacity);
|
||||
}
|
||||
|
||||
pub fn replay(&self, _room: &str, to: &str, presence: &str, now: i64) -> Vec<String> {
|
||||
let request = HistoryRequest::parse(presence, now);
|
||||
let max = request.maxstanzas.unwrap_or_else(|| {
|
||||
|
||||
+137
-20
@@ -57,7 +57,7 @@ fn safe_payload(v: &V) -> String {
|
||||
if child
|
||||
.get("attr")
|
||||
.and_then(|a| field(a, "xmlns"))
|
||||
.is_some_and(|ns| ns != "jabber:client")
|
||||
.is_some_and(|ns| !matches!(ns, "" | "jabber:client"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -71,6 +71,17 @@ fn safe_payload(v: &V) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Keep the crash image transport-independent and exclude passwords/extensions.
|
||||
pub fn sanitize_presence_payload(payload: &str) -> String {
|
||||
if payload.len() > MAX_PRESENCE {
|
||||
return String::new();
|
||||
}
|
||||
let packet = format!("<presence>{payload}</presence>");
|
||||
let Ok(doc) = roxmltree::Document::parse(&packet) else {
|
||||
return String::new();
|
||||
};
|
||||
safe_payload(&crate::storage::stanza_value(doc.root_element()))
|
||||
}
|
||||
/// Decode Prosody's state store (and old frozen room maps) without trusting roles,
|
||||
/// primary-session pointers, or unvalidated stanza extensions.
|
||||
pub fn decode_room(room: &str, value: &V) -> Result<Vec<OccupantSnapshot>> {
|
||||
@@ -235,11 +246,29 @@ pub fn save_room_checkpoint(
|
||||
}
|
||||
|
||||
/// Dormant claims have a fixed restart deadline; probes never extend it.
|
||||
#[derive(Default)]
|
||||
#[derive(Clone)]
|
||||
pub struct PendingOccupants {
|
||||
rooms: BTreeMap<String, (u64, Vec<OccupantSnapshot>)>,
|
||||
deadlines: BTreeMap<(String, String), u64>,
|
||||
grace_seconds: u64,
|
||||
}
|
||||
impl Default for PendingOccupants {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rooms: BTreeMap::new(),
|
||||
deadlines: BTreeMap::new(),
|
||||
grace_seconds: RECOVERY_SECONDS,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PendingOccupants {
|
||||
pub fn set_grace_seconds(&mut self, seconds: u64) {
|
||||
self.grace_seconds = seconds.clamp(1, 7 * 86400);
|
||||
}
|
||||
pub fn grace_seconds(&self) -> u64 {
|
||||
self.grace_seconds
|
||||
}
|
||||
|
||||
pub fn insert(
|
||||
&mut self,
|
||||
room: String,
|
||||
@@ -252,8 +281,15 @@ impl PendingOccupants {
|
||||
<= MAX_OCCUPANTS,
|
||||
"pending recovery limit"
|
||||
);
|
||||
self.deadlines.retain(|(name, _), _| name != &room);
|
||||
for occupant in &occupants {
|
||||
self.deadlines.insert(
|
||||
(room.clone(), occupant.jid.clone()),
|
||||
now.saturating_add(self.grace_seconds),
|
||||
);
|
||||
}
|
||||
self.rooms
|
||||
.insert(room, (now.saturating_add(RECOVERY_SECONDS), occupants));
|
||||
.insert(room, (now.saturating_add(self.grace_seconds), occupants));
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve a checkpoint's original deadline over repeated process restarts.
|
||||
@@ -264,7 +300,7 @@ impl PendingOccupants {
|
||||
saved_at: u64,
|
||||
now: u64,
|
||||
) -> Result<()> {
|
||||
if saved_at > now || saved_at.saturating_add(RECOVERY_SECONDS) <= now {
|
||||
if saved_at > now || saved_at.saturating_add(self.grace_seconds) <= now {
|
||||
return Ok(());
|
||||
}
|
||||
self.insert(room, occupants, saved_at)
|
||||
@@ -285,6 +321,8 @@ impl PendingOccupants {
|
||||
.iter()
|
||||
.position(|o| o.jid == authenticated_jid && o.nick == nick)?;
|
||||
let mut claim = occupants.remove(i);
|
||||
self.deadlines
|
||||
.remove(&(room.into(), authenticated_jid.into()));
|
||||
claim.jid = authenticated_jid.into();
|
||||
Some(claim)
|
||||
}
|
||||
@@ -306,10 +344,61 @@ impl PendingOccupants {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn checkpoint_members(&mut self, room: &str, now: u64) -> Vec<(u64, OccupantSnapshot)> {
|
||||
self.expire(now);
|
||||
self.rooms
|
||||
.get(room)
|
||||
.map(|(_, occupants)| {
|
||||
occupants
|
||||
.iter()
|
||||
.map(|o| {
|
||||
(
|
||||
self.deadlines
|
||||
.get(&(room.into(), o.jid.clone()))
|
||||
.copied()
|
||||
.unwrap_or(now)
|
||||
.saturating_sub(self.grace_seconds),
|
||||
o.clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn insert_checkpoint_members(
|
||||
&mut self,
|
||||
room: String,
|
||||
members: Vec<(u64, OccupantSnapshot)>,
|
||||
now: u64,
|
||||
) -> Result<()> {
|
||||
let members = members
|
||||
.into_iter()
|
||||
.filter(|(at, _)| {
|
||||
at.saturating_add(self.grace_seconds) > now
|
||||
&& at.saturating_add(self.grace_seconds) <= now.saturating_add(7 * 86400)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let Some(saved_at) = members.iter().map(|(at, _)| *at).min() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.insert(
|
||||
room.clone(),
|
||||
members.iter().map(|(_, o)| o.clone()).collect(),
|
||||
saved_at,
|
||||
)?;
|
||||
for (at, o) in members {
|
||||
self.deadlines
|
||||
.insert((room.clone(), o.jid), at.saturating_add(self.grace_seconds));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn room_snapshot(&mut self, room: &str, now: u64) -> Option<(u64, Vec<OccupantSnapshot>)> {
|
||||
self.expire(now);
|
||||
self.rooms.get(room).map(|(deadline, occupants)| {
|
||||
(deadline.saturating_sub(RECOVERY_SECONDS), occupants.clone())
|
||||
(
|
||||
deadline.saturating_sub(self.grace_seconds),
|
||||
occupants.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Read-only membership probe; deadline-sensitive callers use contains_room.
|
||||
@@ -322,22 +411,31 @@ impl PendingOccupants {
|
||||
pub fn identities(&self, now: u64) -> Vec<(String, String)> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|(_, (deadline, _))| *deadline > now)
|
||||
.flat_map(|(room, (_, occupants))| {
|
||||
occupants.iter().map(|o| (room.clone(), o.jid.clone()))
|
||||
occupants
|
||||
.iter()
|
||||
.filter(|o| {
|
||||
self.deadlines
|
||||
.get(&(room.clone(), o.jid.clone()))
|
||||
.is_some_and(|deadline| *deadline > now)
|
||||
})
|
||||
.map(|o| (room.clone(), o.jid.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A dormant nickname remains owned by its authenticated bare account until
|
||||
/// claim expiry; a reused resource belonging to another account cannot take it.
|
||||
pub fn reserved_by(&self, room: &str, nick: &str, now: u64) -> Option<String> {
|
||||
let (deadline, occupants) = self.rooms.get(room)?;
|
||||
if *deadline <= now {
|
||||
return None;
|
||||
}
|
||||
let (_, occupants) = self.rooms.get(room)?;
|
||||
occupants
|
||||
.iter()
|
||||
.find(|o| o.nick == nick)
|
||||
.find(|o| {
|
||||
o.nick == nick
|
||||
&& self
|
||||
.deadlines
|
||||
.get(&(room.into(), o.jid.clone()))
|
||||
.is_some_and(|deadline| *deadline > now)
|
||||
})
|
||||
.map(|o| bare(&o.jid).to_owned())
|
||||
}
|
||||
pub fn contains_room(&mut self, room: &str, now: u64) -> bool {
|
||||
@@ -346,17 +444,36 @@ impl PendingOccupants {
|
||||
.get(room)
|
||||
.is_some_and(|(_, occupants)| !occupants.is_empty())
|
||||
}
|
||||
pub fn remove_room(&mut self, room: &str) {
|
||||
self.rooms.remove(room);
|
||||
self.deadlines.retain(|(name, _), _| name != room);
|
||||
}
|
||||
pub fn remove_jid(&mut self, jid: &str) {
|
||||
self.deadlines.retain(|(_, identity), _| identity != jid);
|
||||
for (_, occupants) in self.rooms.values_mut() {
|
||||
occupants.retain(|o| o.jid != jid);
|
||||
}
|
||||
}
|
||||
pub fn expire(&mut self, now: u64) -> usize {
|
||||
let mut removed = 0;
|
||||
self.rooms.retain(|_, (deadline, occupants)| {
|
||||
if *deadline <= now {
|
||||
removed += occupants.len();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
let before = self.len();
|
||||
let deadlines = &self.deadlines;
|
||||
self.rooms.retain(|room, (deadline, occupants)| {
|
||||
occupants.retain(|o| {
|
||||
deadlines
|
||||
.get(&(room.clone(), o.jid.clone()))
|
||||
.is_some_and(|until| *until > now)
|
||||
});
|
||||
if let Some(first) = occupants
|
||||
.iter()
|
||||
.filter_map(|o| deadlines.get(&(room.clone(), o.jid.clone())))
|
||||
.min()
|
||||
{
|
||||
*deadline = *first;
|
||||
}
|
||||
!occupants.is_empty()
|
||||
});
|
||||
removed
|
||||
self.deadlines.retain(|_, until| *until > now);
|
||||
before - self.len()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.rooms.values().map(|(_, v)| v.len()).sum()
|
||||
|
||||
@@ -2,6 +2,8 @@ use crate::wire::escape;
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ModuleResult {
|
||||
pub replies: Vec<String>,
|
||||
/// A final response after durable session revocation; never retained for resume.
|
||||
pub terminal_reply: bool,
|
||||
/// (destination full or bare JID, serialized stanza)
|
||||
pub deliveries: Vec<(String, String)>,
|
||||
}
|
||||
@@ -9,6 +11,7 @@ impl ModuleResult {
|
||||
pub fn reply(xml: String) -> Self {
|
||||
Self {
|
||||
replies: vec![xml],
|
||||
terminal_reply: false,
|
||||
deliveries: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,6 +1253,7 @@ pub fn on_presence(
|
||||
escape(&id)
|
||||
);
|
||||
Ok(ModuleResult {
|
||||
terminal_reply: false,
|
||||
replies: vec![],
|
||||
deliveries: vec![(jid.into(), query)],
|
||||
})
|
||||
@@ -1594,6 +1595,7 @@ pub fn on_remote_presence(
|
||||
vec![owner.into()],
|
||||
);
|
||||
Ok(ModuleResult {
|
||||
terminal_reply: false,
|
||||
replies: vec![],
|
||||
deliveries: vec![(
|
||||
verified_from.into(),
|
||||
|
||||
+226
-44
@@ -368,7 +368,19 @@ impl Runtime {
|
||||
}
|
||||
let mut rooms = self.mucs.lock().unwrap();
|
||||
for muc in rooms.values_mut() {
|
||||
let result = muc.disconnect(jid);
|
||||
let result = match muc.disconnect_durable(jid) {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
self.logger.event(
|
||||
Level::Error,
|
||||
"muc",
|
||||
None,
|
||||
"checkpoint-failed",
|
||||
&[("reason", "durable-disconnect-failed")],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (to, xml) in result.deliveries {
|
||||
self.hub.deliver(&to, &xml);
|
||||
}
|
||||
@@ -499,6 +511,7 @@ fn refresh_push_session(rt: &Runtime, session: &SharedSession) -> Result<()> {
|
||||
s.push_identifier = identifier;
|
||||
s.first_push_sent = false;
|
||||
s.timeout = Duration::from_secs(timeout);
|
||||
s.checkpoint()?;
|
||||
Ok(())
|
||||
}
|
||||
fn message_pushes(
|
||||
@@ -556,6 +569,17 @@ fn reset_push_watchdog(rt: &Runtime, user: &str, identifier: &str) {
|
||||
let mut s = session.lock().unwrap();
|
||||
if s.owner == owner && s.push_identifier.as_deref() == Some(identifier) {
|
||||
s.first_important_push(timeout);
|
||||
if s.checkpoint().is_err() {
|
||||
s.closed = true;
|
||||
s.cancel.notify_one();
|
||||
rt.logger.event(
|
||||
Level::Error,
|
||||
"smacks",
|
||||
None,
|
||||
"checkpoint-failed",
|
||||
&[("reason", "push-watchdog-state")],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,6 +605,17 @@ fn remove_push_sessions(rt: &Runtime) {
|
||||
if s.expires.is_some() {
|
||||
s.expires = Some(Instant::now() + timeout);
|
||||
}
|
||||
if s.checkpoint().is_err() {
|
||||
s.closed = true;
|
||||
s.cancel.notify_one();
|
||||
rt.logger.event(
|
||||
Level::Error,
|
||||
"smacks",
|
||||
None,
|
||||
"checkpoint-failed",
|
||||
&[("reason", "push-removal-state")],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -730,9 +765,44 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
for (name, host) in &c.hosts {
|
||||
match host.component.as_deref() {
|
||||
Some("muc") => {
|
||||
let component_modules = c.modules(name)?;
|
||||
let unsupported = component_modules
|
||||
.iter()
|
||||
.filter(|module| {
|
||||
!implemented.contains(&module.as_str())
|
||||
&& !["muc", "muc_mam", "muc_vcard", "muc_occupant_id", "adhoc"]
|
||||
.contains(&module.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
unsupported.is_empty(),
|
||||
"MUC modules not integrated for {}: {}",
|
||||
name,
|
||||
unsupported.join(", ")
|
||||
);
|
||||
let mut muc = crate::muc::Muc::new(data.clone(), name.clone());
|
||||
let mut options = c.global.clone();
|
||||
options.extend(host.options.clone());
|
||||
for module in ["muc_vcard", "muc_occupant_id"] {
|
||||
let disabled = c
|
||||
.global
|
||||
.get("modules_disabled")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
host.options
|
||||
.get("modules_disabled")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
.any(|value| value.as_str() == Some(module));
|
||||
if disabled {
|
||||
options.insert(module.into(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
muc.set_component_options(
|
||||
serde_json::Value::Object(options),
|
||||
c.modules(name)?.contains("muc_mam"),
|
||||
@@ -822,6 +892,46 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
}
|
||||
}
|
||||
let upload_routes = upload_routes(&c, &uploads)?;
|
||||
let hub = if c.modules(&domain)?.contains("smacks") {
|
||||
Hub::persistent(
|
||||
data.join(crate::storage::encode_name(&domain))
|
||||
.join("rust_smacks"),
|
||||
)?
|
||||
} else {
|
||||
Hub::default()
|
||||
};
|
||||
// A persisted, unexpired SM session is still a logical room occupant while
|
||||
// its transport is offline. Restore routing before accepting any clients so
|
||||
// room traffic queues durably even before that occupant reauthenticates.
|
||||
// Ordinary MUC recovery claims never establish a trusted session themselves.
|
||||
for session in hub.all() {
|
||||
let (full, owner) = {
|
||||
let state = session.lock().unwrap();
|
||||
(state.jid.clone(), state.owner.clone())
|
||||
};
|
||||
let user = owner
|
||||
.strip_suffix(&format!("@{domain}"))
|
||||
.context("durable session belongs to another host")?;
|
||||
let account = crate::storage::load(&data, &domain, Some(user), "accounts")?;
|
||||
if !account.as_ref().is_some_and(|account| {
|
||||
account.get("disabled") != Some(&crate::storage::LuaValue::Bool(true))
|
||||
}) {
|
||||
hub.kick_owner_checked(&owner)?;
|
||||
continue;
|
||||
}
|
||||
for muc in mucs.values_mut() {
|
||||
// Membership continued across the process boundary: synthetic join
|
||||
// presence and history must not be replayed as new room activity.
|
||||
let _ = muc.restore_for_session(&full)?;
|
||||
}
|
||||
}
|
||||
logger.event(
|
||||
Level::Info,
|
||||
"smacks",
|
||||
None,
|
||||
"durable-sessions-loaded",
|
||||
&[("count", &hub.all().len().to_string())],
|
||||
);
|
||||
let runtime = Runtime {
|
||||
_data_guard: data_guard,
|
||||
domain: domain.clone(),
|
||||
@@ -848,7 +958,7 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
}
|
||||
}),
|
||||
config: Arc::new(c.clone()),
|
||||
hub: Hub::default(),
|
||||
hub,
|
||||
mucs: Arc::new(Mutex::new(mucs)),
|
||||
uploads: Arc::new(uploads),
|
||||
started: Instant::now(),
|
||||
@@ -928,7 +1038,7 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
(s.owner == jid).then(|| s.jid.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
rt.hub.kick_owner(jid);
|
||||
rt.hub.kick_owner_checked(jid)?;
|
||||
for j in old {
|
||||
rt.disconnected(&j);
|
||||
}
|
||||
@@ -1131,6 +1241,9 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
let mut maintenance = tokio::time::interval(Duration::from_secs(300));
|
||||
let mut maintenance_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(1));
|
||||
let heartbeat_seconds = (number(&c, &domain, "smacks_hibernation_time", 300) / 4).clamp(1, 10);
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_secs(heartbeat_seconds));
|
||||
let mut heartbeat_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut hangup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
|
||||
loop {
|
||||
@@ -1154,6 +1267,18 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
}));
|
||||
}
|
||||
},
|
||||
_ = heartbeat.tick() => {
|
||||
if heartbeat_task.as_ref().is_none_or(|task| task.is_finished()) {
|
||||
let rt = runtime.clone();
|
||||
heartbeat_task = Some(tokio::task::spawn_blocking(move || {
|
||||
let result = rt.hub.checkpoint_live().and_then(|_| {
|
||||
for muc in rt.mucs.lock().unwrap().values_mut() { muc.checkpoint_durable()?; }
|
||||
Ok(())
|
||||
});
|
||||
if result.is_err() { rt.logger.event(Level::Error, "recovery", None, "heartbeat-checkpoint-failed", &[]); }
|
||||
}));
|
||||
}
|
||||
},
|
||||
_ = tick.tick() => {
|
||||
for jid in runtime.hub.expired() {
|
||||
logger.event(Level::Info, "smacks", None, "session-expired", &[("jid", &jid)]);
|
||||
@@ -1180,6 +1305,7 @@ pub async fn serve(path: &Path) -> Result<()> {
|
||||
runtime
|
||||
.stopping
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
runtime.hub.freeze()?;
|
||||
for (domain, muc) in runtime.mucs.lock().unwrap().iter_mut() {
|
||||
muc.checkpoint_live()?;
|
||||
logger.event(
|
||||
@@ -1224,7 +1350,9 @@ async fn csi_send<S: AsyncWrite + Unpin>(
|
||||
let output = if let Some(session) = session {
|
||||
let mut s = session.lock().unwrap();
|
||||
let owner = s.owner.clone();
|
||||
s.csi.output(xml.into(), &owner, Instant::now())
|
||||
let output = s.csi.output(xml.into(), &owner, Instant::now());
|
||||
s.checkpoint()?;
|
||||
output
|
||||
} else {
|
||||
vec![xml.into()]
|
||||
};
|
||||
@@ -1237,9 +1365,14 @@ async fn csi_activity<S: AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
session: Option<&SharedSession>,
|
||||
) -> Result<()> {
|
||||
let output = session
|
||||
.map(|s| s.lock().unwrap().csi.activity(Instant::now()))
|
||||
.unwrap_or_default();
|
||||
let output = if let Some(s) = session {
|
||||
let mut s = s.lock().unwrap();
|
||||
let output = s.csi.activity(Instant::now());
|
||||
s.checkpoint()?;
|
||||
output
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for xml in output {
|
||||
send(stream, &xml).await?;
|
||||
}
|
||||
@@ -1252,11 +1385,7 @@ async fn emit<S: AsyncWrite + Unpin>(
|
||||
) -> Result<()> {
|
||||
if let Some(session) = session {
|
||||
let mut session = session.lock().unwrap();
|
||||
if session.sm {
|
||||
ensure!(session.pending.len() < 4096, "unacknowledged stanza limit");
|
||||
session.sent = session.sent.wrapping_add(1);
|
||||
session.pending.push_back(xml.into());
|
||||
}
|
||||
session.track_outbound(xml)?;
|
||||
}
|
||||
csi_send(s, session, xml).await?;
|
||||
if session.is_some_and(|s| s.lock().unwrap().request_ack()) {
|
||||
@@ -2058,7 +2187,11 @@ async fn apply<S: AsyncWrite + Unpin>(
|
||||
result: ModuleResult,
|
||||
) -> Result<()> {
|
||||
for xml in result.replies {
|
||||
emit(stream, session, &xml).await?;
|
||||
if result.terminal_reply {
|
||||
send(stream, &xml).await?;
|
||||
} else {
|
||||
emit(stream, session, &xml).await?;
|
||||
}
|
||||
}
|
||||
for (to, xml) in result.deliveries {
|
||||
if let Ok(doc) = roxmltree::Document::parse(&xml)
|
||||
@@ -2555,12 +2688,14 @@ fn dispatch_iq(
|
||||
(s.owner == protocol::bare(jid)).then(|| s.jid.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
rt.hub.kick_owner(protocol::bare(jid));
|
||||
rt.hub.kick_owner_checked(protocol::bare(jid))?;
|
||||
for j in resources {
|
||||
rt.disconnected(&j);
|
||||
}
|
||||
crate::account_modules::remove_account(&rt.data, &rt.domain, &user)?;
|
||||
return Ok(Some(ModuleResult::reply(protocol::iq_result(iq, ""))));
|
||||
let mut result = ModuleResult::reply(protocol::iq_result(iq, ""));
|
||||
result.terminal_reply = true;
|
||||
return Ok(Some(result));
|
||||
}
|
||||
let password = q
|
||||
.children()
|
||||
@@ -2745,7 +2880,7 @@ fn configure_bound_session(
|
||||
s: &SharedSession,
|
||||
client_id: Option<String>,
|
||||
csi_inactive: bool,
|
||||
) {
|
||||
) -> Result<()> {
|
||||
s.lock().unwrap().ack_limit =
|
||||
number(&rt.config, &rt.domain, "smacks_max_unacked", 100) as usize;
|
||||
s.lock().unwrap().client_id = client_id.clone();
|
||||
@@ -2772,7 +2907,10 @@ fn configure_bound_session(
|
||||
.csi
|
||||
.configure(limit, Duration::from_secs_f64(delay), payloads);
|
||||
}
|
||||
s.lock().unwrap().csi.set_inactive(csi_inactive);
|
||||
let mut session = s.lock().unwrap();
|
||||
session.csi.set_inactive(csi_inactive);
|
||||
session.checkpoint()?;
|
||||
Ok(())
|
||||
}
|
||||
fn bind2_client_uuid(id: &str) -> Option<uuid::Uuid> {
|
||||
uuid::Uuid::parse_str(id)
|
||||
@@ -2834,6 +2972,32 @@ async fn close_retired_transport<S: AsyncWrite + Unpin>(
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
async fn restore_muc_after_resume<S: AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
session: &SharedSession,
|
||||
rt: &Runtime,
|
||||
ctx: &ConnectionLog,
|
||||
) -> Result<()> {
|
||||
let full = session.lock().unwrap().jid.clone();
|
||||
let results = {
|
||||
let mut rooms = rt.mucs.lock().unwrap();
|
||||
rooms
|
||||
.values_mut()
|
||||
.map(|muc| muc.restore_for_session(&full))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
for result in results {
|
||||
if !result.replies.is_empty() || !result.deliveries.is_empty() {
|
||||
ctx.event(
|
||||
Level::Info,
|
||||
"muc-membership-restored",
|
||||
&[("jid", &full), ("mode", "sm-resume")],
|
||||
);
|
||||
}
|
||||
apply(stream, Some(session), rt, result).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
rt: Runtime,
|
||||
@@ -2906,7 +3070,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
"resource-bound",
|
||||
&[("jid", &full), ("mode", "bind2")],
|
||||
);
|
||||
configure_bound_session(&rt, &s, client_id.clone(), request.inactive);
|
||||
configure_bound_session(&rt, &s, client_id.clone(), request.inactive)?;
|
||||
csi_inactive = request.inactive;
|
||||
let mut bound = String::new();
|
||||
{
|
||||
@@ -2996,6 +3160,15 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
} else {
|
||||
open(stream, &mut f, &rt.domain, &features).await?;
|
||||
}
|
||||
if resumed_inline {
|
||||
restore_muc_after_resume(
|
||||
stream,
|
||||
session.as_ref().context("resumed session missing")?,
|
||||
&rt,
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if let Some(full) = &jid
|
||||
&& !resumed_inline
|
||||
&& (rt.enabled("vcard_legacy") || rt.enabled("bookmarks"))
|
||||
@@ -3047,6 +3220,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
send(stream, &xml).await?;
|
||||
}
|
||||
session = Some(resumed);
|
||||
restore_muc_after_resume(stream, session.as_ref().unwrap(), &rt, ctx).await?;
|
||||
ctx.phase = "session";
|
||||
ctx.event(Level::Info, "session-resumed", &[("mode", "sm")]);
|
||||
continue;
|
||||
@@ -3082,10 +3256,14 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
&& matches!(kind, "active" | "inactive")
|
||||
{
|
||||
csi_inactive = kind == "inactive";
|
||||
let output = session
|
||||
.as_ref()
|
||||
.map(|s| s.lock().unwrap().csi.set_inactive(csi_inactive))
|
||||
.unwrap_or_default();
|
||||
let output = if let Some(s) = &session {
|
||||
let mut s = s.lock().unwrap();
|
||||
let output = s.csi.set_inactive(csi_inactive);
|
||||
s.checkpoint()?;
|
||||
output
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for xml in output {
|
||||
send(stream, &xml).await?;
|
||||
}
|
||||
@@ -3096,12 +3274,8 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
"invalid stanza"
|
||||
);
|
||||
ctx.event(Level::Debug, "stanza-received", &[("kind", kind)]);
|
||||
if let Some(s) = &session {
|
||||
let mut s = s.lock().unwrap();
|
||||
if s.sm {
|
||||
s.handled = s.handled.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
let handled_session = session.clone();
|
||||
async {
|
||||
if kind == "iq" {
|
||||
let typ = n.attribute("type").unwrap_or("");
|
||||
if matches!(typ, "result" | "error") {
|
||||
@@ -3111,14 +3285,14 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
crate::pubsub::on_disco_result(&rt.data, &rt.domain, full, n)?
|
||||
{
|
||||
apply(stream, session.as_ref(), &rt, result).await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if let Some(to) = n.attribute("to")
|
||||
&& !blocked(&rt, &user, full, to)?
|
||||
{
|
||||
let _ = route_delivery(&rt, to, &routed(n, full, to)).await;
|
||||
}
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
let q = n.first_element_child().context("IQ payload missing")?;
|
||||
let ns = q.tag_name().namespace().unwrap_or("");
|
||||
@@ -3164,7 +3338,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
configure_bound_session(&rt, &s, client_id.clone(), csi_inactive);
|
||||
configure_bound_session(&rt, &s, client_id.clone(), csi_inactive)?;
|
||||
refresh_push_session(&rt, &s)?;
|
||||
jid = Some(full);
|
||||
session = Some(s);
|
||||
@@ -3186,7 +3360,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
emit(stream, None, &protocol::iq_error(n, "conflict")).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
let full = jid.as_deref().context("binding required")?;
|
||||
if let Some(to) = n.attribute("to") {
|
||||
@@ -3197,12 +3371,12 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
&protocol::iq_error(n, "service-unavailable"),
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if to.contains('/') && to != full {
|
||||
if rt.hub.get(to).is_some() {
|
||||
rt.hub.deliver(to, &routed(n, full, to));
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
} else if rt.owner(to).is_some() {
|
||||
emit(
|
||||
stream,
|
||||
@@ -3210,7 +3384,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
&protocol::iq_error(n, "recipient-unavailable"),
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3221,7 +3395,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
{
|
||||
session.as_ref().unwrap().lock().unwrap().carbons = q.tag_name().name() == "enable";
|
||||
emit(stream, session.as_ref(), &protocol::iq_result(n, "")).await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if ns == "jabber:iq:roster" && typ == "get" {
|
||||
session.as_ref().unwrap().lock().unwrap().roster_interested = true;
|
||||
@@ -3239,7 +3413,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
let result = match dispatch_iq(&rt, full, n) {
|
||||
Ok(Some(r)) => r,
|
||||
@@ -3273,7 +3447,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
};
|
||||
if let Some(result) = muc_result {
|
||||
apply(stream, session.as_ref(), &rt, result).await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if kind == "presence" {
|
||||
if rt.enabled("roster")
|
||||
@@ -3281,7 +3455,7 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
crate::account_modules::subscription(&rt.data, &rt.domain, full, n)?
|
||||
{
|
||||
apply(stream, session.as_ref(), &rt, result).await?;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if n.attribute("to").is_none() {
|
||||
let avatar_xml = if rt.enabled("vcard_legacy") {
|
||||
@@ -3379,26 +3553,26 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
let Some(to) = n.attribute("to") else {
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
};
|
||||
let local = rt.owner(to);
|
||||
if crate::account_modules::is_blocked(&rt.data, &rt.domain, &user, to)? {
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if let Some(target_user) = &local
|
||||
&& crate::account_modules::is_blocked(&rt.data, &rt.domain, target_user, full)?
|
||||
{
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
let mut xml = routed(n, full, to);
|
||||
let mut sent_xml = xml.clone();
|
||||
if kind == "presence" {
|
||||
let _ = route_delivery(&rt, to, &xml).await;
|
||||
continue;
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
let remote = local.is_none()
|
||||
&& !rt.config.hosts.contains_key(
|
||||
@@ -3523,6 +3697,14 @@ async fn authenticated_transport<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}.await?;
|
||||
if let Some(s) = handled_session {
|
||||
let mut state = s.lock().unwrap();
|
||||
if !state.closed {
|
||||
state.mark_handled()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Bounded, per-session durable stream-management slots. Socket handles are never stored.
|
||||
//!
|
||||
//! Recovery is deliberately strict: malformed, oversized, or inconsistent committed
|
||||
//! slots fail startup instead of silently losing acknowledged session state. Uncommitted
|
||||
//! UUID temporary files are discarded; the last renamed JSON slot remains authoritative.
|
||||
use crate::sessions::Session;
|
||||
use anyhow::{Result, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
const SLOT_LIMIT: usize = 8 * 1024 * 1024;
|
||||
const TOTAL_LIMIT: usize = 256 * 1024 * 1024;
|
||||
const SESSION_LIMIT: usize = 4096;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct Record {
|
||||
version: u8,
|
||||
pub jid: String,
|
||||
pub owner: String,
|
||||
pub connection: String,
|
||||
pub client_id: Option<String>,
|
||||
pub push_identifier: Option<String>,
|
||||
pub first_push_sent: bool,
|
||||
pub available: bool,
|
||||
pub priority: i8,
|
||||
pub presence: Option<String>,
|
||||
pub carbons: bool,
|
||||
pub csi: crate::csi::DurableBuffer,
|
||||
pub roster_interested: bool,
|
||||
pub resume_id: String,
|
||||
pub handled: u32,
|
||||
pub sent: u32,
|
||||
pub acked: u32,
|
||||
pub ack_limit: usize,
|
||||
pub pending: std::collections::VecDeque<String>,
|
||||
pub deadline_ms: u64,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
pub(crate) fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64
|
||||
}
|
||||
impl Record {
|
||||
fn from_session(s: &Session) -> Result<Self> {
|
||||
ensure!(!s.closed && s.sm, "session is not resumable");
|
||||
let remaining = s
|
||||
.expires
|
||||
.map(|v| v.saturating_duration_since(Instant::now()))
|
||||
.unwrap_or(s.timeout);
|
||||
Ok(Self {
|
||||
version: 1,
|
||||
jid: s.jid.clone(),
|
||||
owner: s.owner.clone(),
|
||||
connection: s.connection.clone(),
|
||||
client_id: s.client_id.clone(),
|
||||
push_identifier: s.push_identifier.clone(),
|
||||
first_push_sent: s.first_push_sent,
|
||||
available: s.available,
|
||||
priority: s.priority,
|
||||
presence: s.presence.clone(),
|
||||
carbons: s.carbons,
|
||||
csi: s.csi.durable(),
|
||||
roster_interested: s.roster_interested,
|
||||
resume_id: s
|
||||
.resume_id
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing resume token"))?,
|
||||
handled: s.handled,
|
||||
sent: s.sent,
|
||||
acked: s.acked,
|
||||
ack_limit: s.ack_limit,
|
||||
pending: s.pending.clone(),
|
||||
deadline_ms: now_ms()
|
||||
.saturating_add(remaining.as_millis().min(u64::MAX as u128) as u64),
|
||||
timeout_ms: s.timeout.as_millis().min(u64::MAX as u128) as u64,
|
||||
})
|
||||
}
|
||||
pub fn session(self) -> Session {
|
||||
Session {
|
||||
jid: self.jid,
|
||||
owner: self.owner,
|
||||
connection: self.connection,
|
||||
client_id: self.client_id,
|
||||
push_identifier: self.push_identifier,
|
||||
first_push_sent: self.first_push_sent,
|
||||
sender: None,
|
||||
available: self.available,
|
||||
priority: self.priority,
|
||||
presence: self.presence,
|
||||
carbons: self.carbons,
|
||||
csi: crate::csi::Buffer::from_durable(self.csi),
|
||||
roster_interested: self.roster_interested,
|
||||
sm: true,
|
||||
resume_id: Some(self.resume_id),
|
||||
handled: self.handled,
|
||||
sent: self.sent,
|
||||
acked: self.acked,
|
||||
ack_limit: self.ack_limit,
|
||||
ack_requested: false,
|
||||
pending: self.pending,
|
||||
expires: Some(
|
||||
Instant::now() + Duration::from_millis(self.deadline_ms.saturating_sub(now_ms())),
|
||||
),
|
||||
timeout: Duration::from_millis(self.timeout_ms),
|
||||
closed: false,
|
||||
close_condition: None,
|
||||
cancel: Default::default(),
|
||||
durable: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Store {
|
||||
root: PathBuf,
|
||||
_lock: File,
|
||||
slots: Mutex<BTreeMap<String, (String, usize)>>,
|
||||
}
|
||||
fn store(session: &Session) -> Result<Option<Arc<Store>>> {
|
||||
session
|
||||
.durable
|
||||
.as_ref()
|
||||
.map(|weak| {
|
||||
weak.upgrade()
|
||||
.ok_or_else(|| anyhow::anyhow!("durable session hub was dropped"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
pub(crate) fn enable(s: &Session) -> Result<()> {
|
||||
if let Some(store) = store(s)?
|
||||
&& s.resume_id.is_some()
|
||||
{
|
||||
store.write(s, None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn checkpoint(s: &Session) -> Result<()> {
|
||||
if let Some(store) = store(s)?
|
||||
&& s.resume_id.is_some()
|
||||
{
|
||||
store.write(s, Some(&s.connection))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn claim(old: &Session, new: &Session) -> Result<()> {
|
||||
if let Some(store) = store(old)?
|
||||
&& old.resume_id.is_some()
|
||||
{
|
||||
store.write(new, Some(&old.connection))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn remove(s: &Session) -> Result<()> {
|
||||
if let Some(store) = store(s)?
|
||||
&& let Some(id) = &s.resume_id
|
||||
{
|
||||
store.remove(id, &s.connection)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<(Arc<Self>, Vec<Record>)> {
|
||||
fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true).write(true).create(true).truncate(false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let lock = options.open(path.join("store.lock"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
lock.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
lock.try_lock()
|
||||
.map_err(|error| anyhow::anyhow!("stream management store already in use: {error}"))?;
|
||||
let store = Arc::new(Self {
|
||||
root: path.into(),
|
||||
_lock: lock,
|
||||
slots: Default::default(),
|
||||
});
|
||||
let mut recovered = vec![];
|
||||
let mut total = 0usize;
|
||||
let entries = fs::read_dir(path)?.collect::<std::io::Result<Vec<_>>>()?;
|
||||
// A crash before rename can leave an incomplete slot. Only canonical UUID
|
||||
// regular files belong to our writer; preserve unknown names and symlinks.
|
||||
for entry in &entries {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if let Some(id) = name.strip_suffix(".tmp") {
|
||||
let owned = safe_id(id).is_ok()
|
||||
&& uuid::Uuid::parse_str(id).is_ok_and(|uuid| uuid.to_string() == id);
|
||||
if owned && entry.file_type()?.is_file() {
|
||||
fs::remove_file(entry.path())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
File::open(path)?.sync_all()?;
|
||||
for entry in entries {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
ensure!(
|
||||
entry.file_type()?.is_file(),
|
||||
"invalid stream management slot"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(entry.path(), fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
let len = entry.metadata()?.len() as usize;
|
||||
ensure!(len <= SLOT_LIMIT, "stream management slot too large");
|
||||
total = total.saturating_add(len);
|
||||
ensure!(
|
||||
total <= TOTAL_LIMIT && recovered.len() < SESSION_LIMIT,
|
||||
"stream management store limit"
|
||||
);
|
||||
let mut bytes = vec![];
|
||||
File::open(entry.path())?
|
||||
.take(SLOT_LIMIT as u64 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
let record: Record = serde_json::from_slice(&bytes)?;
|
||||
ensure!(
|
||||
record.version == 1
|
||||
&& record.pending.len() <= 4096
|
||||
&& record.sent.wrapping_sub(record.acked) as usize == record.pending.len()
|
||||
&& crate::protocol::bare(&record.jid) == record.owner
|
||||
&& record.jid.contains('/'),
|
||||
"invalid durable session"
|
||||
);
|
||||
ensure!(
|
||||
name == format!("{}.json", safe_id(&record.resume_id)?),
|
||||
"invalid slot token"
|
||||
);
|
||||
if record.deadline_ms <= now_ms() {
|
||||
fs::remove_file(entry.path())?;
|
||||
continue;
|
||||
}
|
||||
store
|
||||
.slots
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(record.resume_id.clone(), (record.connection.clone(), len));
|
||||
recovered.push(record);
|
||||
}
|
||||
File::open(path)?.sync_all()?;
|
||||
Ok((store, recovered))
|
||||
}
|
||||
fn write(&self, s: &Session, expected: Option<&str>) -> Result<()> {
|
||||
let record = Record::from_session(s)?;
|
||||
ensure!(
|
||||
record.pending.len() <= 4096,
|
||||
"stream management queue limit"
|
||||
);
|
||||
let bytes = serde_json::to_vec(&record)?;
|
||||
ensure!(bytes.len() <= SLOT_LIMIT, "stream management slot limit");
|
||||
let name = safe_id(&record.resume_id)?;
|
||||
let mut slots = self.slots.lock().unwrap();
|
||||
if let Some(expected) = expected {
|
||||
ensure!(
|
||||
slots
|
||||
.get(&record.resume_id)
|
||||
.is_some_and(|v| v.0 == expected),
|
||||
"retired stream transport"
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
!slots.contains_key(&record.resume_id),
|
||||
"duplicate stream token"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
slots.len() < SESSION_LIMIT || slots.contains_key(&record.resume_id),
|
||||
"stream management session limit"
|
||||
);
|
||||
let total: usize = slots.values().map(|v| v.1).sum();
|
||||
let previous = slots.get(&record.resume_id).map_or(0, |v| v.1);
|
||||
ensure!(
|
||||
total - previous + bytes.len() <= TOTAL_LIMIT,
|
||||
"stream management storage limit"
|
||||
);
|
||||
let tmp = self.root.join(format!("{name}.tmp"));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&tmp)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(tmp, self.root.join(format!("{name}.json")))?;
|
||||
File::open(&self.root)?.sync_all()?;
|
||||
slots.insert(record.resume_id, (record.connection, bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, id: &str, connection: &str) -> Result<()> {
|
||||
let mut slots = self.slots.lock().unwrap();
|
||||
if slots.get(id).is_none_or(|v| v.0 != connection) {
|
||||
return Ok(());
|
||||
}
|
||||
fs::remove_file(self.root.join(format!("{}.json", safe_id(id)?)))?;
|
||||
File::open(&self.root)?.sync_all()?;
|
||||
slots.remove(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn safe_id(id: &str) -> Result<&str> {
|
||||
ensure!(
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-'),
|
||||
"invalid resume token"
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
+184
-31
@@ -35,8 +35,51 @@ pub struct Session {
|
||||
pub closed: bool,
|
||||
pub close_condition: Option<String>,
|
||||
pub cancel: Arc<tokio::sync::Notify>,
|
||||
pub(crate) durable: Option<std::sync::Weak<crate::session_store::Store>>,
|
||||
}
|
||||
impl Session {
|
||||
/// Persist state before acknowledging client input or exposing output to a transport.
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
crate::session_store::checkpoint(self)
|
||||
}
|
||||
pub fn mark_handled(&mut self) -> Result<()> {
|
||||
ensure!(!self.closed, "session closed");
|
||||
if !self.sm {
|
||||
return Ok(());
|
||||
}
|
||||
let previous = self.handled;
|
||||
self.handled = self.handled.wrapping_add(1);
|
||||
if let Err(error) = self.checkpoint() {
|
||||
self.handled = previous;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn track_outbound(&mut self, xml: &str) -> Result<()> {
|
||||
ensure!(!self.closed, "session closed");
|
||||
if !self.sm {
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(self.pending.len() < 4096, "stream management queue limit");
|
||||
// The byte bound applies to memory-only sessions too.
|
||||
ensure!(
|
||||
self.pending
|
||||
.iter()
|
||||
.map(String::len)
|
||||
.sum::<usize>()
|
||||
.saturating_add(xml.len())
|
||||
<= 8 * 1024 * 1024,
|
||||
"stream management byte limit"
|
||||
);
|
||||
self.sent = self.sent.wrapping_add(1);
|
||||
self.pending.push_back(xml.into());
|
||||
if let Err(error) = self.checkpoint() {
|
||||
self.sent = self.sent.wrapping_sub(1);
|
||||
self.pending.pop_back();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn first_important_push(&mut self, normal_timeout: Duration) {
|
||||
if self.sender.is_none() && self.expires.is_some() && !self.first_push_sent {
|
||||
self.first_push_sent = true;
|
||||
@@ -45,25 +88,18 @@ impl Session {
|
||||
}
|
||||
pub fn transmit(&mut self, xml: String) -> Result<()> {
|
||||
ensure!(!self.closed, "session closed");
|
||||
if self.sm {
|
||||
ensure!(self.pending.len() < 4096, "stream management queue limit");
|
||||
}
|
||||
if let Some(tx) = &self.sender {
|
||||
if let Some(tx) = self.sender.clone() {
|
||||
let permit = tx
|
||||
.try_reserve()
|
||||
.map_err(|_| anyhow::anyhow!("recipient queue unavailable"))?;
|
||||
if self.sm {
|
||||
self.sent = self.sent.wrapping_add(1);
|
||||
self.pending.push_back(xml.clone());
|
||||
}
|
||||
self.track_outbound(&xml)?;
|
||||
permit.send(xml);
|
||||
} else {
|
||||
ensure!(
|
||||
self.sm && self.resume_id.is_some(),
|
||||
"recipient disconnected"
|
||||
);
|
||||
self.sent = self.sent.wrapping_add(1);
|
||||
self.pending.push_back(xml);
|
||||
self.track_outbound(&xml)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -79,9 +115,14 @@ impl Session {
|
||||
ensure!(!self.closed, "session closed");
|
||||
let n = h.wrapping_sub(self.acked) as usize;
|
||||
ensure!(n <= self.pending.len(), "invalid stream acknowledgement");
|
||||
let previous = self.clone();
|
||||
self.pending.drain(..n);
|
||||
self.acked = h;
|
||||
self.ack_requested = false;
|
||||
if let Err(error) = self.checkpoint() {
|
||||
*self = previous;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn enable(&mut self, resume: bool) -> Result<String> {
|
||||
@@ -91,6 +132,11 @@ impl Session {
|
||||
if resume {
|
||||
self.resume_id = Some(uuid::Uuid::new_v4().to_string());
|
||||
}
|
||||
if let Err(error) = crate::session_store::enable(self) {
|
||||
self.sm = false;
|
||||
self.resume_id = None;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(match &self.resume_id {
|
||||
Some(id) => format!(
|
||||
"<enabled xmlns='urn:xmpp:sm:3' id='{id}' resume='true' max='{}'/>",
|
||||
@@ -122,8 +168,62 @@ impl ConflictPolicy {
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Hub {
|
||||
sessions: Arc<Mutex<BTreeMap<String, SharedSession>>>,
|
||||
store: Option<Arc<crate::session_store::Store>>,
|
||||
}
|
||||
impl Hub {
|
||||
pub fn persistent(path: impl AsRef<std::path::Path>) -> Result<Self> {
|
||||
let (store, records) = crate::session_store::Store::open(path.as_ref())?;
|
||||
let mut sessions = BTreeMap::new();
|
||||
for record in records {
|
||||
let mut session = record.session();
|
||||
session.durable = Some(Arc::downgrade(&store));
|
||||
ensure!(
|
||||
!sessions.contains_key(&session.jid),
|
||||
"duplicate durable resource"
|
||||
);
|
||||
sessions.insert(session.jid.clone(), Arc::new(Mutex::new(session)));
|
||||
}
|
||||
Ok(Self {
|
||||
sessions: Arc::new(Mutex::new(sessions)),
|
||||
store: Some(store),
|
||||
})
|
||||
}
|
||||
/// Refresh only connected deadlines; disconnected deadlines never slide.
|
||||
pub fn checkpoint_live(&self) -> Result<()> {
|
||||
for session in self.all() {
|
||||
let state = session.lock().unwrap();
|
||||
if state.sender.is_some() && !state.closed {
|
||||
state.checkpoint()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire in-flight transport handles and commit offline state before shutdown.
|
||||
pub fn freeze(&self) -> Result<()> {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
for shared in map.values_mut() {
|
||||
let previous = shared.clone();
|
||||
let mut old = previous.lock().unwrap();
|
||||
if old.closed || !old.sm || old.resume_id.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut state = old.clone();
|
||||
state.sender = None;
|
||||
if state.expires.is_none() {
|
||||
state.expires = Some(Instant::now() + state.timeout);
|
||||
}
|
||||
state.connection = format!("shutdown-{}", uuid::Uuid::new_v4());
|
||||
state.cancel = Default::default();
|
||||
crate::session_store::claim(&old, &state)?;
|
||||
old.closed = true;
|
||||
old.sender = None;
|
||||
old.resume_id = None;
|
||||
old.expires = None;
|
||||
*shared = Arc::new(Mutex::new(state));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn bind(
|
||||
&self,
|
||||
jid: &str,
|
||||
@@ -150,9 +250,10 @@ impl Hub {
|
||||
match policy {
|
||||
ConflictPolicy::KickNew => anyhow::bail!("resource conflict"),
|
||||
ConflictPolicy::KickOld => {
|
||||
let old = map.remove(&jid).unwrap();
|
||||
let old = map.get(&jid).unwrap().clone();
|
||||
{
|
||||
let mut state = old.lock().unwrap();
|
||||
crate::session_store::remove(&state)?;
|
||||
state.closed = true;
|
||||
state.close_condition = Some("conflict".into());
|
||||
state.sender = None;
|
||||
@@ -160,6 +261,7 @@ impl Hub {
|
||||
state.expires = None;
|
||||
state.cancel.notify_one();
|
||||
}
|
||||
map.remove(&jid);
|
||||
replaced = Some(old);
|
||||
}
|
||||
ConflictPolicy::Increment => {
|
||||
@@ -208,22 +310,34 @@ impl Hub {
|
||||
closed: false,
|
||||
close_condition: None,
|
||||
cancel: Default::default(),
|
||||
durable: self.store.as_ref().map(Arc::downgrade),
|
||||
}));
|
||||
map.insert(jid, s.clone());
|
||||
Ok((s, replaced))
|
||||
}
|
||||
pub fn kick_owner_checked(&self, owner: &str) -> Result<()> {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
let victims: Vec<_> = map
|
||||
.iter()
|
||||
.filter_map(|(jid, session)| {
|
||||
(session.lock().unwrap().owner == owner).then_some(jid.clone())
|
||||
})
|
||||
.collect();
|
||||
for jid in victims {
|
||||
let shared = map.get(&jid).unwrap().clone();
|
||||
let mut state = shared.lock().unwrap();
|
||||
// Do not let account deletion/recreation proceed if durable revocation failed.
|
||||
crate::session_store::remove(&state)?;
|
||||
state.resume_id = None;
|
||||
state.sender = None;
|
||||
state.closed = true;
|
||||
state.cancel.notify_one();
|
||||
map.remove(&jid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn kick_owner(&self, owner: &str) {
|
||||
self.sessions.lock().unwrap().retain(|_, s| {
|
||||
let mut s = s.lock().unwrap();
|
||||
if s.owner == owner {
|
||||
s.sender = None;
|
||||
s.closed = true;
|
||||
s.cancel.notify_one();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let _ = self.kick_owner_checked(owner);
|
||||
}
|
||||
pub fn get(&self, jid: &str) -> Option<SharedSession> {
|
||||
self.sessions.lock().unwrap().get(jid).cloned()
|
||||
@@ -301,9 +415,21 @@ impl Hub {
|
||||
if !graceful && s.sm && s.resume_id.is_some() {
|
||||
s.csi.reset();
|
||||
s.sender = None;
|
||||
s.expires = Some(Instant::now() + s.timeout);
|
||||
// Repeated cleanup must not renew an already offline token.
|
||||
if s.expires.is_none() {
|
||||
s.expires = Some(Instant::now() + s.timeout);
|
||||
}
|
||||
if s.checkpoint().is_err() {
|
||||
return true;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
if crate::session_store::remove(&s).is_err() {
|
||||
return true;
|
||||
}
|
||||
s.closed = true;
|
||||
s.resume_id = None;
|
||||
s.sender = None;
|
||||
removed.push(jid.clone());
|
||||
false
|
||||
}
|
||||
@@ -312,12 +438,16 @@ impl Hub {
|
||||
}
|
||||
pub fn expired(&self) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
self.sessions.lock().unwrap().retain(|jid, s| {
|
||||
if s.lock()
|
||||
.unwrap()
|
||||
.expires
|
||||
.is_some_and(|t| Instant::now() >= t)
|
||||
{
|
||||
self.sessions.lock().unwrap().retain(|jid, session| {
|
||||
let mut state = session.lock().unwrap();
|
||||
if state.expires.is_some_and(|t| Instant::now() >= t) {
|
||||
if crate::session_store::remove(&state).is_err() {
|
||||
return true;
|
||||
}
|
||||
state.closed = true;
|
||||
state.resume_id = None;
|
||||
state.sender = None;
|
||||
state.cancel.notify_one();
|
||||
out.push(jid.clone());
|
||||
false
|
||||
} else {
|
||||
@@ -340,7 +470,12 @@ impl Hub {
|
||||
.values()
|
||||
.find(|session| {
|
||||
let state = session.lock().unwrap();
|
||||
!state.closed && state.owner == owner && state.resume_id.as_deref() == Some(id)
|
||||
!state.closed
|
||||
&& state
|
||||
.expires
|
||||
.is_none_or(|deadline| deadline > Instant::now())
|
||||
&& state.owner == owner
|
||||
&& state.resume_id.as_deref() == Some(id)
|
||||
})
|
||||
.cloned();
|
||||
if let Some(previous) = candidate {
|
||||
@@ -351,13 +486,16 @@ impl Hub {
|
||||
// Keep a retired transport's in-flight operations isolated from the
|
||||
// resumed queue, acknowledgements and CSI state.
|
||||
let mut state = old.clone();
|
||||
state.acknowledge(h)?;
|
||||
state.pending.drain(..count);
|
||||
state.acked = h;
|
||||
state.ack_requested = false;
|
||||
state.cancel = Default::default();
|
||||
state.csi.reset();
|
||||
state.first_push_sent = false;
|
||||
state.sender = Some(sender);
|
||||
state.connection = connection.into();
|
||||
state.expires = None;
|
||||
crate::session_store::claim(&old, &state)?;
|
||||
let mut replay = vec![format!(
|
||||
"<resumed xmlns='urn:xmpp:sm:3' previd='{id}' h='{}'/>",
|
||||
state.handled
|
||||
@@ -383,6 +521,21 @@ impl Hub {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn traffic_before_sm_enable_is_not_counted() {
|
||||
let hub = Hub::default();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let session = hub
|
||||
.bind("a@x/r", "plain", tx, Duration::from_secs(30))
|
||||
.unwrap();
|
||||
let mut session = session.lock().unwrap();
|
||||
session.mark_handled().unwrap();
|
||||
session.track_outbound("plain reply").unwrap();
|
||||
session.enable(true).unwrap();
|
||||
assert_eq!((session.handled, session.sent), (0, 0));
|
||||
session.mark_handled().unwrap();
|
||||
assert_eq!(session.handled, 1);
|
||||
}
|
||||
#[test]
|
||||
fn resume_replays_only_unacked_and_checks_owner() {
|
||||
let h = Hub::default();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
|
||||
+11
-1
@@ -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"));
|
||||
}
|
||||
@@ -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:])
|
||||
@@ -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 & 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 & 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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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'")));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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()))
|
||||
Reference in new issue
Block a user