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

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

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

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

No files matched your search

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