commit c260d7bab8dab933ded4d874ddf5d90858fb6122 Author: wt Date: Tue Oct 20 15:59:55 2026 +0700 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fd93c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target +demo +bin +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c6be7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +*.local.* +/demo/ +/bin/ diff --git a/CHANGELOG-0.6.0.md b/CHANGELOG-0.6.0.md new file mode 100644 index 0000000..7a19f74 --- /dev/null +++ b/CHANGELOG-0.6.0.md @@ -0,0 +1,32 @@ +# 0.6.0 — переподключение и восстановление сессий + +Исправлена причина bind conflict при повторном входе с тем же resource: +раньше runtime всегда отклонял новый bind, включая ресурс hibernating SM. +Теперь используется native `conflict_resolve`: default/kick_old заменяет +старый ресурс, kick_new возвращает conflict, increment выбирает свободный +суффикс #N, random — независимый UUID. Ответ содержит фактически выданный JID. +Одинаковая политика применяется к обычному bind и Bind2. + +Старый transport получает stream conflict/close; его cleanup не удаляет новый +ресурс. Замена отзывает прежний resume token. Очередь и счётчики SM переносятся +только при успешном resume, а не при новой привязке ресурса. + +SM resume допускает ещё живое старое TCP-соединение, проверяет владельца, +токен и h до takeover. Восстановленное состояние получает отдельный Arc: +старый обработчик не может изменять его acknowledgements, очередь и CSI. + +Добавлен SASL2 inline SM resume. Resumed помещается в success с полным JID, +затем повторяются неподтверждённые stanzas. Успешный resume подавляет Bind2; +при неуспехе остаётся Bind2/обычный bind fallback. SM рекламируется в SASL2 +inline только при включённом smacks. + +Исправлены teardown-гонки BOSH terminate и WebSocket close: транспорт даёт +engine завершить cleanup перед fallback abort. Внезапный сетевой обрыв +остаётся негрейсфул, сохраняя возможность SM resume. + +Регрессии: tests/resource_sessions.rs, tests/resource_rebind.py, +tests/sasl2_resume.py, tests/http_transport.rs и scripts/integration_http.py. +Все сетевые fixtures используют отдельные конфиги и временные данные. + +Не выполнена проверка на настоящем устройстве Monal/Conversations, рабочих +данных и APNs/FCM. Флаг --experimental и пределы docs/COMPATIBILITY.md остаются. diff --git a/CHANGELOG-0.6.1.md b/CHANGELOG-0.6.1.md new file mode 100644 index 0000000..1fddba0 --- /dev/null +++ b/CHANGELOG-0.6.1.md @@ -0,0 +1,30 @@ +# 0.6.1 — Android SASL authentication + +Исправлена воспроизводимая несовместимость с выбором channel binding в +Conversations при TLS 1.2. Раньше сервер рекламировал только tls-exporter +для обеих версий TLS. Android selector выбирает exporter только при TLS 1.3, +при TLS 1.2 без другого совместимого типа выбирает обычный SCRAM с GS2 y,,. +Сервер отклонял это как downgrade, а клиент видел not-authorized. + +Добавлены tls-server-end-point на TLS 1.2/1.3 и exporter на TLS 1.3. Тип из +GS2 выбирается из фактически доступных на соединении; проверка binding и +proof не ослаблена. SSDP подписывает полный список объявленных типов. +Digest сертификата соответствует RFC5929: MD5/SHA1 заменяются SHA256; +SHA224/256/384/512, RSA/DSA/ECDSA и RSA-PSS поддержаны. Неизвестные алгоритмы, +EdDSA и неопределённые multi-hash варианты возвращают ошибку; произвольный +SHA256 fallback не применяется. TLS1.3 exporter остаётся доступен независимо +от возможности вычислить certificate binding. + +Исправлен отдельный SSDP edge для WebSocket: при отсутствии CB-типа хеш +не должен содержать пустую CB-секцию с завершающим 0x1f. При явном отключении +всех PLUS-механизмов CB не рекламируется, обычный SCRAM с y,, допускается. +Отказы SCRAM оставляют безопасную диагностику mechanism/stage без credentials. + +Добавлены независимые сетевые проверки Android selector, настоящего +TLS exporter, certificate binding, WSS SCRAM, SSDP и неправильного binding. +Старый 0.6.0 воспроизводит обе найденные ошибки; 0.6.1 проходит новые сценарии. + +В latest monocles есть собственный fallback через n,, и обязательный SSDP; +поведение конкретной установленной версии на устройстве не проверено. +Исправления не доказывают, что любой not-authorized имел эту причину. +Рабочие пароли/данные не изменялись. Полная замена Prosody ещё не подтверждена. diff --git a/CHANGELOG-0.7.0.md b/CHANGELOG-0.7.0.md new file mode 100644 index 0000000..2ea8430 --- /dev/null +++ b/CHANGELOG-0.7.0.md @@ -0,0 +1,27 @@ +# 0.7.0 — operational/authentication logging + +Добавлен instance-owned logger с уровнями debug/info/warn/error, native-style +level maps, explicit sink ranges/sets, console/file, UTC millis, plain/JSON, +0600 append и сериализацией записей. SIGHUP переоткрывает ротируемые файлы, +сохраняя прежние дескрипторы при ошибке. Native syslog/произвольные plugin +sinks и полное hot reload не реализованы. + +TCP/HTTP XMPP transports больше не теряют все ошибки без журнала. Единый ID +прослеживает TLS, SASL features/attempt/challenge, account lookup, точный класс +rejection, auth success, bind/resume и disconnect. Добавлены startup version, +SM hibernation/expiry, resource replacement, maintenance и основные входящие +S2S события. Секреты и XML переписки не передаются в logger; есть фильтр sensitive +keys и escaping. Подробная инструкция и таблица причин — docs/LOGGING.md. + +Исправлены подтверждённые различия: PLAIN нормализует authentication identity +через SASLprep/nodeprep и использует canonical owner; authorization identity +проверяется после нормализации. SCRAM abort отвечает aborted, новая auth +может перезапустить обмен в пределах лимита попыток. Отказы proof и nonce +получили отдельные внутренние причины; wire authentication failure остаётся +обобщённым, чтобы не раскрывать внешнему клиенту состояние account store. + +Новые сетевые регрессии проверяют 8 сценариев журналирования и SIGHUP, +20 случаев normalizing/retry в TLS1.2/1.3 и SASL1/2, плюс logger unit tests. +Оставшийся отказ конкретных Conversations/monocles на устройстве не воспроизведён; +для него теперь можно получить server-side reason и этап. Полная замена Prosody +не подтверждена, остальные ограничения перечислены в docs/COMPATIBILITY.md. diff --git a/CHANGELOG-0.7.1.md b/CHANGELOG-0.7.1.md new file mode 100644 index 0000000..cada8cc --- /dev/null +++ b/CHANGELOG-0.7.1.md @@ -0,0 +1,35 @@ +# 0.7.1 — cached SASL2 inline fallback and Gajim discovery + +По предоставленному журналу TLS завершался успешно, а SASL2 запрос отклонялся +inline validator до проверки SCRAM/PLAIN credentials. В исходниках обоих Android +клиентов Quickstart использует сохранённую доступность inline Bind2 и может +посылать bind после того, как текущая конфигурация перестала его объявлять. +Сервер 0.7.0 возвращал malformed-request для отключённого Bind2. Дополнительная +ошибка регистра в mapper скрывала `bind2 is disabled` за invalid-inline-fields. + +Теперь отключённые optional Bind2/inline SM requests игнорируются, обычная +проверка credentials выполняется, и сервер возвращает post-auth features для +legacy bind. Модуль Bind2 остаётся явно включаемым: ложной рекламы capability +нет. Реализованный путь соответствует проверенному fallback клиентского кода. +Malformed requests включённых расширений по-прежнему отклоняются; wrong password +не принимается. События sasl-attempt/inline-options доступны до validation, +ignored extension отмечается inline-request-ignored; причины parser errors уточнены. + +Добавлены host/account disco features XEP-0258 и XEP-0077, которые проверяют +SecurityLabels/Register modules Gajim. Register discovery означает также +смену пароля/отмену регистрации: allow_registration=false по-прежнему запрещает +создание новых аккаунтов, но не управление своим существующим аккаунтом. +Security label catalog отражает requested @to в обеих версиях. Для точной +policy/classifications схемы предоставленного конфига добавлен catalog-only +adapter: выбирает default_policy, показывает classification names/display/default; +не реализует ACL или обязательное enforcement. Неизвестные policy fields +отклоняются. Это документированное расширение относительно supplied mod_seclabels. + +Logger объединяет одинаковые destinations и маски уровней: перекрывающиеся +console правила больше не дублируют строки. Разные файлы сохраняют свои уровни. + +Старый cached-inline отказ воспроизведён, новый fallback проверен реальным TLS, +SCRAM-SHA-1/PLAIN, SSDP on/off, Bind2/SM flags и bind+ping. Gajim predicates и +фактические catalog/password/create/delete операции проверены по TLS. +Реальные Android/Gajim приложения в этой среде не запускались; полный runtime +остаётся experimental с ограничениями docs/COMPATIBILITY.md. diff --git a/CHANGELOG-0.7.2.md b/CHANGELOG-0.7.2.md new file mode 100644 index 0000000..fefed57 --- /dev/null +++ b/CHANGELOG-0.7.2.md @@ -0,0 +1,46 @@ +# 0.7.2 — PEP operations and diagnostics + +Предоставленный INFO-журнал версии0.7.1 подтверждает успешный SCRAM login и +resource bind Conversations/monocles. Он не содержит PubSub IQ: конкретный +production отказ PEP из него определить нельзя. Диагноз «данные OMEMO повреждены» +по такому журналу не устанавливается. + +Исправлены найденные при проверке совместимости дефекты: + +- IQ error теперь возвращает from исходного to и исходный payload, как требуется + для ответа от имени bare-аккаунта. Отсутствующие PEP узел/элемент по-прежнему + возвращают item-not-found, что OMEMO-клиент обрабатывает как повод публикации. +- Эффективные notification defaults материализуются при обработке существующих + узлов, включая ранее сохранённые конфигурации с пропущенными полями. + Publish-options больше не сообщает ложный precondition-not-met для опций, + которые доставка уже считала включёнными. Реальное несовпадение по-прежнему + возвращает conflict/precondition-not-met; чужое изменение узла запрещено. +- INFO событие pubsub-iq-result показывает action, node_kind, status, condition, + precondition_not_met, количество запланированных deliveries; disco дополнительно + показывает pep_identity, persistent_items, publish_options. Никаких payload, + node suffix, device/item/IQ IDs или ключевых данных. deliveries не является + подтверждением получения телефоном. iq-dispatch-failed сообщает безопасный + io_kind для ошибок хранения/модулей, не печатая их исходный текст. + +Добавлены сценарии первого OMEMO запуска, no-to публикации avatar/devices/bundle, +owner config recovery, TLS/restart persistence, native-like omitted defaults, +filtered caps events и проверка redaction. Это проверки wire/storage, не запуск +Android приложений или криптографическая проверка тестовых bundle bytes. + +DNSSEC: публичные DS/DNSKEY wthome.ru на дату проверки отсутствуют. Инструкция +подписания DNS-зоны и DS у регистратора — docs/DNSSEC.md. Это отдельная задача +оператора DNS, не capability XMPP и не исправление в этой сборке. +Полная замена Prosody пока не подтверждена: docs/COMPATIBILITY.md. + +Проверка мигрированных форм выявила и исправила несовместимость0.7.1: +notification_type, itemreply и native payload_type выдавались в форме, но +не принимались parser при повторной отправке. Теперь payload_type соответствует +wire pubsub#type; notification_type normal/headline и itemreply +none/owner/publisher валидируются. Как в native PEP, уведомления PEP сохраняют +headline; standalone PubSub учитывает normal/headline. itemreply=publisher +включает bare publisher в чтение и уведомления, none/owner не раскрывают его +(поведение предоставленного native handler). Поддержаны publish_model +open/subscribers с проверками блокировок/outcast, без изменения owner-only +прав на create/configure/retract. Неизвестные enum значения отвергаются. +Baseline regression на исходниках ZIP0.7.1 воспроизводит rejected whole-form +submission и ложные default preconditions; патч проходит эти сценарии. diff --git a/CHANGELOG-0.7.3.md b/CHANGELOG-0.7.3.md new file mode 100644 index 0000000..5e922ca --- /dev/null +++ b/CHANGELOG-0.7.3.md @@ -0,0 +1,38 @@ +# 0.7.3 — newest-first PEP item retrieval + +Журнал0.7.2 показывает успешные SASL/bind, own-account PEP discovery, +чтение и публикацию OMEMO. Ошибок на OMEMO IQ в приложенном фрагменте нет; +есть ошибки avatar metadata у отдельных запросов, но без scope они не +доказывают отказ на собственном аккаунте. Пользователь подтвердил полное +завершение процессов Android-приложений; прежний latch нельзя принимать +за установленную причину. + +Найден и воспроизведён дефект выдачи нескольких сохранённых элементов: +сначала выбирались последние N, затем ответ разворачивался ещё раз и +возвращал старый элемент первым. Native Prosody archive_itemstore возвращает +новые первыми. Проверенный monocles IqParser.getItem берёт только первый item; +если старый item содержит пустой список, клиент видит пустой список, хотя +в более позднем item есть устройства и весь IQ имеет type=result. + +В0.7.3 устранено лишнее разворачивание ответа PEP/PubSub items. Поиск по ID, +выбор последних N, права доступа, лимиты хранения и сами данные сохраняются. +Native oracle с настоящим mod_pep подтвердил current,old на тех же данных; +сетевая проверка повторяет первый-item parser, замену current и restart. +Regression на исходниках ZIP0.7.2 воспроизводит old,current и ошибку parser. + +Уточнена безопасная INFO диагностика: target_scope отличает own-account, +other-account, server/resource/implicit/host-or-component. Для успешного +items result добавлены items_count, first_content_kind, first_device_count. +Имена пользователей/узлов/элементов/устройств и payload не добавляются в поля. +Это структурные счётчики, не криптографическая проверка OMEMO. Событие выдаётся +после local dispatch; ранние ветки routing могут не формировать его. + +Наличие нескольких элементов и конкретное содержимое production узлов не +были доступны. Исправленный дефект — подтверждённый сценарий совместимости, +но не доказательство, что он единственный источник индикатора именно этого +устройства. Не внедряется неподтверждённая гипотеза двойного item wrapper: +предоставленный native archive хранит только payload, как и Rust. + +DNSSEC повторно проверен публичными DS/DNSKEY запросами: нет Answer, AD=false. +Зона/DS у регистратора не менялись. Инструкция — docs/DNSSEC.md. +Полная замена Prosody пока не подтверждена: docs/COMPATIBILITY.md. diff --git a/CHANGELOG-0.7.4.md b/CHANGELOG-0.7.4.md new file mode 100644 index 0000000..f4a04b7 --- /dev/null +++ b/CHANGELOG-0.7.4.md @@ -0,0 +1,39 @@ +# 0.7.4 — Converse Origin, HTTP interfaces and PEP diagnostics + +Пользователь подтвердил страницу Converse https://conversejs.wthome.ru/. +Исходный http_cors_override.websocket.origins разрешал только +https://wthome.ru. В изолированном TLS тесте исходная политика даёт101 для +wthome.ru и403 для реального Origin Converse; после добавления точного Origin +Converse получает101. examples/config.json и отдельный YAML fragment обновлены. +Рабочий конфиг на целевом сервере нужно изменить отдельно от сборки. +Wildcard разрешение Origin не добавляется; проверка TLS сохраняется. + +Исправлено игнорирование http_interfaces/https_interfaces: раньше HTTP всегда +слушал0.0.0.0. Теперь учитывается настройка соответствующего протокола, fallback +к interfaces; * раскрывается в IPv4/IPv6, дубликаты убираются, IPv6 socket V6ONLY. +Тест HTTPS-only с глобальным interfaces127.0.0.1 и отдельным HTTPS ::1 подтверждает +обе привязки. INFO listener-ready показывает address/transport/route flags. + +Добавлена диагностика HTTP с connection ID: TLS установление/отказ/таймаут, +фиксированный класс websocket/bosh/other, HTTP status, завершение. Ни URI, +query, Origin/header values, bodies, пароли или raw errors не печатаются. +Новый serve_router_logged сохраняет прежний serve_router API без logger. + +Новый RFC7395 тест выявил настоящий дефект закрытия до SASL: stream close +разбирался как XML document и считался invalid-xml. Теперь закрытие отвечает +stream close, штатно завершает authenticate, затем закрывает WebSocket. +Проверены оба WS пути и browser token-list headers/subprotocol xmpp. + +PEP: production0.7.3 own IQ replies содержат1 item и непустые списки1/2/6 устройств, +так что прошлый multi-item порядок не объясняет текущий случай. Нет доказанного +нового отказа сервера PEP и нет обещания исправления индикатора этим патчем. +Добавлены legacy/OMEMO2 distinction, valid_device_count/invalid_device_count +по inspected Android signed32 parsing и notification-generated content_present. +Синтетический TLS тест показывает: include_payload=false даёт пустое уведомление, +при этом IQ retrieval по-прежнему содержит устройства. Это проверка диагностики, +а не установленная production конфигурация. ID/XML/ключи в журнал не выводятся. + +scripts/filter-android-pep-log.py печатает только фиксированные причины из +клиентского журнала и помогает определить фактическую ветку pepBroken. +DNSSEC повторно: DS/DNSKEY без Answer, AD=false; изменение DNS не выполнялось. +Полная замена Prosody всё ещё не подтверждена, см. COMPATIBILITY.md. diff --git a/CHANGELOG-0.7.5.md b/CHANGELOG-0.7.5.md new file mode 100644 index 0000000..b12f900 --- /dev/null +++ b/CHANGELOG-0.7.5.md @@ -0,0 +1,13 @@ +#0.7.5 + +- Настраиваемые HTTP-пути WS/BOSH/upload, HTTP Host/fallback и публичный URL + reverse proxy с префиксом. +- Доверенные proxy IP/CIDR, обработка X-Forwarded-For/Proto или RFC7239 + Forwarded, безопасное игнорирование заголовков недоверенного peer. +- Проверка защищённости WS/BOSH на запросе, TLS termination без глобального + consider_*_secure; эффективный peer в серверной диагностике. +- Проверки кастомных маршрутов и loopback TLS reverse proxy; инструкция + docs/HTTP-PROXY.md. + +Полная замена Prosody ещё не подтверждена; актуальные границы — +docs/COMPATIBILITY.md, результаты — docs/VALIDATION.md. diff --git a/CHANGELOG-0.7.6.md b/CHANGELOG-0.7.6.md new file mode 100644 index 0000000..a86e748 --- /dev/null +++ b/CHANGELOG-0.7.6.md @@ -0,0 +1,14 @@ +# 0.7.6 + +- MUC: hats, язык, политики PM/модераторских PM, presence broadcast по ролям, + дополнительные формы/дискавери и отключаемые vCard/occupant-id. +- История MUC без MAM; ограничения запроса истории и native последнее сообщение. +- MAM открытых комнат, forced archiving, presence records, optional totals, + очистка архива при уничтожении комнаты. +- Native occupant checkpoints persistent/temporary комнат при штатной остановке; + повторная аутентификация точного полного JID, проверка конфигурации и300-секундный + grace без активации недоступных адресатов. Состояние нескольких ресурсов. +- Защита серверных MUC identity/hats от клиентской подмены. + +Подробные условия восстановления и отличия от native: docs/MUC-RECOVERY.md. +Полная взаимозаменяемость сервера пока не подтверждена. diff --git a/CHANGELOG-0.7.7.md b/CHANGELOG-0.7.7.md new file mode 100644 index 0000000..d6a1557 --- /dev/null +++ b/CHANGELOG-0.7.7.md @@ -0,0 +1,10 @@ +# 0.7.7 + +- Встроенный http_altconnect: XRD/JRD host-meta discovery, CORS, HEAD/OPTIONS. +- Реклама включённых BOSH/WS, advertise_bosh/advertise_websocket и отключение + discovery отдельно от транспорта. +- Custom http_paths.altconnect, HTTP Host/fallback и публичный URL reverse proxy. +- Публичные href из конфигурации с предпочтением HTTPS; native HTTP fallback. +- Конфликты с transport routes и неправильные advertise-типы отвергаются. + +Включение и пример reverse proxy — docs/HTTP-ALTCONNECT.md. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..73faf58 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2182 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.8", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.8", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "lua-src" +version = "551.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d400ffef0e3d4d29287092bdc5a276d0bf468b2c69709d5bab0d469312d9f947" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.7.4+c6ffc14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db548b001f0b7da416daf0ffae963912bae6cad1799fa9f4eaba09da6dba837" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7ae6b65bf54b7fdd4cf5b6f44f60862350ebf3fe3779b3a9cc005e7fa14f81" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1631cda17bd6486410ab751d91f5fd4484f441589278cdad879eb3afc721a285" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rust-xmpp" +version = "0.7.7" +dependencies = [ + "anyhow", + "axum", + "base64", + "chrono", + "futures-util", + "glob", + "hex", + "hickory-resolver", + "hmac", + "http-body-util", + "hyper-util", + "mlua", + "pbkdf2", + "roxmltree", + "rustls-native-certs", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yaml_ng", + "sha1", + "sha2", + "socket2", + "stringprep", + "subtle", + "tempfile", + "tokio", + "tokio-rustls", + "tokio-util", + "toml", + "uuid", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a8f4b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "rust-xmpp" +version = "0.7.7" +edition = "2024" +description = "Experimental Rust XMPP server with Prosody-compatible storage" +license = "MIT" + +[dependencies] +anyhow = "1" +mlua = { version = "0.12", features = ["lua54", "vendored", "serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml_ng = "0.10" +toml = "0.9" +glob = "0.3" + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal", "fs"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +rustls-pemfile = "2" +roxmltree = "0.21" +base64 = "0.22" +sha1 = "0.10" +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = "0.12" +hex = "0.4" +subtle = "2" +stringprep = "0.1" +uuid = { version = "1", features = ["v4"] } + +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } + +axum = { version = "0.8", features = ["ws"] } +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["io"] } +http-body-util = "0.1" +hyper-util = { version = "0.1", features = ["server-auto", "tokio", "service"] } + +socket2 = "0.6" + +hickory-resolver = { version = "0.24", features = ["tokio-runtime", "system-config"] } +rustls-native-certs = "0.8" + +[dev-dependencies] +tempfile = "3" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b45559 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1.99.1-slim AS builder +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release --locked + +FROM debian:trixie-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates gosu \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /src/target/release/rust-xmpp /usr/local/bin/rust-xmpp +RUN ln -s /usr/local/bin/rust-xmpp /usr/local/bin/prosodyctl +COPY scripts/docker-entrypoint.sh /usr/local/bin/rust-xmpp-entrypoint +RUN chmod 0755 /usr/local/bin/rust-xmpp-entrypoint +WORKDIR /work +ENTRYPOINT ["rust-xmpp-entrypoint"] +CMD ["serve", "/etc/prosody/prosody.cfg.lua", "--experimental"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0ec081 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rust XMPP contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e961aee --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# Rust XMPP 0.7.7 + +Сервер на Rust, разрабатываемый для замены **Prosody 13.0 nightly 100, +cafd74b9c5ea** с предоставленными модулями. Читает существующий Lua-конфиг, +а также JSON, YAML/YML и TOML. Использует внутреннее файловое хранилище +Prosody: конвертация аккаунтов и истории для проверенных сценариев не нужна. +Nginx не требуется: HTTPS, WebSocket, BOSH и загрузка файлов обслуживаются +самим сервером; coturn остаётся отдельным сервисом. + +Это работающая экспериментальная реализация, **полная взаимозаменяемость +ещё не достигнута**. Уже реализованы вход, сообщения, контакты, MAM, MUC, +PEP/pubsub, закладки, offline, carbons, SM, push, TLS-федерация и HTTP. +Точные ограничения перечислены в [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md), +проверки — в [docs/VALIDATION.md](docs/VALIDATION.md). + +Нестандартные 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). + +Встроенный http_altconnect: [docs/HTTP-ALTCONNECT.md](docs/HTTP-ALTCONNECT.md). + +## Сборка и изолированный запуск + +Нужны Rust 1.99.1 (rustup установит версию из `rust-toolchain.toml`), +C-компилятор и make. Для тестовых сертификатов и сетевых проверок нужны +OpenSSL CLI, Bash и Python 3; HTTP-тест дополнительно использует Python `websockets`. +Системные CA-сертификаты нужны для проверки удалённых серверов федерации. +Cargo.lock фиксирует зависимости; первая сборка требует их загрузки. + +```bash +cd rust-xmpp +cargo build --release --locked +bash scripts/make-demo.sh +./target/release/rust-xmpp serve demo/config.toml --experimental +``` + +Demo слушает только `127.0.0.1:55222` и создаёт отдельное хранилище `demo/data`. +Аккаунты: `alice@localhost / alice-test-only` и +`bob@localhost / bob-test-only`. Для ручного клиента используйте STARTTLS +и доверьте сгенерированному `demo/cert.pem`. `make-demo.sh` не перезаписывает +существующий demo. Сертификаты и аккаунты в исходный архив не включены. + +## Существующий конфиг без редактирования + +```bash +./target/release/rust-xmpp check-config /etc/prosody/prosody.cfg.lua +./target/release/rust-xmpp serve /etc/prosody/prosody.cfg.lua --experimental +``` + +`check-config` проверяет разбор и базовые типы; ограничения runtime проверяются +при `serve`. Существующий Lua-файл читается непосредственно, преобразовывать +его в другой формат не требуется. Процессу нужны права на сертификат, ключ, +каталог данных и Unix admin socket. Используйте тот же UID/GID, что и Prosody, +чтобы сохранять доступ при возврате к нему. + +Первую проверку выполняйте на копии данных с отдельными портами: сервер пишет +аккаунты, историю, комнаты и другие данные в обычном режиме. Одновременно +запускать Rust и Prosody с одним каталогом данных нельзя. Между экземплярами +Rust действует блокировка inode каталога данных, в том числе при разных портах +и путях-синонимах. Prosody в этой блокировке не участвует. Команда create-user +также отказывается писать в каталог работающего Rust-сервера; для изменения +аккаунтов во время работы используйте admin shell. Для переключения остановите один процесс и запустите +другой. Резервная копия нужна для проверки отката до переключения рабочего +сервера; тестирование формата не доказывает совместимость всех ваших записей. + +## Ваш Docker Compose + +Распакуйте каталог `rust-xmpp` рядом с исходным `docker-compose.yml`. +Дополнительный файл заменяет образ только сервиса `prosody`. Порты, тома, +healthcheck `prosodyctl check` и сервис coturn наследуются из исходного файла. + +```bash +docker compose -f docker-compose.yml -f rust-xmpp/compose.override.yml config +docker compose -f docker-compose.yml -f rust-xmpp/compose.override.yml up -d --build prosody +``` + +Эта команда запускает сервер на томах из исходного Compose; применяйте её +после проверки на копии. Исходные `docker-compose.yml` и `prosody.cfg.lua` +редактировать не требуется. Образ содержит CA-сертификаты и команду +`prosodyctl`, совместимую с указанным healthcheck. Это проверка ответа +работающего процесса через admin socket, а не полный аналог диагностики +`prosodyctl check`. + +Entrypoint определяет UID/GID владельца `/var/lib/prosody` и запускает процесс +с этими правами, не меняя рекурсивно владельцев существующих файлов. Если +каталог принадлежит root, а файлы внутри другому UID, задайте `XMPP_UID` и +`XMPP_GID` в окружении контейнера через дополнительный Compose-файл. +При другом пути данных задайте также `XMPP_DATA_DIR`. Выбранный пользователь +должен читать TLS-ключ и писать данные. Docker-образ в среде разработки +не собирался: здесь нет Docker; состав образа и override подготовлены, +но запуск на целевом Docker ещё предстоит проверить. + +## Форматы конфигурации + +Все форматы используют одну структуру: `global` и `hosts`; у хоста есть +`options` и, для компонента, `component`. Надёжнее получить её из Lua: + +```bash +./target/release/rust-xmpp convert /etc/prosody/prosody.cfg.lua json config.json +./target/release/rust-xmpp convert /etc/prosody/prosody.cfg.lua yaml config.yml +./target/release/rust-xmpp convert /etc/prosody/prosody.cfg.lua toml config.toml +./target/release/rust-xmpp check-config config.yml +``` + +Конвертер сохраняет области настроек и Include, не меняет исходный файл, +не перезаписывает существующий выходной и создаёт его с правами `0600`. +Выходной файл содержит секреты конфигурации. Lua выполняется в ограниченной +VM без системных библиотек; произвольные Lua-плагины не исполняются. +Поддержка Lua в проекте также нужна для точного чтения файлов данных Prosody. + +## Команды и проверки + +Полный локальный прогон: `bash scripts/verify-local.sh`. +`XMPP_LOAD_CHECK=1` добавляет нагрузочный сценарий; настройки native Prosody +для дополнительных проверок описаны в `scripts/prosody_compat/README.md`. + +```bash +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +cargo build --release --locked +python3 scripts/smoke.py # после make-demo.sh +python3 scripts/integration_xmpp.py +python3 tests/sasl_network.py --spawn --hash sha1 +python3 tests/sasl_network.py --spawn --hash sha256 +python3 tests/sasl2_bind.py target/release/rust-xmpp +python3 scripts/integration_http.py # требует websockets +python3 scripts/load_smoke.py --clients 500 --accounts 100 --rounds 10 --message-rounds 10 --archive +``` + +Все сетевые тесты создают отдельные тестовые данные; кроме smoke с портом +55222, выбирают свободные loopback-порты. Оригинальная Prosody отдельно +проверяет формат данных и пароли: инструкции в +[scripts/prosody_compat/README.md](scripts/prosody_compat/README.md). + +```bash +./target/release/rust-xmpp create-user CONFIG USER --password-stdin +./target/release/rust-xmpp check [CONFIG] +./target/release/rust-xmpp shell CONFIG 'server:status()' +``` + +Пароль `create-user` принимает через stdin; существующий аккаунт не заменяет. +Admin shell реализует ограниченный набор команд управления сервером и +аккаунтами. Произвольный Lua-код и горячая перезагрузка модулей не поддержаны. + +## Дополнительный SASL2 Bind2 + +Модуль `sasl2_bind2` (alias `bind2`) включает привязку ресурса внутри SASL2, +inline SM/CSI/Carbons и MAM metadata. Добавьте его в modules_enabled вместе +с `sasl2` только если эта функция нужна. Исходный конфиг по-прежнему работает +без включения нового модуля. Ресурс генерируется случайно; прежние соединения +того же аккаунта с тем же валидным UUIDv4 user-agent завершаются после успешного +входа. Inline возобновление SM через SASL2 пока не объявляется. + +## PEP, push и pre-authenticated roster + +В 0.5.0 исправлены ответы disco для bare JID аккаунта. При включённых +`pep` и `cloud_notify` (они уже есть в предоставленном конфиге) изменение +конфига не требуется. После обновления переподключите аккаунт в клиенте, +чтобы повторить service discovery. Push требует также настройки +клиентского push-сервиса; реальный APNs/FCM шлюз здесь не имитируется. + +При включённом `roster` клиент может обнаружить и вызвать контактную +ad-hoc команду `urn:xmpp:invite#invite` на серверном JID. Она возвращает +`xmpp:…?roster;preauth=…` и срок действия. Регистрацию аккаунта такое +приглашение не разрешает. XEP-0379 presence tokens сохраняются даже +для неизвестных серверу приглашений, созданных самим клиентом. + +## Переподключение в 0.6.0 + +Повторная привязка одного ресурса по умолчанию заменяет старое соединение, +как в Prosody. `conflict_resolve` поддерживает `kick_old`, `kick_new`, +`increment` и `random`. Если явно задан `kick_new`, занятый ресурс по-прежнему +получит protocol conflict — это выбранная политика. + +SM восстанавливается при гонке со старым TCP и внутри SASL2. Исправлено +освобождение ресурсов при BOSH/WebSocket close. Подробности: +[CHANGELOG-0.6.0.md](CHANGELOG-0.6.0.md). + +## Android SASL в 0.6.1 + +Добавлен `tls-server-end-point` для TLS1.2/1.3 и исправлена SSDP-проверка +соединений WebSocket без channel binding. Причина воспроизводимого +`not-authorized` в Conversations, область тестирования и ограничения +описаны в [CHANGELOG-0.6.1.md](CHANGELOG-0.6.1.md) и +[docs/ANDROID-SASL.md](docs/ANDROID-SASL.md). + +## Логирование в 0.7.0 + +Добавлены журнал TLS/SASL/bind/SM с единым connection ID, точные причины +отказов, файлы/console, JSON/plain и SIGHUP rotation reopen. Для диагностики +Conversations/monocles используйте [docs/LOGGING.md](docs/LOGGING.md). + +## Исправления 0.7.5 + +Cached SASL2 Bind2/SM requests допускают legacy fallback, XEP-0258/0077 +объявляются для Gajim; policy/classifications схема может использоваться +как каталог labels. Дубликаты одинаковых log destinations устранены. +Подробности — [CHANGELOG-0.7.5.md](CHANGELOG-0.7.5.md). diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 0000000..b7bd46b --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,11 @@ +# Run from the directory holding the ORIGINAL docker-compose.yml: +# docker compose -f docker-compose.yml -f rust-xmpp/compose.override.yml config +# Then use up -d --build prosody after validation on copied data. +# The base file's ports, volumes, coturn service and healthcheck are inherited. +services: + prosody: + image: rust-xmpp:0.5.0 + pull_policy: never + build: + context: ./rust-xmpp + dockerfile: Dockerfile diff --git a/docs/ANDROID-SASL.md b/docs/ANDROID-SASL.md new file mode 100644 index 0000000..10334b8 --- /dev/null +++ b/docs/ANDROID-SASL.md @@ -0,0 +1,55 @@ +# Источники проверки Android SASL +Проверено 2026-10-20. Java source использовался для анализа выбора механизма; +Android-приложения на устройстве не запускались. Python regression повторяет +выбор из Conversations, не всю реализацию приложения. + +- https://xmpp.org/extensions/xep-0440.html (1.0.0) +- https://xmpp.org/extensions/xep-0474.html +- https://www.rfc-editor.org/rfc/rfc5929.html +- https://www.rfc-editor.org/rfc/rfc9266.html +- https://codeberg.org/iNPUTmice/Conversations +- https://codeberg.org/monocles/monocles_chat + +Файлы fetched с mutable master. Следующие branch-head значения получены +после fetch; это не доказательство скачивания каждого файла по commit pin: + +Retrieved 2026-10-20 UTC from mutable master; no commit pin confirmed. +https://codeberg.org/iNPUTmice/Conversations/raw/branch/master/src/main/java/eu/siacs/conversations/crypto/sasl/ +API master commit observed after source fetch: bf3269cd128d4dbf0e479c091477fd9544a798b0 +monocles master commit: 0bc8473a5d4597d1ffe5545055da692ef8b89d37 + +Локальные SHA256 загруженных исходников: + +- ChannelBinding.java: 88723522ed5bb53e947ad7afbc8cd0f8712ea4747d847347c3e5cd7b9aa41389 +- ChannelBindingMechanism.java: e8a492de0eb866fc93d673cbc46bd5fae18ecf4b7bb46e272e6dca9e8c086937 +- DowngradeProtection.java: a3fe98c2ccad998deb46f5748411a1a26506215cabb2a4e1d7adce0f51f2ac03 +- SaslMechanism.java: 9bec637e7d62cc86a7b09cb8353e6272c80fd7b5d94e305b936bcf8c5a611e73 +- ScramMechanism.java: aa5b2cdc5389a12e8de8fef562f0ee2de45fbdf61fa8fdbd33ea63a5b9299ae8 +- monocles-ChannelBinding.java: 88723522ed5bb53e947ad7afbc8cd0f8712ea4747d847347c3e5cd7b9aa41389 +- monocles-ScramMechanism.java: fea3687a12260d9280e204cc003d689043654950118e2d1dc84c058c736234e7 + +Conversations selector предпочитает exporter только на TLS1.3, затем +unique на TLS≤1.2, затем endpoint. При отсутствии совместимого объявленного +типа старый путь использует NONE/y,,. Monocles master содержит дополнительный +continueWithoutChannelBinding с n,, и обязательным downgrade protection; +его поведение зависит от версии и не полностью моделируется Python тестом. + +## Cached inline requests в 0.7.1 + +Полученный серверный журнал показывал TLS1.3 success и inline-request-malformed +до sasl-attempt. В server 0.7.0 строка `bind2 is disabled` не совпадала с +`Bind2 is disabled` в diagnostic mapper. В supplied конфигурации Bind2 явно +не включён. Quickstart constructor в клиентских XmppConnection.java использует +QUICKSTART_FEATURES, даже если на новом соединении Bind2 уже не объявлен. +Это воспроизведено новым tests/sasl2_cached_inline.py на binary0.7.0. + +В0.7.1 unavailable optional bind/resume не отклоняет credentials exchange. +Bare authorization identifier и post-auth legacy bind позволяют клиентскому +fallback продолжить вход; available Bind2 работает по прежнему. Это решение +совместимости с фактическим client fallback, а не ссылка на явное MUST-ignore +из XEP-0388. Wrong password и malformed enabled extensions не разрешаются. + +Источники: Conversations и monocles `src/main/java/eu/siacs/conversations/ +xmpp/XmppConnection.java`, `im/conversations/android/xmpp/model/sm/Enable.java`, +полученные с mutable master 2026-10-20. Authentication success handler оставляет +isBound=false без bound, затем processSecureStreamFeatures делает обычный bind. diff --git a/docs/CHANGELOG-0.4.0.md b/docs/CHANGELOG-0.4.0.md new file mode 100644 index 0000000..35b906b --- /dev/null +++ b/docs/CHANGELOG-0.4.0.md @@ -0,0 +1,39 @@ +# 0.4.0 + +Экспериментальная версия; полная взаимозаменяемость с Prosody не заявляется. + +- SASL2 Bind2: опциональный модуль `sasl2_bind2` (alias `bind2`), inline SM, + CSI, Carbons, MAM metadata, случайные ресурсы и замена сессии того же + аккаунта с тем же валидным UUIDv4 устройства. SASL2 inline resume пока нет. +- PubSub/PEP: full-JID blocklist во всех путях доставки и replay, соблюдение + `include_payload` при replay, native deliver_notifications/deliver_payloads, + независимые правила notify_retract, корректные publish-options ошибки и + сравнение native строковых лимитов max_items. +- MUC: защита от понижения/исключения равного модератора без admin/owner, + сохранность бинарной occupant_id_salt и приоритет native _affiliations. + Нативные MUC-блокировки домена запрещают вход и исключают текущих участников. +- Федерация: общий deadline аутентификации, проверка положения TLS/SASL + features в XML, нормализация регистра доменов, проверка DNS labels и + безопасное переподключение до передачи stanza актору записи. +- Push: только текст настоящего body/subject в namespace сообщения включается + в summary/importance; содержимое чужих XML extensions не раскрывается. +- Сообщения: fallback отсутствующего full JID на активные ресурсы, native + правила headline/error/groupchat; offline-доставка bodyless receipts и + no-permanent-store без сохранения последнего в MAM. +- HTTP: обрезка допустимого byte range до конца файла, квоты просроченных + незагруженных слотов, синхронизация PUT с очисткой, регистронезависимый + Bearer и сохранение XML namespaces WebSocket/BOSH. Поддержаны URL с завершающим + слешем и WS CORS OPTIONS. BOSH продолжает обработку RID после HTTP disconnect, + сохраняет ответ для повторного запроса и не выполняет stanza дважды. + Число обработчиков и время выполнения ограничены. +- Каталог данных блокируется между процессами Rust, включая CLI create-user. + Блокировка не защищает от одновременно работающей Prosody. +- Нагрузочный сценарий поддерживает несколько аккаунтов и несколько раундов + сообщений; проверяется также адрес реального отправителя. +- `bash scripts/verify-local.sh` повторяет сборку и доступные локальные + протокольные проверки. HTTP-тест требует Python package `websockets`. + +Изменений формата пользовательских файлов не требуется. Существующий конфиг +не включает Bind2 автоматически. Для переключения рабочего сервера всё ещё +требуются проверка копии данных и проверки целевых клиентов/окружения; +актуальные пределы перечислены в COMPATIBILITY.md. diff --git a/docs/CHANGELOG-0.5.0.md b/docs/CHANGELOG-0.5.0.md new file mode 100644 index 0000000..d8ff439 --- /dev/null +++ b/docs/CHANGELOG-0.5.0.md @@ -0,0 +1,26 @@ +# 0.5.0 + +Исправлены клиентские проверки PEP и push: + +- Account disco на bare JID: identity account/registered, pubsub/pep при PEP, + automatic/filtered PubSub features. Серверный адрес остаётся server/im. +- Push feature urn:xmpp:push:0 появляется на своём аккаунте с cloud_notify, + независимо от PEP. Не объявляется identity внешнего push-шлюза pubsub/push. +- При раннем PEP reply сохраняются MAM и остальные account features, включая + поддерживаемые bookmarks/vCard conversions; leaf disco/ACL не подменяются. +- Регрессия воспроизводит predicates handleAccountDiscoInfo из исходника Monal. +- XEP-0379: preauth сохраняется в пересылке и pending presence для неизвестного + серверу токена; валидный серверный token однократный, с expiry/owner scope, + приводит к подтверждению и обратному запросу подписки. +- XEP-0401: реализована контактная ad-hoc команда urn:xmpp:invite#invite и её + discovery на серверном JID; выдаётся URI и expiry. Нет прав регистрации, + нет ibr=y, landing page и account-creation command. +- RFC6121 preapproval объявляется в stream features вместе с roster versioning. +- Unscoped invite_token совместим с настоящим mod_invites; native test + проверяет создание/использование токенов в обе стороны и изоляцию scope. + +Текущий конфиг с roster, pep, cloud_notify не требует редактирования для этих +исправлений. Клиенту нужно повторить discovery после переподключения аккаунта. +Доставка push на физическое устройство зависит от используемого шлюза; +проверена серверная регистрация и сериализация, а не реальный APNs/FCM цикл. +Функциональные пределы полной замены остаются в COMPATIBILITY.md. diff --git a/docs/CLIENT-DISCOVERY.md b/docs/CLIENT-DISCOVERY.md new file mode 100644 index 0000000..3ac03eb --- /dev/null +++ b/docs/CLIENT-DISCOVERY.md @@ -0,0 +1,23 @@ +# Клиентские признаки и проверки + +Проверено 2026-10-20 по официальным XEP и исходнику Monal develop: +https://github.com/monal-im/Monal/blob/develop/Monal/Classes/MLIQProcessor.m + +handleAccountDiscoInfo требует для PEP identity pubsub/pep и features +filtered-notifications, publish-options, publish, subscribe, create-nodes, +delete-items, delete-nodes, persistent-items, retrieve-items, auto-create. +Для modern PEP дополнительно проверяет item-ids/multi-items; для max-items +проверяет config-node-max. Push включается по urn:xmpp:push:0 на аккаунте. +Эти predicates воспроизведены в tests/push_discovery.py. Само приложение +в среде разработки не запускалось. + +Стандарты: +- https://xmpp.org/extensions/xep-0163.html §6.1 +- https://xmpp.org/extensions/xep-0357.html §4.1 +- https://xmpp.org/extensions/xep-0379.html §3–5 +- https://xmpp.org/extensions/xep-0401.html §3, §5.1 + +XEP-0379 определяет токен в presence и URI. Генерация серверной ссылки +использует контактную ad-hoc команду XEP-0401, не выдуманный IQ PARS. +Истёкший/неизвестный токен не даёт автоматических прав и сохраняет ручной +workflow. tests/preauth_network.py проверяет весь контактный flow через TLS. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..e80cc63 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,141 @@ +# Совместимость с предоставленной установкой + +Цель — конкретная Prosody `cafd74b9c5ea` и предоставленные community/custom +модули ревизии `dddb6c34b964`, а не все существующие модули Prosody. +Реализация содержит рабочие обработчики всех явно включённых имён модулей +из конфига. Это не означает полного совпадения их поведения. + +| Область | Что реализовано | Существенные пределы | +|---|---|---| +| Конфигурация | Lua/JSON/YAML/YML/TOML, Include, глобальные и локальные области, наследование модулей | Lua VM ограничена; не все функции окружения конфигуратора Prosody доступны. Runtime обслуживает один VirtualHost и его компоненты | +| Хранилище | internal `.dat`, `.list`, бинарные строки, смешанные ключи Lua, атомарные записи, потоковые архивы, инвалидирование `.lidx` | SQL, external storage и произвольные драйверы не реализованы. Каталог блокируется между процессами Rust; Prosody в блокировке не участвует | +| Аккаунты, 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 | +| 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 шлюзом не проверен. Состояние живых сессий не переживает перезапуск процесса | +| 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, streaming PUT, GET/HEAD/range/conditional, лимиты/квоты/expiry, native metadata, JWT slots | Один upload-компонент; внешнее объектное хранилище не реализовано. Фактический upload 10 GiB не прогонялся | +| WebSocket/BOSH | WS/WSS и HTTP/HTTPS, общая XMPP-сессия, origin rules, BOSH RID/retry/hold/reorder/restart; http_altconnect XRD/JRD discovery; http_paths, HTTP Host/fallback, внешний URL, доверенные IP/CIDR и forwarded headers для TLS reverse proxy | Один VirtualHost с компонентами; нет PROXYv1/v2, произвольных Lua HTTP-приложений и горячей перенастройки. См. HTTP-PROXY.md | +| admin_shell | протокол admin socket, status/version/uptime, списки, основные операции аккаунтов | Нет произвольного Lua, горячей загрузки модулей и полного набора prosodyctl | +| Остальные модули | disco, ping, time, version, uptime, register, TURN extdisco/HMAC, каталог seclabels | Нет встроенного TURN. Coturn работает отдельно. `pep_simple` пуст в предоставленном исходнике | + +## Особенности именно вашего конфига + +- `allow_registration = true` действительно разрешает регистрацию, несмотря + на противоречащий этому комментарий в исходном файле. +- Личная история настроена `never`, комнатная — на неделю. Очистка выполняется + периодически; append не переписывает весь архив. +- Лимит отдельного файла — 10 GiB, общий — 100 GiB. Сетевой тест проверяет + потоковую загрузку меньшего файла; это не проверка фактической загрузки 10 GiB. +- Структура `security_labels.default_policy/policies/classifications` отличается + от native `mod_seclabels`. С0.7.1 её точная schema может использоваться как + catalog metadata: выбранный default_policy и его classifications. ACL и + обязательное enforcement не реализуются; неизвестные поля отвергаются. +- Настройки `limits` сами по себе не включают `mod_limits`; он не указан + среди включённых модулей исходного конфига и не является autoload-модулем + предоставленной Prosody. В этой реализации транспортного rate limiting пока нет. +- Чужие Lua-плагины из каталога `custom_plugins` автоматически не запускаются. + Наличие исходников 479 каталогов не означает, что все они были включены. + +## Что осталось для утверждения «полная замена» + +Нужны подтверждённый запуск на копии реальных данных, проверки настоящими +Monal/Conversations/Gajim и используемым push-шлюзом, совместимость с внешними +XMPP-доменами и сертификатами, проверка целевого Docker/UID/TLS, откат, долгий +нагрузочный прогон и устранение оставшихся различий из таблицы. +Текущие автоматические проверки покрывают конкретные сценарии и не заменяют +эти критерии. Проект намеренно сохраняет флаг `--experimental`. + +## Исправления discovery и pre-auth в 0.5.0 + +PEP описывается identity `pubsub/pep` на bare JID аккаунта. Push feature +`urn:xmpp:push:0` возвращается на собственном bare JID при включённом +`cloud_notify`, в том числе без PEP. Серверный JID остаётся `server/im`. +PEP leaf nodes сохраняют `node`, собственные ACL и ошибки отсутствующего узла. +Функции MAM, блокировок и другие account features не теряются в раннем PEP reply. + +С `roster` доступны RFC6121 subscription preapproval и контактная часть +XEP-0379/XEP-0401: ad-hoc `urn:xmpp:invite#invite`, одноразовые токены +с истечением срока, автоматическое подтверждение и обратная подписка. +Неизвестный клиентский preauth сохраняется при пересылке и в pending presence, +чтобы владелец мог подтвердить подписку вручную или в своём клиенте. + +Нет приглашений на создание/сброс аккаунта и landing page; `ibr=y` не выдаётся. +Unscoped токены используют native per-user `invite_token` map; scoped токены +хранятся отдельно, чтобы Prosody без поддержки scope не мог выдать лишние права. +Токен потребляется до нескольких roster-записей: при дисковой ошибке между +записями нет multistore rollback, ошибка передаётся вызывающему коду, токен +повторно автоматически не авторизует подписку. Возможен ручной fallback. + +## Переподключение в 0.6.0 + +`conflict_resolve` следует native sessionmanager: default/kick_old, kick_new, +increment и random. Новая привязка отзывает прежний resume token при замене; +она не наследует старые SM acknowledgements. Успешный resume сохраняет SM, +Carbons, push identifier и очередь, изолируя старый transport от нового состояния. + +BOSH terminate ждёт engine cleanup, WebSocket close/EOF закрывает input и +даёт engine завершиться перед ограниченным timeout abort. Сетевой тест +использует kick_new, чтобы случайное hibernation не скрылось takeover-политикой. + +## Android SASL в 0.6.1 + +На TLS1.2 предлагается certificate endpoint binding, на TLS1.3 также exporter. +Проверены SHA1/256, SASL1/2, SSDP on/off, tampered bindings, WSS без CB и +явно отключённый PLUS. Monocles имеет версионные различия fallback; настоящие +устройства не запускались. Certificate signature algorithms EdDSA/unknown +не используются для endpoint binding; TLS1.3 exporter при этом сохраняется. + +## Логирование в 0.7.0 + +Native-style log level maps и explicit sinks, console/file, JSON/plain, UTC +millis и SIGHUP reopen реализованы. В scope входят TLS/SASL/bind/SM, startup, +maintenance и основные входящие S2S события. Syslog/plugin sinks, mixed Lua +log tables, полный hot reload и журналы всех модулей не реализованы. +PLAIN теперь нормализует имя/authorization identity; wire abort/retry проверены +отдельно. Журнал нужен для диагностики оставшегося реального Android отказа. + +## Cached SASL2 и Gajim в 0.7.1 + +Bind2 остаётся явно включаемым. Сохранённый Quickstart request к недоступному +optional extension не мешает credentials exchange и последующей обычной +привязке. Inline SM при отключённом smacks также не обрабатывается. +Host/account discovery объявляет seclabels/register по наличию реальных +обработчиков; создание новых аккаунтов по-прежнему зависит от allow_registration. +Gajim predicates проверены по python source, реальные приложения не запускались. +Policy catalog adapter — расширение для supplied config, а не ACL implementation. + +## PEP и DNSSEC в 0.7.2 + +Добавлены проверки OMEMO/аватаров с публикацией без to, recoverable missing +bundles, publish-options/config retry, сохранением после restart и скрытой +от журнала полезной нагрузкой. IQ errors адресуются от имени исходной цели; +notification defaults совпадают с эффективной конфигурацией при preconditions. +Конкретный production IQ отказ из INFO-лога0.7.1 неизвестен. +DNSSEC клиент проверяет до XMPP; текущая зона wthome.ru не имеет обнаруженных +DS/DNSKEY. Требуется настройка DNS/регистратора, docs/DNSSEC.md. Эта сборка не +добавляет DNSSEC validation в outbound federation. + +## PEP порядок в 0.7.3 + +PEP/PubSub items выдаются newest-first, как реальный supplied mod_pep. +Протокольный regression сохраняет старую пустую и новую populated devicelist, +проверяет первый item как Android parser, замену current и restart. Native +oracle отдельно подтверждает порядок. Это не гарантирует исправления каждого +production индикатора: реальная конфигурация/содержимое узлов не предоставлены. + +## HTTP и диагностика0.7.4 + +HTTPS учитывает https_interfaces (включая* → IPv4+IPv6), HTTP — http_interfaces, +с fallback к interfaces. Добавлена безопасная HTTP/TLS/status диагностика. +Закрытие до SASL обрабатывается штатно и завершает WebSocket. Для подтверждённой +страницы Converse https://conversejs.wthome.ru добавлен точный allowed Origin +в примеры; рабочий конфиг требуется обновить отдельно. Runtime wire test +подтверждает original403 → configured101, IPv4/IPv6 и TLS validation. +PEP production причина по-прежнему не доказана; новая диагностика различает +parser-valid IDs и форму уведомлений. Нет подтверждения Android UI, живого +production WSS, DNSSEC signing или полной замены Prosody. diff --git a/docs/CONVERSE-WSS.md b/docs/CONVERSE-WSS.md new file mode 100644 index 0000000..8d384cd --- /dev/null +++ b/docs/CONVERSE-WSS.md @@ -0,0 +1,50 @@ +# Converse.js: https://conversejs.wthome.ru → WSS5281 + +Страница браузера и endpoint имеют разные роли: + +- Страница: https://conversejs.wthome.ru/ → Origin https://conversejs.wthome.ru. +- XMPP endpoint: wss://wthome.ru:5281/xmpp-websocket. + +В рабочий YAML конфиг под global добавьте точный Origin, сохранив остальные +настройки http_cors_override и текущие origins: + +```yaml +global: + http_cors_override: + websocket: + enabled: true + origins: + - https://wthome.ru + - https://conversejs.wthome.ru +``` + +Это fragment, не полный конфиг: не заменяйте им hosts/data_path/SSL и прочие +параметры. examples/converse-origin.fragment.yaml содержит тот же фрагмент; +полный examples/config.json тоже обновлён. Примените изменение к вашему +prosody.rust-xmpp.cfg.yaml и перезапустите процесс после пересборки0.7.4. +Endpoint можно оставить прежним. В настройке клиента websocket_url должен +указывать полный WSS URL с путём. Сертификат endpoint должен быть доверенным +и действительным для wthome.ru; Origins не меняют TLS hostname validation. + +Изолированный test воспроизводит исходный403 для нужного Origin и исправленный101. +Он использует настоящую проверку тестового CA/hostname, оба endpoint paths, +HTTP/1.1 Connection token list, обязательный subprotocol xmpp, отсутствие/неверный +subprotocol400, посторонние origins403, неизвестный путь404, invalid CA rejection, +RFC7395 open/features/close до SASL. Параметры auth/bind отдельно проверяются +sasl_web_network и integration_http. Интерфейсы127.0.0.1 и::1 подтверждены по TLS. + +## Как читать новый журнал + +- http listener-ready address=[::]:5281/0.0.0.0:5281, transport=https и + websocket=enabled — настроенная доступность listener/route. +- http request-complete route=websocket status=403 — запрос отклонён политикой. + Сопоставляйте Origin именно страницы с конфигом. В журнале значение Origin + скрыто, чтобы не писать произвольные заголовки. +- status=101 — выполнен Upgrade; дальнейшие auth/bind события идут в c2s. +- 400 — отсутствующий/неверный xmpp subprotocol или некорректный Upgrade. +- 404 — endpoint не найден; phase=tls-handshake указывает на TLS этап. + +Журнал не доказывает browser connectivity через ваш firewall/proxy/DNS. +Попытка read-only проверки production endpoint в среде разработки не смогла +разрешить имя, поэтому живой wss://wthome.ru:5281 здесь не проверен. +Origin не расширяется до wildcard и TLS не отключается. diff --git a/docs/DNSSEC.md b/docs/DNSSEC.md new file mode 100644 index 0000000..a98b79a --- /dev/null +++ b/docs/DNSSEC.md @@ -0,0 +1,106 @@ +# DNSSEC и индикатор monocles + +Проверено 2026-10-20 UTC. Выполнены только чтение исходников клиентов и +публичные DNS-запросы; DNS-зона, регистратор и production не изменялись. +Приложения на Android не запускались. + +## Что именно показывает клиент + +В monocles `EditAccountActivity.java` выбирает `not_dnssec_verified`, если +нет соединения или `XmppConnection.resolverAuthenticated()` возвращает false. +Последний метод возвращает `currentResolverResult != null && +currentResolverResult.isAuthenticated()`. Проверка DANE отдельная и выполняется +только внутри положительной ветки DNSSEC; наличие TLS-сертификата само по себе +не делает этот индикатор положительным. + +В обычном пути SRV + IP (`Resolver.resolveIpsAsFuture`) флаг равен +`SRV.isAuthenticData() && addressResult.isAuthenticData()`. Для hostname-only +fallback достаточно authentic SRV; для пути без SRV monocles переносит authentic +флаг A/AAAA. MiniDNS сначала пытается проверить DNSSEC через DNS текущей сети, +при ошибках может перейти к обычному DNS. Поэтому даже после корректного +подписания зоны результат зависит от версии приложения и DNS сети телефона. +Ручной hostname (`fromHardCoded`) устанавливает authenticated=true без DNSSEC; +его нельзя использовать как доказательство подписи зоны или как исправление DNSSEC. + +Эти детали относятся к проверенному monocles master. В проверенном Conversations +master DNS-запросы выполняются через AndroidDNSClient, а `ResolverResult` создаётся +с null DNSSEC metadata; нельзя автоматически переносить поведение индикатора между +версиями и форками клиентов. + +## Публичная зона wthome.ru + +Google DNS JSON API с `do=1` дал следующие результаты: + +| Запрос | Результат | +| --- | --- | +| `wthome.ru DS` | NOERROR, без DS в Answer | +| `wthome.ru DNSKEY` | NOERROR, без DNSKEY в Answer | +| `_xmpp-client._tcp.wthome.ru SRV` | `5 0 5222 wthome.ru.` | +| `_xmpps-client._tcp.wthome.ru SRV` | NXDOMAIN | +| `wthome.ru A` | `88.204.18.107` | + +Во всех этих ответах `AD=false`; у ответов зоны wthome.ru нет RRSIG. +Cloudflare DNS JSON API независимо подтвердил отсутствие DS. Совокупность +результатов указывает на неподписанную зону без цепочки доверия из .ru, +а не на ошибку SASL или отсутствие XMPP stream feature. Это снимок DNS на дату +проверки, а не постоянная гарантия состояния зоны. + +## Что нужно настроить оператору + +1. Включить DNSSEC у авторитетного DNS-провайдера зоны, убедиться, что DNSKEY и + RRSIG опубликованы и действительны. Если текущий тариф/провайдер этого не + поддерживает, сначала перенести зону с сохранением всех записей. +2. Получить DS из реально работающего подписанного ключа и опубликовать его + через регистратора в родительской зоне .ru. Не придумывать DS вручную и не + публиковать DS до готовности подписанной зоны. +3. Проверить валидатором цепочку root → ru → wthome.ru и подписи SRV + A/AAAA + фактически используемого target. Для target в другой зоне проверить и её + цепочку. Дождаться обновления DNS-кэшей. +4. Переподключить клиент через DNS-сеть, передающую нужные DNSSEC-записи, проверить + его resolver log и индикатор. Отдельный DANE-индикатор потребует корректных + TLSA и их DNSSEC-проверки; для одного DNSSEC-индикатора TLSA не обязателен. + +Диагностика с установленным BIND tools: + +```sh +dig +dnssec wthome.ru DS +dig +dnssec wthome.ru DNSKEY +dig +dnssec _xmpp-client._tcp.wthome.ru SRV +dig +dnssec wthome.ru A +delv _xmpp-client._tcp.wthome.ru SRV +delv wthome.ru A +``` + +`+dnssec` только запрашивает записи DNSSEC и само по себе не проверяет подписи; +для проверки нужен валидирующий resolver или delv. Обычный DoH AD=false тоже +не различает все причины отсутствия проверки без анализа DS/DNSKEY/ответов. + +## Почему это не исправляется кодом c2s-сервера + +Клиент проверяет DNS до TCP/TLS/XMPP. Сервер не может передать ему DNSSEC-флаг +через XML, SASL2, Bind2 или TLS. Текущий `src/federation.rs` использует +`TokioAsyncResolver::tokio_from_system_conf()` только для исходящего s2s SRV; +Cargo.toml не включает dnssec feature Hickory. Проверка TLS при этом использует +JID-домен назначения, а не неподтверждённый SRV target. Добавление DNSSEC в s2s +resolver было бы отдельной задачей и не исправит DNSSEC-индикатор Android-клиента. + +## Источники и воспроизводимость + +Исходники прочитаны с mutable master; commit pin для этих загрузок не подтверждён. +Пути внутри `src/main/java/eu/siacs/conversations/`: + +- https://codeberg.org/monocles/monocles_chat — `ui/EditAccountActivity.java`, + `utils/Resolver.java`, `xmpp/XmppConnection.java`. +- https://codeberg.org/iNPUTmice/Conversations — `utils/Resolver.java`. +- https://dns.google/resolve?name=wthome.ru&type=DS&do=1 +- https://dns.google/resolve?name=wthome.ru&type=DNSKEY&do=1 +- https://dns.google/resolve?name=_xmpp-client._tcp.wthome.ru&type=SRV&do=1 +- https://cloudflare-dns.com/dns-query?name=wthome.ru&type=DS + (заголовок `Accept: application/dns-json`). + +SHA256 прочитанных Java-файлов: + +- `monocles-AccountActivity.java`: `96af8f891a8e6dbf647c34e07a19dd6782aeeba3fa6ec2de5d6b3332121ce56d` +- `monocles-Resolver.java`: `890856ea1221cf09aa951612e6fce2f27d2aea5c5e7c03b27af0a9a8d5977e79` +- `monocles-XmppConnection.java`: `37a2cd5c62242b47eaab5023c48807870428a053cbc1586408ba51e1b8dee6fd` +- `conv-Resolver.java`: `892bc1a346102798dc502bfa8c4c061a2f5fec7bffde1b4c78fba45d4383557b` diff --git a/docs/GAJIM-DISCOVERY.md b/docs/GAJIM-DISCOVERY.md new file mode 100644 index 0000000..de2ea82 --- /dev/null +++ b/docs/GAJIM-DISCOVERY.md @@ -0,0 +1,24 @@ +# Gajim discovery verification — 0.7.1 + +Upstream sources read 2026-10-20 (mutable master, not pinned): + +- https://raw.githubusercontent.com/gajim/gajim/master/src/gajim/common/modules/security_labels.py +- https://raw.githubusercontent.com/gajim/gajim/master/src/gajim/common/modules/register.py +- https://raw.githubusercontent.com/gajim/gajim/master/src/gajim/gtk/preference/server_info.py +- https://raw.githubusercontent.com/gajim/python-nbxmpp/master/nbxmpp/modules/security_labels.py +- https://xmpp.org/extensions/xep-0258.html +- https://xmpp.org/extensions/xep-0077.html + +Gajim sets SecLabels.supported from sec-label:0 in info.features and Register.supported +from jabber:iq:register. The server-info window uses those values, not only the +pre-auth registration stream feature. nbxmpp catalog queries use catalog:2 with +@to; server replies preserve the target attribute. + +Server now advertises sec-label:0/catalog:2/catalog:0 on domain and current account +security features on own bare JID. Registration discovery is gated by register, +whereas creating a new account remains gated by allow_registration. + +Tests exercise real TLS account management and catalogs, enabled/disabled modules, +registration gate, password login after change and rejection after cancellation. +The sample policy schema is a catalog metadata adapter only. Names classify labels; +they confer no access or enforcement. Unknown policy fields fail explicitly. diff --git a/docs/HTTP-ALTCONNECT.md b/docs/HTTP-ALTCONNECT.md new file mode 100644 index 0000000..639ec61 --- /dev/null +++ b/docs/HTTP-ALTCONNECT.md @@ -0,0 +1,97 @@ +# http_altconnect (0.7.7) + +Встроенный модуль публикует альтернативные XMPP connection methods: + +- GET `/.well-known/host-meta`: XRD, Content-Type application/xrd+xml. +- GET `/.well-known/host-meta.json`: JRD, Content-Type application/json. + +Оба ответа содержат Access-Control-Allow-Origin: *. HEAD возвращает те же +заголовки без тела; OPTIONS разрешён; остальные методы дают405. Неизвестные +пути и неразрешённый HTTP Host не обслуживаются. Query не изменяет формат +и не включает JSONP. В логах запрос классифицируется как altconnect. + +Реализация сверена с supplied plugins/mod_http_altconnect.lua cafd74b9c5ea. +Источник протокола: https://xmpp.org/extensions/xep-0156.html. + +## Включение + +Добавьте `http_altconnect` в существующий modules_enabled, сохранив остальные +модули. В Lua, например: + +```lua +modules_enabled = { + -- остальные существующие модули + "websocket"; + "bosh"; + "http_altconnect"; +} +advertise_bosh = true; +advertise_websocket = true; +``` + +Это фрагмент: не заменяйте им весь рабочий список modules_enabled. +Оба advertise-параметра по умолчанию true; false скрывает соответствующий Link. +Выключенный/неподключённый транспорт не рекламируется. Если нет ни одного +транспорта, XRD пустой, JSON содержит links: []. Отключение http_altconnect +убирает discovery endpoints, сохраняя работу WS/BOSH. + +Адреса учитывают http_host, http_paths.bosh/websocket и http_external_url. +При отсутствии внешнего URL выбирается первый настроенный HTTPS listener, +затем первый HTTP listener; значения не зависят от Host/Forwarded в запросе. +Пустой interfaces список не считается действующим listener. + +Пример для HTTPS proxy с удаляемым публичным префиксом /xmpp/: + +```lua +http_external_url = "https://wthome.ru/xmpp/"; +http_paths = { + websocket = "/ws"; + bosh = "/bind"; +} +``` + +Опубликованные href: https://wthome.ru/xmpp/bind и wss://wthome.ru/xmpp/ws. +http_external_url меняет публичные адреса, внутренние пути остаются /bind и /ws. +`http_paths.altconnect` меняет внутреннюю базу discovery, например `/discover`: +в этом случае endpoint — `/discover/host-meta[.json]`. `$host` поддерживается. +Путь transport, конфликтующий с активным host-meta endpoint, отвергается при +check-config/startup вместо неоднозначной маршрутизации. + +## Публичный discovery + +Клиент запрашивает well-known на HTTPS origin XMPP-домена, например +https://wthome.ru/.well-known/host-meta. HTTPS listener5281 сам по себе не +создаёт публикацию на443: используйте существующий reverse proxy или port mapping. +Пример дополнительных location внутри TLS server Nginx, если backendHTTP5280: + +```nginx +location = /.well-known/host-meta { + proxy_pass http://127.0.0.1:5280; + proxy_set_header Host wthome.ru; +} +location = /.well-known/host-meta.json { + proxy_pass http://127.0.0.1:5280; + proxy_set_header Host wthome.ru; +} +``` + +Эти location публикуют discovery без transport-префикса /xmpp/; соответствующие +WS/BOSH location по-прежнему настраиваются согласно HTTP-PROXY.md. +При custom altconnect mount proxy должен переписать well-known на него. + +XEP-0156 требует HTTPS загрузку metadata и только https/wss href. Для native +совместимости supplied module HTTP/WS fallback также сохранён: если настроен +только HTTP, ссылки будут http/ws. Для стандартного защищённого discovery +настройте HTTPS listener или HTTPS http_external_url. Автоматическое изменение +production DNS, proxy или TLS-сертификатов не выполняется. + +Проверка с действительным CA (для self-signed добавьте --cacert cert.pem): + +```bash +curl -i https://wthome.ru/.well-known/host-meta +curl -i https://wthome.ru/.well-known/host-meta.json +``` + +Модуль включается на единственном runtime VirtualHost. Host Meta2/XEP-0487, +DNS TXT/SRV changes, redirects на чужие Host и автоматический запуск браузера +не являются частью этого плагина. Результаты локальных проверок — VALIDATION.md. diff --git a/docs/HTTP-PROXY.md b/docs/HTTP-PROXY.md new file mode 100644 index 0000000..65cdd20 --- /dev/null +++ b/docs/HTTP-PROXY.md @@ -0,0 +1,92 @@ +# HTTP-пути и reverse proxy + +С0.7.5 WS/BOSH используют настройки `http_paths.websocket` и `http_paths.bosh`. +`http_paths.file_share` задаёт внутренний путь upload-компонента. Настройки +VirtualHost/Component имеют приоритет над глобальными; `$host` заменяется именем +XMPP-хоста. Дефолты: `/xmpp-websocket`, `/http-bind`, `/file_share`. +HTTP-пути задаются без query/fragment; конечный `/` допускается. Поддержаны +литеральные RFC3986 path-символы и percent encoding, например `/v1:ws` и +`/bind%20endpoint`. Закодированные slash/backslash, control и dot segments +отвергаются, чтобы избежать неоднозначного переписывания прокси. + +`http_host` задаёт HTTP Host, который proxy должен отправлять backend. +`http_default_host` глобально выбирает fallback для неизвестного HTTP Host. +Runtime по-прежнему обслуживает один VirtualHost и его компоненты. + +`http_external_url` задаёт публичный HTTP(S) origin и необязательный префикс. +Например, `https://chat.example.org/xmpp/` вместе с `http_paths.bosh: /bind` +соответствует публичному `https://chat.example.org/xmpp/bind` и внутреннему `/bind`. +Прокси должен удалить `/xmpp/` перед отправкой backend. Эта настройка сама по +себе не меняет внутренние маршруты и не включает автоматическое discovery. +Для WebSocket клиент использует эквивалентный `wss://` URL. + +## TLS на прокси + +Пример фрагмента YAML для существующего конфига (сохраните остальные параметры): + +```yaml +global: + http_ports: [5280] + http_interfaces: [127.0.0.1] + trusted_proxies: [127.0.0.1, '::1'] + http_legacy_x_forwarded: true + http_default_host: wthome.ru + http_external_url: https://wthome.ru/xmpp/ + http_paths: + websocket: /ws + bosh: /bind + http_cors_override: + websocket: + origins: [https://conversejs.wthome.ru] + bosh: + origins: [https://conversejs.wthome.ru] +``` + +Nginx: переменная map задаётся внутри `http`, location — внутри TLS server. + +```nginx +map $http_upgrade $xmpp_connection { + default upgrade; + '' close; +} + +location /xmpp/ { + proxy_pass http://127.0.0.1:5280/; + proxy_http_version 1.1; + proxy_set_header Host wthome.ru; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $xmpp_connection; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 120s; +} +``` + +Публичные адреса: `wss://wthome.ru/xmpp/ws` и +`https://wthome.ru/xmpp/bind`. Для прежнего прямого WSS5281 настройка +прокси не требуется. Origin — адрес страницы Converse, а не WSS endpoint. + +IP/CIDR в `trusted_proxies` разрешают доверять forwarded-данным только от +указанных socket peers. По умолчанию доверены loopback127.0.0.1 и::1. +Пустой список отключает доверие. Не задавайте `0.0.0.0/0` для публичного backend. +X-Forwarded-For разбирается справа налево до первого недоверенного адреса. +X-Forwarded-Proto=https разрешает HTTP WS/BOSH без consider_*_secure только +для доверенного прокси. Прямой HTTP без такого подтверждения получает403, +если `consider_websocket_secure`/`consider_bosh_secure` не включены явно. +Не включайте consider_*_secure на открытом внешнем HTTP listener. + +При `http_legacy_x_forwarded: false` используются RFC7239 `Forwarded` +с IP, bracketed IPv6/port, `proto=https` и цепочками доверенных прокси. +В RFC-режиме защищённость TLS termination принимается от ближайшего доверенного +прокси; каждый следующий разобранный hop также должен иметь `proto=https`. Это +намеренно отличается от консервативного `secure AND proto` supplied native +revision, не позволявшего так отметить HTTP backend защищённым. +`X-Forwarded-Host`/`Forwarded host` не заменяют HTTP Host: настройте Host на прокси. + +Поддержаны HTTP reverse proxies, сохраняющие WebSocket Upgrade и BOSH body. +HAProxy PROXYv1/v2, произвольные Lua HTTP-приложения и горячая перенастройка +маршрутов не реализованы. Nginx-конфигурация дана для оператора; настоящий +Nginx и production-сертификат в локальном прогоне не запускались. + +Основание семантики: https://prosody.im/doc/http и supplied mod_http.lua. diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 0000000..e6231db --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,144 @@ +# Журнал сервера и диагностика авторизации — 0.7.4 + +После сборки/перезапуска строка `starting`/`ready` должна показывать 0.7.4. +Это позволяет проверить, что запущен новый binary/container image. +Без настройки журнал пишет info/warn/error в stderr (виден в Docker logs). + +Для диагностики одной попытки входа добавьте в Lua-конфиг: + +```lua +log = { debug = "*console" } +log_format = "json" +``` + +В JSON-конфиге это поля `global.log = {"debug":"*console"}` и +`global.log_format = "json"`. Обычный текст: `log_format = "plain"`. +`debug = true` включает debug по умолчанию, если `log` не задан. +Настройки наследуются из global, локальный `log` VirtualHost имеет приоритет. + +Файлы в стиле Prosody: + +```lua +log = { + info = "/var/log/prosody/rust-xmpp.log"; + error = "/var/log/prosody/rust-xmpp.err"; + debug = "*console"; +} +log_format = "plain" +``` + +Каталоги должны существовать и быть доступны UID процесса. Файлы открываются +append с правами 0600. Level-map включает указанный уровень и старшие уровни. +Explicit sinks поддерживают диапазон и точный набор уровней: + +```lua +log = { + { to = "file"; filename = "/var/log/prosody/debug.log"; + levels = { min = "debug"; max = "warn" } }; + { to = "console"; levels = { "error" } }; +} +``` + +`levels = "warn"` в explicit sink — только warn, как у native loggingmanager. +`log = "*console"` и `log = { "*console" }` включают debug и старше. +Дополнительное сокращение runtime `log = "debug"` также пишет debug в консоль. +Вся строка JSON/plain записывается под общим mutex, ID не смешиваются. + +Файлы ротируются внешним logrotate: после rename отправьте процессу SIGHUP. +Журнал переоткроет destinations и запишет `logs-reopened`. При ошибке +переоткрытия старые файловые дескрипторы сохраняются. SIGHUP здесь не означает +горячую перезагрузку конфигурации или Lua-модулей. Автоматической ротации и +retention нет. Native syslog sink, mixed Lua tables, timestamp templates и +произвольные plugin sinks пока не реализованы. + +## Как собрать одну попытку + +В JSON есть `timestamp`, `level`, `module`, `connection`, `event`, `fields`. +`connection` — случайный ID одного transport, не SM resume token. Поля в +`fields` — массив `{key,value}`, который можно преобразовать через jq +`from_entries`. Проследите один ID от `connection-open` до `connection-closed`: + +1. `connection-open`: host, transport, peer. +2. `tls-established`: фактическая версия TLS и cipher. +3. `sasl-features`: объявленные механизмы, CB-типы, account_hash и SSDP. +4. `sasl-attempt`: номер попытки, механизм, SASL1/SASL2. +5. `account-lookup`: нормализованный user, hash и состояние записи. +6. `sasl-challenge`, затем `auth-success` либо `sasl-rejected`. +7. `resource-bound`/`session-resumed`, затем `connection-closed`. + +При закрытии есть phase, reason, duration_ms и io_kind. Если клиент отключился +после challenge, сервер видит только `phase=sasl-client-final` и disconnect; +его внутреннюю причину (например, локальное отклонение SSDP) сервер узнать +не может. Для этого потребуется клиентский журнал. Для HTTP adapter peer +помечается not-forwarded: внутренний duplex не получает адрес HTTP-запроса. + +| Событие/причина | Что проверять | +|---|---| +| account-lookup: missing-or-disabled | Правильный host, data_path/volume, наличие и enabled-состояние аккаунта | +| account-record-invalid | Lua account record повреждён или не поддержан | +| account-hash-or-record-invalid | stored/server key length и настроенный SHA-1/SHA-256 должны соответствовать | +| invalid-client-proof | Proof не проверилась: пароль, устаревшие credentials либо несовместимый расчёт клиента; это не доказательство конкретно неверного пароля | +| channel-binding-mismatch | Выбранный CB не соответствует данному TLS-сеансу/сертификату | +| channel-binding-downgrade-detected | Клиент отправил y при доступном серверном CB | +| mechanism-not-offered | Клиент выбрал механизм, которого нет в sasl-features | +| invalid-authzid | Authorization identity не совпадает с нормализованным владельцем | +| client-aborted-after-challenge | Клиент явно отменил обмен; wire-ответ aborted | +| inline-request-malformed | Ошибка Bind2/resume, поле detail уточняет класс | +| bind-rejected/resource-conflict | Авторизация прошла, проблема уже на этапе ресурса | + +Дополнительно пишутся listener-ready, resource-replaced, session-disconnected +(removed/hibernating и unacked count), session-expired, maintenance-failed, +базовые открытия/закрытия входящих S2S и shutdown-requested. Это начальное +покрытие операций, не полная замена всех журналов модулей Prosody. + +В журнал не передаются пароли, соль/ключи, SASL base64, nonce/proof, resume/push +токены и XML/тела сообщений. Имена аккаунтов и peer-адреса присутствуют для +диагностики, как в Prosody. Дополнительный фильтр редактирует sensitive field +keys; escaping исключает вставку новых строк/control characters в запись. + +Нужен фрагмент одной неуспешной попытки Conversations/monocles, включая version +и перечисленные события. До получения этого фрагмента причина конкретного +оставшегося отказа на вашей установке не установлена. + +## Изменения 0.7.1 + +Одинаковые console destinations (включая aliases) и одинаковые строки путей +файлов объединяются с union уровней; overlapping rules не создают дубликаты. +Разные destinations получают независимые копии записи согласно своим уровням. + +`sasl-attempt` теперь записывается до inline validation. `sasl-inline-options` +содержит только requested/available flags без payload. Необъявленный cached +Bind2/SM логируется как `inline-request-ignored` с причиной bind2-disabled или +sm-disabled и fallback=post-auth-stream-features. Это не ошибка пароля. +Malformed enabled inline extensions сохраняют rejection и более точный detail. + +## PEP/PubSub в 0.7.2 + +INFO pubsub-iq-result содержит классифицированные action/node_kind/status/condition, +precondition_not_met и счётчик deliveries. Для disco — pep_identity, +persistent_items, publish_options. Идентификаторы устройства/элемента/запроса, +полные имена узлов и payload не печатаются. WARN iq-dispatch-failed содержит +безопасный io_kind. Значение deliveries не доказывает получение клиентом. +Интерпретация ошибок и проверка после обновления — PEP-DIAGNOSTICS.md. + +## Содержимое результата PEP в 0.7.3 + +`target_scope` классифицирует цель запроса без её адреса. У successful items +result `items_count` — число непосредственных item, `first_content_kind` — +известный класс первого payload или none/other/nested-item, `first_device_count` +— количество непосредственных device в распознанном списке первого item. +Никаких значений device ID или bundle bytes. Это структура XML, не проверка +валидности ключей, доверия или числовых ID. Для других операций этих счётчиков нет. +`implicit` означает отсутствие to; сам по себе он не утверждает выбор аккаунта. + +## HTTP и PEP0.7.4 + +HTTP diagnostics: listener-ready, connection-open, tls-established, +request-complete route/status, connection-closed phase/reason. Routes ограничены +websocket/bosh/other; raw URLs, queries, Origin/headers/bodies не пишутся. +Для подключения Converse: CONVERSE-WSS.md. + +PEP IQ: device_protocol legacy/omemo2/other, valid_device_count и +invalid_device_count. notification-generated: content_present и такие же +счётчики локально созданного сообщения ДО routing, не подтверждение доставки. +ID/JID/ключевые данные не добавляются. Подробности — PEP-DIAGNOSTICS.md. diff --git a/docs/MUC-RECOVERY.md b/docs/MUC-RECOVERY.md new file mode 100644 index 0000000..b3c8fb8 --- /dev/null +++ b/docs/MUC-RECOVERY.md @@ -0,0 +1,68 @@ +# MUC: расширения и восстановление0.7.6 + +Сопоставление выполнено с предоставленной Prosody cafd74b9c5ea: plugins/muc +и mod_muc_mam.lua. Поддержка относится к встроенным расширениям этой ревизии, +а не к произвольным community Lua plugins. + +Добавлены 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-архив. + +## Как восстанавливается членство + +При SIGTERM/Ctrl-C сервер сначала сохраняет метаданные комнаты и native +`state/.dat`, затем завершает соединения. Сохраняются nick, полный JID, +роль, show/status/priority и последнее сообщение истории. Это относится также +к временным комнатам с участниками. Транспортное отключение во время shutdown +не считается добровольным выходом из комнаты и не удаляет checkpoint. + +После запуска сохранённые участники ожидают восстановления до300 секунд. +Они не становятся активными адресатами маршрутизации автоматически. Когда +тот же полный JID проходит SASL, bind и отправляет общую available presence, +сервер повторно проверяет комнату и восстанавливает членство, nick, роль и +presence. Одного совпадения bare JID или resource недостаточно. +Клиент может по-прежнему явно войти обычной MUC presence. + +Отпечаток конфигурации должен совпасть с сохранённым: изменение пароля или +прав комнаты отменяет автоматическое восстановление; требуется обычный вход +с актуальными параметрами. В presence state не записываются MUC-пароли и +произвольные расширения. Пароль комнаты остаётся в исходной native config, +как и до этого. Тот же checkpoint не продлевает grace при повторных перезапусках. Успешно использованная запись восстановления +погашается на диске до отправки результата клиенту, чтобы авария не вернула +старую роль после выхода или снятия модератора. +Неактивированные временные комнаты очищаются после истечения срока. + +Native формат читается/пишется и проверяется настоящей Prosody в обе стороны. +Для автоматического восстановления используются checkpoint с отпечатком и +временем Rust; старый native state без этих полей сам по себе не является +разрешением на автоматический вход. Обычное повторное присоединение доступно. + +Это восстановление XMPP-членства в комнате: старые TCP/TLS-сокеты после +остановки процесса не существуют. SM resume между процессами не реализован. +Аварийный SIGKILL/отказ питания не создаёт новый checkpoint: гарантируется +проверенный штатный shutdown, а не полный crash recovery. + +История ограничена `max_history_messages`: по умолчанию100, допустимый внутренний +предел4096. Состояние комнаты ограничено4096 ресурсами. Необязательные presence +поля слишком большого размера и последнее сообщение больше16KiB не включаются +в checkpoint; это не мешает сохранить членство. Текущие сообщения доставляются +по обычным транспортным лимитам. + +## Проверка и границы + +Автоматические тесты используют отдельные временные данные. Сетевой сценарий +останавливает процесс с живыми клиентскими socket, запускает его повторно, +проверяет persistent и temporary комнаты, запрет чужих identity/resource, +восстановленные роли/presence и доставку сообщений. Native oracle проверяет +перенос state с несколькими ресурсами туда и обратно. + +Полная production-миграция, непрерывные федеративные reconnect и все community +расширения не проверены. Конкретные результаты — VALIDATION.md; общие пределы +сервера — COMPATIBILITY.md. Реальные клиенты должны повторно аутентифицироваться. diff --git a/docs/PEP-DIAGNOSTICS.md b/docs/PEP-DIAGNOSTICS.md new file mode 100644 index 0000000..5ea6751 --- /dev/null +++ b/docs/PEP-DIAGNOSTICS.md @@ -0,0 +1,131 @@ +# PEP «повреждено»: проверка 0.7.4 + +В предоставленном журнале0.7.1 monocles и Conversations успешно авторизовались. +Содержимого PubSub запросов/ответов в INFO-журнале не было. Поэтому изменения +0.7.2 устраняют воспроизведённые ошибки совместимости и добавляют нужные данные +для следующей диагностики, но не доказывают причину именно вашего индикатора. + +## Предикаты клиента + +В проверенном monocles AccountActivity ветка PEP broken использует +AxolotlService.isPepBroken(), а не только bare-account disco. Флаг устанавливается +при ответе с ошибкой, отличной от item-not-found, на чтение own bundle, при +ошибках публикации device list, кроме ожидаемого precondition-not-met, и при +повторяющихся пустых device list. Отсутствие узла при первом запуске нормально: +сервер должен ответить item-not-found, затем принять публикацию. Источник master +может отличаться от версии установленного приложения. + +## После обновления + +1. Пересобрать и перезапустить сервер. События starting/ready должны показывать + 0.7.4. Выполнить переподключение клиента; если статус сохраняется только в + текущем процессе приложения, полностью закрыть и снова открыть приложение. + Удалять аккаунт, локальные ключи, device list или серверное хранилище не нужно. +2. Включить существующий INFO sink (JSON или plain), воспроизвести открытие + настроек аккаунта/обновление аватара/публикацию OMEMO. +3. Проверять pubsub-iq-result для того же connection. node_kind различает + omemo-bundle, omemo-devices, avatar-data, avatar-metadata, bookmarks, custom; + неизвестное имя не записывается. status=result означает успешный ответ на IQ. +4. items/item-not-found до первой публикации ожидаем. conflict + + precondition_not_met=true допускает owner configure GET/SET и повтор publish. + forbidden, feature-not-implemented или internal-server-error на собственном + узле требует разбора; iq-dispatch-failed io_kind=PermissionDenied указывает + на ошибку доступа к данным. io_kind=none означает не-I/O ошибку; её текст + намеренно не печатается, чтобы не раскрыть содержимое данных. + +Событие формируется для IQ, прошедших локальный dispatch. Ранние ветки +блокировки/удалённой маршрутизации могут его не формировать; отсутствие события +само по себе не доказывает, что клиент не отправил запрос. + +Disco flags подтверждают рекламу features, но не доступность каждого узла. +deliveries — количество созданных маршрутизаций, не доставки/ACK. Чтение и +публикация PEP не требуют согласия на раскрытие XML или OMEMO данных в журнале: +диагностические поля ограничены известными значениями протокола. + +Автоматические проверки: tests/pep_client_interop.rs, tests/pep_client_network.py, +tests/pep_discovery.rs, tests/pubsub_persistence.rs, tests/iq_errors.rs. Network +fixtures используют синтетические ключевые bytes, не настоящие приватные ключи. +Полная миграция реальных данных и поведение Android приложения пока не проверены. + +Источники: https://codeberg.org/monocles/monocles_chat — +src/main/java/eu/siacs/conversations/ui/AccountActivity.java, +crypto/axolotl/AxolotlService.java, generator/IqGenerator.java, +xmpp/pep/PublishOptions.java; https://xmpp.org/extensions/xep-0060.html; +https://xmpp.org/rfcs/rfc6120.html. Master mutable; дата проверки 2026-10-20. + +Проверка мигрированных форм выявила и исправила несовместимость0.7.1: +notification_type, itemreply и native payload_type выдавались в форме, но +не принимались parser при повторной отправке. Теперь payload_type соответствует +wire pubsub#type; notification_type normal/headline и itemreply +none/owner/publisher валидируются. Как в native PEP, уведомления PEP сохраняют +headline; standalone PubSub учитывает normal/headline. itemreply=publisher +включает bare publisher в чтение и уведомления, none/owner не раскрывают его +(поведение предоставленного native handler). Поддержаны publish_model +open/subscribers с проверками блокировок/outcast, без изменения owner-only +прав на create/configure/retract. Неизвестные enum значения отвергаются. +Baseline regression на исходниках ZIP0.7.1 воспроизводит rejected whole-form +submission и ложные default preconditions; патч проходит эти сценарии. + +Старый Rust config.type нормализуется в native payload_type при обработке +узла; это предотвращает дублирование pubsub#type при смешанной миграции. + +## Успешный IQ, но старый первый item + +Исправлен порядок выдачи: новые элементы теперь первые, как в supplied native +Prosody. Старые0.7.2 ответы на multi-item node могли содержать пустой old item +перед populated current item. IqParser.getItem monocles выбирает первый item. +Это отдельный дефект от malformed/error ответов; type=result недостаточно +для доказательства корректного состояния, видимого клиенту. + +С0.7.3 для self-account/implicit items проверяйте items_count, +first_content_kind=omemo-devices и first_device_count. Если счётчик устройств0, +первый список структурно пуст; если items_count>1, узел хранит несколько записей. +Сам по себе пустой список допустим и не означает поломку OMEMO. Другие аккаунты +могут законно отвечать forbidden из-за ACL. Диагностические поля не раскрывают +ID, XML или ключевые данные. Для подтверждения прежней production причины +нужна структура реальных данных; в текущем журнале0.7.2 этих счётчиков нет. + +Regression: tests/pep_item_order.rs и tests/pep_order_network.py. Дополнительный +ignored oracle запускает настоящий mod_pep и подтверждает порядок current,old. +Источник parser: monocles IqParser.java getItem180-190/deviceIds193-228 +(проверенный mutable master); native source: plugins/mod_pubsub/pubsub.lib.lua +archive_itemstore:items reverse=true, util/pubsub.lua service:get_items. + +## Что установлено по production0.7.3 + +Own-account PEP discovery успешен; own devices replies имеют1 item и +непустые XML списки1/2/6. Исправление порядка нескольких items не объясняет +этот случай. Элементы device ещё не означают успешно разобранные Android ID. +Теперь valid_device_count считает уникальные signed32 ID по inspected Java +Integer.valueOf (без whitespace trimming), invalid_device_count — пропущенные/ +некорректные/выходящие за диапазон. device_protocol различает legacy/omemo2/other. +Это client-parser diagnostics, не полная валидность XEP0384 или криптографии. + +notification-generated показывает структуру локально сгенерированного события, +items_count, content_present, device_protocol и счётчики. Событие пишется ДО routing, +без transport connection ID и без адресата: оно не доказывает получение клиентом. +Не все remote delivery ветки проходят через этот logger. Payload suppression +include_payload=false может давать пустой item event при непустом IQ; тест покрывает +этот случай. Флаг false здесь не доказывает, что именно ваш узел настроен так. + +Для определения ветки клиентской ошибки используйте отфильтрованный клиентский +журнал, а не полный XML/ключи. При доступном adb: + +```sh +adb logcat -v brief | python3 scripts/filter-android-pep-log.py +``` + +Для сохранённого клиентского журнала: + +```sh +python3 scripts/filter-android-pep-log.py < client.log +``` + +Выход содержит только фиксированные причины: empty-device-list-retry-limit, +device-list-publish-error, bundle-read-error, bundle-publish-error, +empty-device-list-publish. Строки без этих известных фраз не выводятся. Версия +клиента может использовать другие сообщения; отсутствие вывода не доказывает +отсутствие проблемы. Пароли/JID/ID/произвольный текст/XML не копируются в выход. +Нужны одна такая причина и соответствующие notification-generated/own-account +IQ события для дальнейшего конкретного исправления. Текущий источник master +не гарантирует совпадение с установленной версией приложения. diff --git a/docs/SOURCE-SHA256.txt b/docs/SOURCE-SHA256.txt new file mode 100644 index 0000000..b1f2f8d --- /dev/null +++ b/docs/SOURCE-SHA256.txt @@ -0,0 +1,115 @@ +7136ea911bb6b6d352f703f80efeebde921028ea9dc39cd0d2cd55ea465f8c37 examples/config.json +8a65739eb5c24f10b9f2c509f0bd050fb1115706e6a41fdf804b9e0e44f583fd examples/config.toml +ca86274bcf4dc5daebbf69c9f003854afca92954719aff068f810e0a3d615dea examples/config.yaml +bbc2591581d382fef5fb918a480e6c338c7e3e3f2025df5509f51708534631e7 examples/converse-origin.fragment.yaml +66ea9e2b58f6a39b70911b9981cae5e99625483af7a41168e24923464de8ae56 examples/reverse-proxy.fragment.yaml +16e476e50c5802efcbb7d1aa68141c183c60f5ab17037d697dce720c209141f4 scripts/docker-entrypoint.sh +6fcb8a4977fee840d826951b0c30a3f2477c55d00860c79e99f635ca6e215e2b scripts/filter-android-pep-log.py +a83cfdb6d910af1add9a14df7b0bd43fb0e2a23342f1a1eec831d47c659bba6f scripts/integration_http.py +b1018e1b279c89f6cf14b4017e4f1b14e9165afef2f668cec6ac8bbe0c710d85 scripts/integration_xmpp.py +89e3fc6202656363712f2605efcfe1669a5ae77e5b08da790e4e481772a85e00 scripts/load_smoke.py +86089451e83f881b4496b3cd7157e25d2285e511696283075287177b4a46550a scripts/make-demo.sh +038052022c9b38f59fc0a6f91b0ef36911b0a023e467be359affd469d7015c04 scripts/native-federation.sh +6d8423de4f2db9198c8f6c19175f0fef4502f34002103f88cca7d8661100d073 scripts/prosody_compat/README.md +e08ac6136cce27cda2ddbd510da0f25750766ab0150a9f4b8f87c953276d16e9 scripts/prosody_compat/oracle.lua +2c7a2c946885205e9df38b97305073a8ac72f58ac8ff84fc6f9af320c35bbc96 scripts/prosody_compat/verify.sh +1b9ed063daf3e0320819b4fe0135c96dd27e49877044aa2f5093b8ab4fbda41d scripts/smoke.py +4d9702d73fab7c20f1e5b9798c0f53c6e1e77aab15074c1c8118df9ae5abd795 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 +834ebe4a1f2d46ab932c14c0a2c7be3375873a71ef56026d372c86e60ef041fc src/data_lock.rs +1c8aa5cb816a32a0f9f24589ac00beba27544a83c3d9aad771523a1d1cbcd4c8 src/extras.rs +dca8959effb123b94e1fbd42c561a79c721178c4b612a004db9f9fccb3b8950d src/federation.rs +46a59bc6689f0931670547e26b3dc4beae3aa210eee1aae832070925b377499c src/http_altconnect.rs +43ab37be4b6ad41e51c3918c8806d91650c1ee2b62977caade639920abf7aa44 src/http_config.rs +3fa73ae129fe552bd7de7afca0617a1c18bf4d3b36a39023bfec036bca17ffc5 src/http_proxy.rs +9366d1aaa7e13d06cf7547cfb1e10461091ba876413927e0ad49df41c3661e6f src/http_service.rs +c4fd5046b55510f87a1c87e2b293500f0a01b76f22dacfa6d145e857e857fc09 src/http_transport.rs +9f27d509f7e58467c92e1f7ac9175deeb7347c61a45664bb35d17ed4ad0d0d0e src/lib.rs +a21e1119ea0271b63935e92eb106ab6076f0035e6d3a06c21a352f152b9b37db src/logging.rs +58ce2a9daa36ac26b39c8f263a16f4fa8cc4e57c91bbbf5b24a6aad3f1a326e0 src/lua.rs +85616b8aa8e6fc7ac9aeaa75f256463ee9c2527a590832b243481720caabbe42 src/main.rs +71d5d185900b9cde190e10e2f28371cd5042d98b5900b4385d823f724234fced src/mam.rs +0f780d95523b41c4ae242bd4a1fb6b65ff57826dca6d23ac73a1a1459609ec0a src/muc.rs +d8263eb28f5cdff362512a6b13217aa8f7574f4a3d54b7bcb6ba7930f8c353d8 src/muc_history.rs +4c151ffcdc36e4a3e680f859d4dc7ba16b0162d4f1fe6fb470df4c5e21197e5a src/muc_recovery.rs +7d7114a22ef3bf02f9caaf578f0a50d4f75b4dcd4b335b4cb8425d9716c97e71 src/pep_diagnostics.rs +f09a6479b76c9c601a169b5f1f9cf1364e82df5ae27e4bab8d1b1428b5cad457 src/preauth.rs +a7333b31a57780c57f2d30620cdfaeb7b1215808865960bb7e5b595d3ad98f7c src/protocol.rs +761e29d6431fac5c2a4a24bcfca1f393949a5c55585fd6b0dd7a4f6e9f94a10d src/pubsub.rs +913cd033f60a68f6c7cf9b3adbaf76db1c5c9bc88a0599196d373b16b64c0a1e src/push.rs +bec477f2520af6a9d7ca54e43eb12158b53e3cf09057653e504dfdeab956885e src/sasl.rs +46b532fa98cfe8d5d6878ea7bdcc535132a678906caf5f84e7e8feb726048a25 src/server.rs +894f11389bfc3fddc3508396d106146261376999f7adcef5a049458f6b4028cf src/sessions.rs +71d7ce1036a091d8c27016ea7ce4cffedf904dfca9d1cc4891b7dc66a1e06a4e src/storage.rs +462b00edb2c1e02dd34524bafe4f82b5ef902028b44310ba31b085d31f4dc2aa src/tls_binding.rs +f9b023565711e0217238b53fb678c5fb14849cdcd2efcd0fce249018274fad69 src/wire.rs +b4eb7a230570dfde0fd0d301a6ace36559a5eae3e1402e42c76f014f61739c24 tests/_wire_client.py +45613ad6c53087a50c6c3a7969b30783578c334499bc6baceadb2d18b0b3ea4e tests/admin_socket.rs +0645fda01bede4362d998ef22aefdd2c02578dd2c2aae3878d0cff87b3679835 tests/android_sasl_network.py +366b315521bd4f4448c0bac17bf74ddb77a728ff41fc48e29d46377625e0330f tests/archive_mam.rs +cb6b7bbcd1bc7ee01a4ccf892d4b6074765685c56c4b8bb46e0381b834577b6a tests/auth.rs +b3c3470f0b9b64584abf98c26a4578b0b90089086d00f7b07187aa5f6db6a417 tests/cli.rs +cc58adbb0fc867802b09cf7bd0d25397a273e721fbd0d0cb0ce30c19e80c13a2 tests/config.rs +ba364e9ea561624b2c649cd0dda559e213c75e6d7080ae85f0c23f1f13d117e1 tests/csi.rs +3f5b4558cf380e5e9e849baf6935b267598f33d8614dfe960068471fba2f240a tests/csi_native.rs +d4d8d982c6f4ab96a809b598ea9ead7f64fa9d916547f1eb9d797aa71db64fdb tests/data_lock.rs +be18a83fb5884cfdbd2ddc542cb25fd07b70f139cbdce1ae67562568df42a2e2 tests/debug_legacy.rs +63753c2e3524e8e7826bc2d48e2bd78e36e1cc56fde9da19950d5c35e1c38f77 tests/federation_tls.rs +1aaf06ba872cb5dbe0545afffdd8efddd0eae7305de06f3b3b75c19289581865 tests/fixtures/prosody.cfg.lua +19d570b92075d1355257c3798e4c52e404cc356dfe5dfd0c8fb384e4e7150682 tests/gajim_discovery_network.py +4398bf0639c31f0df328f0b0f1a5df17f2e69d2df84514845bba882146de06bc tests/gajim_features.rs +a346e70af3657fbf56e0bb1c39c427f630de8c349e6858b8933c52f42a21ab46 tests/http_altconnect.rs +e124050689fd712fa5dce058add7d9f0b6b51ad163a11e835a650bed2513461e tests/http_altconnect_network.py +f1ea3eb78d5c716f4281cf17cb41b9fb22e08dbb4cf08ed9c8477c79b7d2382b tests/http_proxy_paths_network.py +7c599c83eb999cd8776f0ce6811db8f478366b840311f2e513823f70b5d7cd30 tests/http_transport.rs +278db32ba0dec882b0af8f1969da87670ff3d2d749d32320154dabef51cff191 tests/http_upload.rs +da2fc65e931ec74fbcdcd26d04f2cb073e2dc1e354139135166c79580973a477 tests/iq_errors.rs +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 +b24bd920f096576c2009ff1ccc0366eb8734c5f0b2feb39f04ad7087268a94b9 tests/muc_history.rs +1f3df5f3f39143adc2b2a419fac0cd74b2229337ffa86570261d1f6ac61137a1 tests/muc_native.rs +ac1caa62a3cd91e74bd7100a5e542c404e97aec61a34e9627900b458c2ee3806 tests/muc_recovery.rs +b5c2e179ee61287a8ec8b6bc593d8c960ca4f87cc71f2230e0c033fadd701ed3 tests/muc_restart_network.py +e89a1ec8bfd4608795f6457710622b12a1520628184bc3f033cc6062e01c25d4 tests/muc_restart_state.rs +13349e35ebaf65242cfb0bdbbccf0ea83acd80a1251312975141c2b74cccc1d7 tests/muc_state_native.rs +cd13d1481dba7725986c47b5e5eb606241c8bbc32ab05d53f536f4431d6f7a3d tests/pep_client_interop.rs +741cbcbf5552a8c3e5e7757a29de76ecd29da58f9d1184a35a075f1ed7aa5af9 tests/pep_client_network.py +dd68c50d7d7cfd7871f69331a2787e0336dcab73762a82c599cee2a39920ae95 tests/pep_diagnostics.rs +2dbcdf8887762f6977618c267bb9c880351f9927462b05321bbbfa651eb16ff5 tests/pep_discovery.rs +42b6c19d14e60b0c93193b4df473a0dd7cfcdb19250245ab94a7e1a237608395 tests/pep_item_order.rs +3e349f9219da20602a239a6c4ce71dc045faf9afb1c36599e2b41e90b2423508 tests/pep_notifications_network.py +83cea7164e5e6aadfc126b6b8349eb242bc113ba638385fa13c51ffda32aa57a tests/pep_order_network.py +6a078c3fbddff07131f3247d01fb1e13bc923888d93ea30175adda471cbece2f tests/preauth.rs +b4048d34620bd0726cfb79d4d1cca9635f27ca8e58c8ad61d619af63672a2e09 tests/preauth_native.rs +06a66caa73c179b9d3b239e6f4deac6fc876d8c8dfeafe302f043d85d9f4beb9 tests/preauth_network.py +fa1eae49717ca9fb8b118f40058dff0c068c8f28823ea1b0ac42fd0c3e935bda tests/pubsub_native.rs +359de6dd2d960e63296f1109617f139bed2eac40409e50f72ff745897ba943ac tests/pubsub_persistence.rs +6fb7475ca7359ad870a83d5f143a1994c7bd34e9b56e275f7496ba8da291ee17 tests/push.rs +b8dd71798f96931f7ab4489cdb1e258556f41411815ef38d3a03e80e4e8fbdfc tests/push_discovery.py +abf5e6a32ab542ccba5861eaaf7aed9a86b8306abfe2c329646d54e43176dc0c tests/reference/COPYING +1ecd0a5c122910cbd066922c95ff0c32e945a07e2c9b0a37a30e5f035b5ecea6 tests/reference/configmanager.lua +f407c6565fa73b6653e7e73204daa65ae1d87111e82c6b96154aea76497ad65d tests/reference_prosody.rs +5ab78e9319d6004d65d35cd4d04dbb99d4ea5ffd8da00473d874bb26000fce35 tests/resource_rebind.py +2e79de5ef8d3be81ac8f715bb96797b6e8e39cdcd61c13d8d873a96b53cb52e4 tests/resource_sessions.rs +cefd1001c9619456402223fa89e449f65599c4397f523860e965ca192a4fd915 tests/sasl2_bind.py +e14450fee2a25e5917682d9b71395adf76ef82c2a627060a4bb33aa839b6a280 tests/sasl2_cached_inline.py +198cfa72ece0124e5ac992e30dbabeb4b60df87bada33999ba66d62220082bc6 tests/sasl2_resume.py +ddd9d5b46dc943138a334c64477fb11970b3d45e4cfbeaafc7348754f1e5e29e tests/sasl_accounts.rs +a7d1519f4f44cc7bfad3d20bcfdf455e92abdd084feed493ffc9e0267a387740 tests/sasl_network.py +4748fa227743686f78a0fa0364735d2dda5995f8d70a79ad03c1aa1decb42978 tests/sasl_retry_network.py +d8882b8aabd6ff603c81fbbc259eefa68772afb2b3eec2e003ac29efd65d146b tests/sasl_web_network.py +72f2d60e47e5295b82d497289b78925a64a1d986c70c277015ae228d211d32bb tests/storage_accounts.rs +5c38afd5f77b5774552d12c461902ffaae21de11735560c6f0088e3dda8afa58 tests/storage_compat.rs +8724991fdf22471952cf1a0fac863abe837069c93d3c8d2fa751af4f6bbc3e38 tests/tls_binding.rs +5bea9b5546d3a6ae4fe78d17acbc3f6e6a1d5e1d13b32f2aa88d0e320892b7c5 tests/tls_plus_network.rs +e454204af26d5a356cb6aa838c3b46f4d13c552b5bd36d6b01bcd6f604821ff2 tests/wss_interop.py diff --git a/docs/THIRD_PARTY.md b/docs/THIRD_PARTY.md new file mode 100644 index 0000000..169dc6b --- /dev/null +++ b/docs/THIRD_PARTY.md @@ -0,0 +1,10 @@ +The original Prosody configuration loader in tests/reference/configmanager.lua +is from commit cafd74b9c5ead46eb58910359eb266e1732b0aa1, supplied by the user. +It is used for differential testing only. Its copyright notices are preserved; +its MIT/X11 license is in tests/reference/COPYING. +Rust runtime code is a new implementation. Dependency licenses remain with +those dependencies; Cargo.lock records exact versions used. + +API references consulted: +- https://docs.rs/mlua/0.12.2/mlua/struct.Lua.html +- https://docs.rs/serde_yaml_ng/0.10.0/serde_yaml_ng/ diff --git a/docs/VALIDATION-0.3.0.md b/docs/VALIDATION-0.3.0.md new file mode 100644 index 0000000..4928847 --- /dev/null +++ b/docs/VALIDATION-0.3.0.md @@ -0,0 +1,68 @@ +# Проверка 0.3.0 + +Проверено 2026-10-16, Linux x86_64, Rust 1.99.1 +(`58cf8f9c3`, 2026-10-13). Использована чистая папка сборки, отключена +инкрементальная компиляция. Для тестовой сборки отключены debug symbols; +release собран с обычной оптимизацией Cargo. Исходники после проверки +больше не менялись. + +| Проверка | Результат | +|---|---| +| `cargo test --locked` | 123 passed, 0 failed, 6 ignored | +| `cargo clippy --locked --all-targets -- -D warnings` | Успешно, без предупреждений | +| `cargo fmt --check` | Успешно | +| `cargo build --release --locked` | Успешно | +| Native storage/auth oracle | Успешно для SHA-1 и SHA-256, оба направления Rust ↔ настоящая Prosody | +| Native PEP oracle | Настоящие mod_pep/util.pubsub читают данные Rust, публикуют замену; Rust читает результат | +| Native MUC/MAM oracle | Настоящие mod_muc/mod_muc_mam восстанавливают комнату и affiliations, читают историю Rust, архивируют реальную native рассылку; Rust читает результат | +| Native CSI oracle | 15 решений о важности stanza совпадают с оригинальным mod_csi_simple | +| Большой архив, отдельный ignored test | Успешное потоковое чтение архива больше 128 MiB | +| `scripts/smoke.py` | TLS-сертификат, пароль и отказ неверному, независимый расчёт hash, bind, ping, сообщения, поддельный from, отказ входу без TLS | +| `scripts/integration_xmpp.py` | Roster/subscriptions, IQ routing, CSI/whitespace flush, MAM, блокировка, OMEMO PEP payload, MUC, offline, SM resume и корректное завершение по SIGTERM | +| `tests/sasl_network.py --spawn --hash sha1` | SASL1/SASL2, SCRAM, SSDP, подпись сервера, отказ неверному паролю | +| `tests/sasl_network.py --spawn --hash sha256` | То же для SHA-256; постоянный client_id, native push registration, max восстановления 259200 | +| `scripts/integration_http.py` | Реальные WSS и HTTPS BOSH, auth/bind, CSI, унаследованные namespace, roster/chat, upload IQ/PUT/GET и запрет повторного PUT | +| Federation TLS tests | Реальные взаимные TLS/CA/EXTERNAL, отказ подмене домена; 23 сообщения используют одно соединение, idle/EOF вызывают безопасное переподключение | +| `sh -n scripts/docker-entrypoint.sh` | Успешно | +| Docker image / Compose runtime | Не запускались: Docker в этой среде отсутствует | +| AF_UNIX admin socket test | Не запускался здесь: окружение запрещает Unix sockets. Протокол/команды отдельно проверены через in-memory duplex | + +Таким образом, дополнительно к 123 обычным прошли **4 native oracle tests и +1 большой архивный тест**. Из шести отключённых по умолчанию тестов только +AF_UNIX остаётся непроверенным на настоящем сокете этой сборки. +Проверки формата не ограничены собственной копией парсера: oracle загружает +переданную оригинальную Prosody `cafd74b9c5ea`, её настоящий modulemanager, +storagemanager и скомпилированные native-библиотеки. + +## Короткая нагрузочная проверка + +`python3 scripts/load_smoke.py --clients 500`: + +- 500 из 500 TLS-подключений авторизованы и получили разные ресурсы. +- 1500 ping: median 12.512 ms, p95 15.492 ms, max 19.709 ms. +- 500 из 500 направленных сообщений доставлены; ошибок нет. +- Создание подключений: 0.604 s при параллелизме 50. +- FD: 10 до нагрузки, 510 с подключениями, 10 после отключения. +- Измеренный peak RSS 24352 KiB; CPU процесса после нагрузки 1.3 s. + +Это один аккаунт с 500 ресурсами на loopback, TLS+PLAIN, короткий прогон. +Это не тест 500 независимых пользователей, не нагрузка полного набора модулей, +не долговременный benchmark и не доказательство отсутствия утечек памяти. +Память allocator после отключения оставалась выделенной. Сырые измерения +доступны в `validation-results.json`. + +## Что ещё не доказано + +Нет проверки настоящими Monal/Conversations/Gajim, внешним push-шлюзом, +разнообразными публичными XMPP-серверами и полной копией рабочих данных. +Не проверены upload 10 GiB, длительная нагрузка, Docker UID/GID/TLS и откат +вашей рабочей установки. Это необходимые проверки перед утверждением +«полная замена без изменений». Функциональные отличия перечислены в +`COMPATIBILITY.md`. + +Для повторения обычных проверок — команды из README. Для native oracle — +`scripts/prosody_compat/verify.sh`; для AF_UNIX на целевом Linux: + +```bash +cargo test --locked --test admin_socket -- --ignored +``` diff --git a/docs/VALIDATION-0.4.0.md b/docs/VALIDATION-0.4.0.md new file mode 100644 index 0000000..aabf480 --- /dev/null +++ b/docs/VALIDATION-0.4.0.md @@ -0,0 +1,63 @@ +# Проверка 0.4.0 + +Проверено 2026-10-20 в Linux x86_64, Rust 1.99.1. После всех изменений +артефакты самого проекта удалены через `cargo clean -p rust-xmpp`; +сборка и проверки выполнены заново. Старый отчёт сохранён отдельно в +VALIDATION-0.3.0.md. Машинные результаты находятся в validation-results.json. + +| Проверка | Результат | +|---|---| +| `cargo test --locked` | 148 passed, 0 failed, 8 ignored | +| `cargo clippy --locked --all-targets -- -D warnings` | Успешно, без предупреждений | +| `cargo fmt --check` | Успешно | +| `cargo build --release --locked` | Успешно | +| Native storage/auth oracle | Оригинальная Prosody читает и изменяет Rust данные и пароли SHA-1/SHA-256; Rust читает результат | +| Native PEP, MUC/MAM, CSI | Оригинальные модули работают с тестовыми данными Rust; правила CSI сверяются с оригиналом | +| Native push body lookup | Оригинальный util.stanza подтверждает исключение foreign namespace и вложенного extension body | +| Native federation | Настоящая Prosody cafd74b9c5ea обменивается сообщениями с Rust по двум TCP STARTTLS + SASL EXTERNAL соединениям; trust и identity включены | +| Большой архив | Потоковое чтение архива больше 128 MiB, отдельный ignored test | +| TCP integration | Контакты/подписки, IQ routing, CSI, MAM, блокировки, OMEMO PEP, MUC, offline, SM resume и SIGTERM | +| Offline/routing regression | Отсутствующий ресурс использует bare-JID fallback; no-permanent-store доставляется offline и исключается из MAM; bodyless receipt доставляется; offline headline не создаёт ошибку | +| SASL network SHA-1/SHA-256 | Независимый SCRAM, SASL1/SASL2, SSDP, подпись сервера, неверный пароль | +| SASL2 Bind2 | Канонический модуль, inline SM/CSI/Carbons/MAM, tag/resource privacy, malformed/auth failures, same UUIDv4 replacement и изоляция аккаунтов | +| WSS/HTTPS BOSH/upload | Авторизация, bind, CSI, namespaces, контакты, сообщения, upload PUT/GET/reuse | +| BOSH disconnected retry | Настоящий TCP reset до ответа; RID повторяется во время обработки и после неё, stanza выполняется однократно | +| Data directory lock | Межпроцессный отказ, тот же inode через symlink, освобождение guard и сохранность существующих файлов | +| Shell/Python syntax | Проверено для новых/изменённых сценариев | + +Из восьми ignored tests отдельно успешно выполнены семь: пять native +storage/module checks, federation с настоящей Prosody и большой архив. +Оставшийся AF_UNIX admin socket test попытались выполнить: среда запретила +bind с `Operation not permitted`. Это не проверка готовности Unix socket +на целевом Docker/хосте. In-memory admin protocol tests проходят обычно. + +## Нагрузочные сценарии + +Оба сценария используют отдельное тестовое хранилище, 100 настоящих тестовых +аккаунтов и 500 TLS-соединений с разными ресурсами. Авторизация — TLS + PLAIN. +Проверяются адреса отправителей, IDs и содержимое направленных сообщений. + +- Без MAM: 100 раундов ping и сообщений — 50 000 ping и 50 000 доставок. +- С MAM: 10 раундов — 5000 ping, 5000 доставок и ровно 10 000 записей + в архивах отправителей/получателей, распределённых между 100 файлами. + +Сырые задержки, CPU/RSS и количество дескрипторов записаны в JSON. +Это короткие loopback-прогоны на машине разработки; они не подтверждают +длительную работу полного набора модулей или отсутствие утечек памяти. +MUC/PEP/push не нагружаются этим сценарием на всех 500 соединениях. + +## Воспроизведение + +```bash +bash scripts/verify-local.sh +python3 scripts/load_smoke.py --clients 500 --accounts 100 --rounds 100 --message-rounds 100 +python3 scripts/load_smoke.py --clients 500 --accounts 100 --rounds 10 --message-rounds 10 --archive +``` + +Для native проверок нужны присланные исходники Prosody и Lua dependencies: +инструкции в scripts/prosody_compat/README.md. Docker в среде разработки +отсутствует: образ, UID/GID и Compose на целевом хосте не запускались. +Не проверены реальные Monal/Conversations/Gajim, APNs/FCM push-шлюз, +рабочие сертификаты/данные, загрузка 10 GiB, длительная нагрузка и откат +конкретной установки. Функциональные ограничения остаются в COMPATIBILITY.md. +Это экспериментальный сервер, а не подтверждённая полная замена Prosody. diff --git a/docs/VALIDATION-0.5.0.md b/docs/VALIDATION-0.5.0.md new file mode 100644 index 0000000..2bca381 --- /dev/null +++ b/docs/VALIDATION-0.5.0.md @@ -0,0 +1,39 @@ +# Проверка 0.5.0 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. Машинный отчёт: +validation-results.json. Предыдущие версии отчётов сохранены отдельно. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 162 passed, 0 failed, 9 ignored | +| Clippy all-targets с -D warnings | Успешно | +| cargo fmt --check | Успешно | +| Release build --locked | Успешно | +| PEP discovery | Bare-account identity и полный набор feature predicates из Monal; leaf node echo/ACL/errors | +| Push discovery | Четыре комбинации PEP × cloud_notify, правильная область identity/features, реальные enable/options/disable | +| Pre-auth TLS | Command discovery, invitation URI/expiry, RFC preapproval, взаимная подписка, single-use replay denial и account isolation | +| Unknown preauth | Forwarding и replay из pending после offline; graceful manual fallback | +| Native invitation oracle | Настоящий mod_invites читает/использует Rust token, Rust использует native token; scoped token native не принимает | +| Остальные native oracles | storage/auth SHA-1/SHA-256, PEP, MUC/MAM, CSI, push body lookup | +| Federation | Двусторонний STARTTLS + SASL EXTERNAL с настоящей Prosody cafd74b9c5ea | +| TCP, WSS, HTTPS BOSH, upload | Повторены на release 0.5.0; также SCRAM/SSDP, SASL2/Bind2, offline/SM и SIGTERM | +| Нагрузка с MAM | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 доставок и ровно 10 000 архивных записей | +| Большой архив | Отдельный ignored test архива больше 128 MiB | + +Восемь дополнительных ignored tests выполняются отдельно: шесть native +module/storage checks, native federation и большой архив. AF_UNIX admin +socket остаётся зависимым от разрешений среды; результат попытки сохранён +в validation-logs-0.5.0. In-memory admin protocol tests входят в обычный набор. + +Полный запуск: bash scripts/verify-local.sh. Новые сценарии: + +```bash +python3 tests/push_discovery.py target/release/rust-xmpp +python3 tests/preauth_network.py target/release/rust-xmpp +``` + +Native инструкции — scripts/prosody_compat/README.md. Проверено соответствие +тестируемым клиентским predicates по исходникам, но реальные Monal/Conversations +и APNs/FCM шлюзы не запускались. Docker, UID/GID/TLS целевого хоста и копия +рабочих данных не проверены. Короткий loopback load не является длительным +benchmark полного набора модулей. Полная замена Prosody пока не подтверждена. diff --git a/docs/VALIDATION-0.6.0.md b/docs/VALIDATION-0.6.0.md new file mode 100644 index 0000000..a6dabf8 --- /dev/null +++ b/docs/VALIDATION-0.6.0.md @@ -0,0 +1,31 @@ +# Проверка 0.6.0 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. Полный лог: +validation-logs-0.6.0/verify.txt. Машинный отчёт: validation-results.json. +Предыдущие отчёты сохранены отдельно. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 172 passed, 0 failed, 9 ignored | +| Clippy all-targets с -D warnings, fmt | Успешно | +| Release build --locked | Успешно | +| Resource conflict TLS | default/kick_old/kick_new/increment/random; live и hibernating SM; old cleanup isolation; старый token отзывается при замене | +| SM active takeover | Владелец/id/h проверяются до замены; partial replay и отдельное состояние старого транспорта | +| SASL2 inline resume | Full JID, replay после success, Bind2 suppression/fallback, неверная авторизация/owner/token/h и malformed requests | +| HTTP transport | 10 тестов BOSH/WS, включая async cleanup и graceful/abrupt disconnect | +| HTTPS BOSH + WSS | Настоящий engine, SM close и повторный вход с тем же resource под kick_new; chat, CSI, roster и upload | +| Остальные сетевые сценарии | TCP modules, offline/SM, SIGTERM, SCRAM SHA-1/SHA-256, SASL1/2/Bind2, PEP/push discovery и preauth | +| Native module oracles | 6 дополнительных ignored tests: auth/storage, PEP, MUC/MAM, CSI, push body lookup, invitation tokens | +| Native federation | Дополнительный ignored test: двусторонний STARTTLS + SASL EXTERNAL с Prosody cafd74b9c5ea | +| Нагрузка с MAM | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 архивных записей; failures=[] | + +Полный запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh. +Для native проверок нужны переменные PROSODY_REFERENCE_SOURCE и +PROSODY_REFERENCE_LUA; инструкции в scripts/prosody_compat/README.md. +Большой архив проверен в 0.5.0 и в этом прогоне не повторялся. +AF_UNIX admin требует разрешений среды, ранее bind был запрещён. + +Проверки проходят на изолированных временных данных. Настоящие Monal и +Conversations, APNs/FCM, рабочие данные, Docker/UID/TLS целевого хоста и +длительная эксплуатация не проверены. Полная замена Prosody не подтверждена; +оставшиеся функциональные различия перечислены в COMPATIBILITY.md. diff --git a/docs/VALIDATION-0.6.1.md b/docs/VALIDATION-0.6.1.md new file mode 100644 index 0000000..c578c2d --- /dev/null +++ b/docs/VALIDATION-0.6.1.md @@ -0,0 +1,32 @@ +# Проверка 0.6.1 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. +Полный лог: validation-logs-0.6.1/verify.txt. +Машинный отчёт: validation-results.json. Предыдущие отчёты сохранены отдельно. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 178 passed, 0 failed, 9 ignored | +| Clippy all-targets -D warnings; fmt; release build | Успешно | +| Android SASL | 32 сочетания TLS1.2/1.3 × SHA1/256 × SASL1/2 × SSDP on/off × valid/invalid binding | +| Настоящий TLS exporter/endpoint | 14 обменов: SASL1/2, exporter TLS1.3, endpoint TLS1.2/1.3, full SSDP lists, server signature, tampered binding и отключённый PLUS | +| WSS SCRAM | SASL1/2 с y,,, отсутствие CB/PLUS, правильный SSDP без пустого CB separator, server signature, bind и ping | +| Certificate digest | 4 теста с OpenSSL fixtures: weak-hash upgrade, SHA2, RSA-PSS, ECDSA, malformed/unsupported DER | +| Старый 0.6.0 | 4 Android TLS1.2 отказа воспроизведены; WSS SSDP mismatch воспроизведён отдельно | +| Остальная сеть | TCP modules, offline/SM, SIGTERM, SCRAM, Bind2, inline resume, PEP/push discovery, preauth, resource policies, HTTPS BOSH/WSS и upload — успешно | +| Native Prosody | 6 module/storage ignored tests + 1 двусторонний TLS EXTERNAL federation test — успешно | +| Нагрузка с MAM | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 архивных записей; failures=[] | + +Запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh. +Native проверки требуют PROSODY_REFERENCE_SOURCE и PROSODY_REFERENCE_LUA; +инструкции в scripts/prosody_compat/README.md. Большой архив проверен в 0.5.0 +и сейчас не повторялся; AF_UNIX admin требует разрешений целевого хоста. + +Android selector в Python повторяет исследованный путь Conversations, а не +всё приложение. TLS1.3 exporter проверяется настоящим Rust/rustls клиентом; +Python на TLS1.3 использует endpoint binding, поскольку stdlib не даёт exporter. +У current monocles есть дополнительный fallback; детали в ANDROID-SASL.md. + +Реальные Monal/Conversations/monocles на устройствах, APNs/FCM, production data, +Docker/UID/TLS целевого хоста и длительная эксплуатация не проверены. Остаточные +различия — COMPATIBILITY.md. Полная замена Prosody пока не подтверждена. diff --git a/docs/VALIDATION-0.7.0.md b/docs/VALIDATION-0.7.0.md new file mode 100644 index 0000000..3285006 --- /dev/null +++ b/docs/VALIDATION-0.7.0.md @@ -0,0 +1,28 @@ +# Проверка 0.7.0 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. +Полный лог: validation-logs-0.7.0/verify.txt. +Машинный отчёт: validation-results.json. Предыдущие отчёты сохранены отдельно. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 187 passed, 0 failed, 9 ignored | +| Clippy all-targets -D warnings; fmt; release build | Успешно | +| Logger unit | 8 тестов: levels/maps/ranges/sets, append0600, JSON/plain, redaction/escaping, threads, rotate и failed-reopen atomicity | +| Logging network | 8 сценариев с настоящим TLS и JSON-файлом: корреляция, успешный вход, wrong password/proof/binding, client disconnect, unknown/corrupt account, SIGHUP reopen и повторный вход | +| Секреты в журнале | В тестовых записях нет отправленных паролей, SASL payload/proof и body сообщения | +| SASL normalization/retry | 20 TLS1.2/1.3 × SASL1/2 случаев canonical PLAIN/authz/bind, invalid authz, aborted retry и active restart | +| Предыдущие сетевые сценарии | SCRAM/PLUS/SSDP/Android, WSS, TCP/SM/offline, SIGTERM, Bind2/inline resume, discovery/preauth, resource policies, HTTPS BOSH/WSS/upload — успешно | +| Native Prosody | 6 module/storage checks + 1 двусторонний STARTTLS/SASL EXTERNAL federation — успешно | +| Нагрузка | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 MAM записей, failures=[] | + +Запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh. Native инструкции: +scripts/prosody_compat/README.md; нужны PROSODY_REFERENCE_SOURCE и +PROSODY_REFERENCE_LUA. Большой архив проверен в0.5.0; AF_UNIX admin требует +разрешений целевого хоста, ранее среда запрещала bind. + +Тесты используют временные fixtures. На устройствах Conversations/monocles +оставшийся отказ не воспроизведён; для следующей диагностики нужен server log +одной попытки по docs/LOGGING.md. Реальные APNs/FCM, production data, Docker/UID/TLS +целевого хоста и длительная эксплуатация не проверены. Полная замена Prosody +не подтверждена; остаточные различия — docs/COMPATIBILITY.md. diff --git a/docs/VALIDATION-0.7.1.md b/docs/VALIDATION-0.7.1.md new file mode 100644 index 0000000..dc6a012 --- /dev/null +++ b/docs/VALIDATION-0.7.1.md @@ -0,0 +1,30 @@ +# Проверка 0.7.1 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. +Полный лог: validation-logs-0.7.1/verify.txt. Машинный отчёт: validation-results.json. +Предыдущие отчёты сохранены отдельно. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 196 passed, 0 failed, 9 ignored | +| Clippy all-targets -D warnings; fmt; release build | Успешно | +| Cached SASL2 inline | 6 конфигураций: SCRAM-SHA-1/PLAIN, good/wrong password, SSDP off/on, Bind2 on/off, SM disabled cached resume, legacy bind+ping | +| Auth baseline | Поведение0.7.0 воспроизводит отказ до SCRAM challenge;0.7.1 проходит fallback | +| Gajim TLS | 4 сочетания modules on/off × allow_registration on/off; domain/ownbare disco, два каталога @to, account/password GET/SET, создание и удаление с проверкой fresh logins | +| Label catalog | 6 unit tests: оба namespace, actual example policy schema, mapping, 15 malformed/unknown schema cases; no implicit ACL | +| Logger | Объединение одинаковых destinations, независимые файлы, console aliases; прежние escaping/redaction/rotation regressions проходят | +| Предыдущие сети | TLS/PLUS/SSDP/Android, logging+SIGHUP, normalization/retry, TCP/SM/offline, Bind2/resume, discovery/preauth, resource policies, HTTPS BOSH/WSS/upload — успешно | +| Native Prosody | 6 module/storage checks + 1 двусторонний STARTTLS/SASL EXTERNAL federation — успешно | +| Нагрузка | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 MAM записей; failures=[] | + +Запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh. Native инструкции: +scripts/prosody_compat/README.md. Нужны PROSODY_REFERENCE_SOURCE и +PROSODY_REFERENCE_LUA. Большой архив последний раз проверен в0.5.0; +AF_UNIX admin ранее был запрещён средой и требует проверки на целевом хосте. + +Проверки работают с временными данными. Исходники Conversations/monocles и +Gajim проверены, но сами приложения на устройствах не запускались. Runtime +metadata catalog adapter не реализует access-control enforcement. Production +data/deployment, APNs/FCM, Docker/UID/TLS целевого хоста и длительная +эксплуатация не проверены. Полная замена Prosody пока не подтверждена; +оставшиеся различия — COMPATIBILITY.md. diff --git a/docs/VALIDATION-0.7.2.md b/docs/VALIDATION-0.7.2.md new file mode 100644 index 0000000..e572508 --- /dev/null +++ b/docs/VALIDATION-0.7.2.md @@ -0,0 +1,36 @@ +# Проверка 0.7.2 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. +Полный лог: validation-logs-0.7.2/verify.txt; машинный отчёт: validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 206 passed, 0 failed, 9 ignored | +| fmt, Clippy all-targets -D warnings, release build | Успешно | +| Baseline0.7.1 | Из исходников предыдущего ZIP воспроизведены неверное IQ error addressing, ложный конфликт notification defaults, отказ native whole-form config submit | +| PEP unit | 7 сценариев: first own missing bundle, no-to publish/read/caps, implicit defaults, native config roundtrip + stale type canonicalization, notification style, publisher visibility, open/subscriber models + outcast/blocklist | +| PEP STARTTLS | Fresh + migrated metadata, legacy/OMEMO2 item selectors, no-to publish, native form GET/SET/retry, restart persistence, owner restriction, INFO JSON protocol-only diagnostics | +| Предыдущие сети | TLS/PLUS/SSDP/Android SASL1/2, retry/normalization, logging/rotation, Bind2/resume, disco/push, IBR/seclabels, preauth, resource rebinding, HTTP/HTTPS BOSH/WSS/upload — успешно | +| Native Prosody | 6 module/storage tests + 1 двусторонняя STARTTLS/SASL EXTERNAL федерация — успешно | +| Нагрузка | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 MAM записей; failures=[] | +| Публичный DNS | У wthome.ru не обнаружены DS/DNSKEY, AD=false. Google/Cloudflare read-only; production не менялся | + +Запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh; native checks требуют +PROSODY_REFERENCE_SOURCE и PROSODY_REFERENCE_LUA, см. scripts/prosody_compat/README.md. +Весь итоговый прогон сделан после изменений исходников; последующие изменения +касаются только отчётов и упаковки. + +Первый baseline roundtrip проверяет ошибку wire name payload_type; повторный +baseline убирает только ранние assert имени поля, чтобы независимо показать +feature-not-implemented на whole-form SET. Остальные baseline тесты не менялись. +Лог содержит намеренно упавшие regression tests на0.7.1, а не ошибки0.7.2. + +Предоставленный INFO0.7.1 журнал не содержит конкретных отказавших PubSub IQ; +причина именно пользовательского Android индикатора пока не доказана. Реальные +приложения не запускались; тестовые bundle bytes синтетические. Shared OMEMO2 +multi-item test явно запрашивает max_items=max: это не утверждение, что клиент, +запрашивающий только open access, автоматически хранит все устройства при +native default max_items=1. Native-like metadata fixtures не заменяют полную +миграцию production. Крупный архив последний раз проверен0.5.0; AF_UNIX admin +нуждается в разрешённом хосте. Полная замена Prosody, APNs/FCM, целевое окружение +Docker/UID/TLS и длительная эксплуатация пока не подтверждены. diff --git a/docs/VALIDATION-0.7.3.md b/docs/VALIDATION-0.7.3.md new file mode 100644 index 0000000..6325006 --- /dev/null +++ b/docs/VALIDATION-0.7.3.md @@ -0,0 +1,39 @@ +# Проверка 0.7.3 + +Проверено 2026-10-20, Linux x86_64, Rust 1.99.1. +Итоговый лог: validation-logs-0.7.3/verify.txt; validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 208 passed, 0 failed, 10 ignored | +| fmt, Clippy all-targets -D warnings, release build | Успешно | +| Baseline ZIP0.7.2 | Новый regression на исходниках предыдущего архива воспроизводит successful IQ old,current: первый список пуст, новый populated item второй | +| PEP порядок | All/recent2/recent1 читают current первым; старый item сохраняется | +| Android wire | Реальный STARTTLS, первый item как IqParser.getItem, no-to current replacement, повтор после restart; XML/ID не печатаются | +| Native порядок | Настоящий mod_pep читает те же файлы и подтверждает current,old | +| Диагностика | Unit + INFO JSON wire assertions: scope self-account, items2, first content omemo-devices, first devices2, отсутствие device IDs/raw node | +| Предыдущие проверки | SASL1/2, PLUS/SSDP, Bind2/resume, logging/rotation, Gajim discovery/register/labels, PEP/push/preauth, resource, CSI/MAM/MUC/offline, HTTP/HTTPS/BOSH/WSS/upload — успешно | +| Native Prosody | 7 module/storage/order tests +1 двусторонняя STARTTLS/SASL EXTERNAL федерация — успешно | +| Нагрузка | 100 аккаунтов, 500 TLS-соединений, 5000 ping, 5000 сообщений, 10000 MAM записей; failures=[] | +| DNSSEC | Повторный Google DS/DNSKEY read-only: NOERROR без Answer, AD=false. DNS не изменялся | + +Запуск: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh; native checks требуют +PROSODY_REFERENCE_SOURCE и PROSODY_REFERENCE_LUA, инструкции +scripts/prosody_compat/README.md. Итоговый прогон выполнен после всех изменений +исходников; позже менялись только отчёты и упаковка. + +В новом production журнале0.7.2: 9 auth-success, 126 PubSub IQ events, +41 OMEMO IQ и 0 OMEMO IQ errors. Это не доказывает корректное содержимое IQ. +Пользователь подтвердил полное завершение Android процессов. Мы не подменяем +диагностику рекомендацией просто переподключиться. Исправлен воспроизведённый +дефект, но реальные max_items/содержимое production узлов пока недоступны; +нельзя утверждать, что устранена единственная причина индикатора на устройстве. + +Baseline log содержит намеренно упавший тест на0.7.2, а не ошибку0.7.3. +Native источник подтверждает payload-only archive, поэтому неподтверждённая +гипотеза двойного item wrapper отвергнута и не меняет данные. Синтетические +bundle bytes проверяют wire/storage, не криптографию. Native default retention1 +и явные конфигурации не менялись. Реальные Android/Monal/Gajim, полная миграция, +production Docker/UID/TLS/APNs/FCM и длительная эксплуатация не проверены. +Большой архив последний раз проверен0.5.0; AF_UNIX admin требует разрешённого +хоста. Полная замена Prosody пока не подтверждена. diff --git a/docs/VALIDATION-0.7.4.md b/docs/VALIDATION-0.7.4.md new file mode 100644 index 0000000..4eb55b4 --- /dev/null +++ b/docs/VALIDATION-0.7.4.md @@ -0,0 +1,40 @@ +# Проверка0.7.4 + +Проверено2026-10-20, Linux x86_64, Rust1.99.1 (восстановлен exact toolchain). +Итоговый лог validation-logs-0.7.4/verify.txt; JSON validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 215 passed, 0 failed, 10 ignored | +| fmt, Clippy all-targets -D warnings, release | Успешно | +| Converse Origin | Исходная политика: wthome.ru101, actual conversejs.wthome.ru403; исправленная: actual101 | +| HTTPS binding | HTTPS-only startup, отдельные127.0.0.1 и::1 при global interfaces127.0.0.1; wildcard/override/fallback unit | +| WSS | Проверка CA/hostname, обязательный xmpp, browser headers, оба пути,403/400/404/101, preauth open/features/close, закрытие WebSocket | +| HTTP privacy | Фиксированные route/status/TLS outcomes, никакого request URL/query/Origin/header/body в журнале | +| PEP unit | 6 новых tests: invalid IDs, signed32/duplicates, legacy/OMEMO2, payloadless event, direct first-item, privacy | +| PEP wire | STARTTLS replay/live: valid1invalid1, suppression empty event + непустой IQ, diagnostic redaction | +| Предыдущие сети | SASL/PLUS/SSDP/retry, Bind2/resume, PEP order+config migration, Gajim IBR/labels, preauth/push, logging/rotation, resource, CSI/MAM/MUC/offline, BOSH/WSS/upload — успешно | +| Native | 7 storage/module/order tests +1 двусторонняя STARTTLS/SASL EXTERNAL федерация — успешно | +| Нагрузка | 100 аккаунтов,500 TLS-соединений,5000 ping,5000 сообщений,10000 MAM записей; failures=[] | +| Client log filter | Синтетический ввод с приватными маркерами выдаёт только фиксированные причины, без XML/ID/token | +| DNSSEC | Повторные DS/DNSKEY: NOERROR без Answer, AD=false; production DNS не менялся | + +Итоговый прогон выполнен после изменений исходников; последующие изменения +касаются отчётов/упаковки. Scripts/*.sh сохранены executable в ZIP. +Native reference заново извлечён из supplied archive и собран с настоящими +Lua5.4.6/LuaSec/LuaSocket/ICU/OpenSSL модулями в private prefix. Запуск: +XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh с PROSODY_REFERENCE_SOURCE, +PROSODY_REFERENCE_LUA и native prefix env из scripts/prosody_compat/README.md. + +Серверный журнал0.7.3:12 own OMEMO IQ,0 own errors, XML device counts1/2/6. +Этот факт не доказывает валидность IDs/содержимое уведомлений и не устанавливает +причину Android pepBroken. Клиентского журнала у пользователя нет. Текущий патч +НЕ объявляет PEP проблему устранённой; добавлена необходимая безопасная диагностика. +Converse origin подтверждён пользователем; пример updated, рабочий YAML на +целевом сервере требует явного добавления Origin. Прямой read-only probe live +WSS не смог разрешить production DNS из этой среды. Браузерный UI не запускался. + +No wildcard Origin/TLS bypass. Нельзя утверждать полную замену Prosody: +не проверены полная production миграция, настоящие приложения/APNs/FCM, +целевой Docker/UID/TLS и длительная эксплуатация. Большой архив последний раз +проверен0.5.0, AF_UNIX admin требует разрешённого хоста. COMPATIBILITY.md. diff --git a/docs/VALIDATION-0.7.5.md b/docs/VALIDATION-0.7.5.md new file mode 100644 index 0000000..700509b --- /dev/null +++ b/docs/VALIDATION-0.7.5.md @@ -0,0 +1,32 @@ +# Проверка0.7.5 + +Linux x86_64, Rust1.99.1,2026-10-20. Итоговый лог: +validation-logs-0.7.5/verify.txt; структурированный итог — validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 229 passed,0 failed,10 ignored | +| fmt / Clippy all-targets -D warnings / release | Успешно | +| HTTP config | Наследование путей по приложениям, $host, Host/fallback, внешний префикс/порт, literal pchars/percent, отказ конфликтам/невалидным путям | +| Proxy trust | IPv4/IPv6 CIDR, пустой Lua table, недоверенные заголовки, цепочки и malformed RFC7239, защита от повторного повышения secure после HTTP hop | +| Настоящий TLS proxy | Verified HTTPS→HTTP, переписывание публичного пути, WS и BOSH auth/restart/bind/ping, legacy и RFC7239; cached RID reply | +| Ограничения входа | Origin, старые пути404, insecure/spoofed/untrusted403, IPv6 когда доступен | +| Upload | Отдельный внутренний путь и публичный prefix в unit PUT/GET; integrated custom $host path IQ/PUT/GET/reuse | +| Прежние сетевые сценарии | SASL/PLUS/SSDP/retry/Bind2/resume, WSS, PEP/push/preauth, logging/resource/CSI/MAM/MUC/offline — успешно | +| Native | 7 storage/module/order +1 bidirectional TLS/SASL EXTERNAL federation — успешно | +| Нагрузка | 100 аккаунтов,500 TLS клиентов,5000 ping,5000 сообщений,10000 MAM; failures=[] | + +Полный итоговый прогон выполнен после изменений кода с CARGO_INCREMENTAL=0; +предварительный Clippy выявил три замечания, исправленные перед этим прогоном. +Ранее устаревшие compiler artifacts были удалены cargo clean -p rust-xmpp. +Исходные данные production не изменялись. После проверки обновлены только +инструкция, отчёт и упаковка. Команда: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh +с native reference env согласно scripts/prosody_compat/README.md. + +Прокси в сетевом тесте — отдельный настоящий TLS socket forwarder с переписыванием +пути и HTTP metadata. Nginx/production proxy и настоящий Converse UI не запускались. +Изменения рабочего YAML и proxy выполняет оператор; пример — HTTP-PROXY.md. +Новые возможности не устраняют непроверенные границы из COMPATIBILITY.md. +PEP Android и DNSSEC из предыдущего сообщения остаются отдельными нерешёнными +задачами; этот выпуск их исправленными не объявляет. Прежние проверки DNSSEC +и логи0.7.4 сохранены в историческом отчёте. Нельзя заявлять полную замену Prosody. diff --git a/docs/VALIDATION-0.7.6.md b/docs/VALIDATION-0.7.6.md new file mode 100644 index 0000000..d030daf --- /dev/null +++ b/docs/VALIDATION-0.7.6.md @@ -0,0 +1,37 @@ +# Проверка0.7.6 + +Linux x86_64, Rust1.99.1,2026-10-20. Итоговый лог: +validation-logs-0.7.6/verify.txt; структурированный итог — validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 259 passed,0 failed,11 ignored | +| fmt / Clippy all-targets -D warnings / release | Успешно | +| MUC extension tests | PM policies, hats/identity privacy, presence role transitions, язык/config/disco, offline affiliates, optional flags, MAM ACL/all/presences/totals/purge | +| История | 10 tests: count/time/char constraints, combined limits, subject/hints, ordering/delay, last-message snapshot | +| Recovery | Native state filtering/roundtrip, exact resource, expiry, persisted deadline, changed config/password deny, no stale-role replay, explicit nick change, temporary-room retention | +| Bounds | Oversized4KiB/256KiB presence status omitted;20KiB last message does not prevent membership checkpoint | +| MUC wire restart | Настоящий SIGTERM с живыми STARTTLS sockets; temporary/persistent rooms, role/show/status, чужой account/resource отказ, nick theft отказ, groupchat после восстановления | +| Native occupant state | Настоящая Prosody восстанавливает Rust state с3 sessions/2 resources; native freeze меняет роль/status, Rust читает результат | +| Прежние сети | SASL/PLUS/SSDP/Bind2/resume, PEP/push/preauth, WSS/BOSH/proxy paths/upload, logging/resource/CSI/MAM/MUC/offline — успешно | +| Native total | 8 module/storage/MUC tests +1 bidirectional TLS/SASL EXTERNAL federation — успешно | +| Нагрузка | 100 аккаунтов,500 TLS клиентов,5000 ping,5000 сообщений,10000 MAM; failures=[] | + +Итоговый прогон выполнен после заморозки исходников с CARGO_INCREMENTAL=0 +и очисткой package artifacts. Предварительные проверки нашли исправленные +проблемы cleanup временных комнат, повторного использования checkpoint, +необязательных oversized полей и двух Clippy замечаний. Дополнительно проверен канонический отпечаток конфигурации, +независимый от порядка ключей Lua VM. Здесь сохранён только +полный успешный прогон. После него менялись отчёты и упаковка. + +Команда: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh с native reference env +согласно scripts/prosody_compat/README.md. Native source cafd74b9c5ea используется +непосредственно с Lua5.4/LuaSec/LuaSocket и compiled ICU/OpenSSL модулями. + +Не проверены production migration, реальные Monal/Conversations/Gajim, SIGKILL +crash recovery и все community-модули. Контроль штатного восстановления не +означает сохранение старых TCP-сокетов или SM между процессами. Условия300s / +полный JID / неизменный config описаны в MUC-RECOVERY.md. Нативное состояние без +Rust fingerprint не даёт автоматического разрешения на восстановление. +Предыдущие PEP/DNSSEC сообщения этот выпуск не объявляет исправленными. +Полная взаимозаменяемость сервера не подтверждена: COMPATIBILITY.md. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..11cea68 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,31 @@ +# Проверка0.7.7 + +Linux x86_64, Rust1.99.1,2026-10-20. Итоговый лог: +validation-logs-0.7.7/verify.txt; структурированный итог — validation-results.json. + +| Проверка | Результат | +|---|---| +| cargo test --locked | 262 passed,0 failed,11 ignored | +| fmt / Clippy all-targets -D warnings / release | Успешно | +| Новый module unit | Link порядок/schemes, загруженные/disabled модули, flags, наследование custom paths/public prefix, неправильные типы и path collisions | +| http_altconnect wire | 10 изолированных конфигураций: настоящий HTTP и CA-verified HTTPS, XRD/JRD parity, MIME, CORS, HEAD/OPTIONS/405, query/JSONP, Host, custom mount/public prefix, HTTPS preference, HTTP fallback | +| Транспортная регрессия | WSS framing работает до и после отключения discovery; старые WS/BOSH/proxy paths/upload сценарии прошли | +| Остальные регрессии | SASL/PLUS/SSDP/Bind2/resume, PEP/push/preauth, logging/resource/CSI/MAM/MUC/offline, MUC restart — успешно | +| Native total | 8 module/storage tests +1 TLS/SASL EXTERNAL federation — успешно | +| Нагрузка | 100 аккаунтов,500 TLS клиентов,5000 ping,5000 сообщений,10000 MAM; failures=[] | + +Итоговый прогон выполнен после исправления проверки Host с портом вне диапазона, +которую обнаружил новый сетевой тест. Все тесты запускают отдельные loopback +listeners и временные данные. После прогона менялись только отчёты и упаковка. +Native reference suite прежних модулей пройдена; непосредственно http_altconnect +проверен Rust unit/network и сопоставлением с supplied Lua source, без отдельного +native differential oracle этого модуля. + +Команда: XMPP_LOAD_CHECK=1 bash scripts/verify-local.sh с native reference env +согласно scripts/prosody_compat/README.md. Точный скрипт включает новый wire test. + +Рабочие конфиги/DNS/proxy/certificates production не изменялись; оператор должен +добавить http_altconnect и опубликовать HTTPS well-known. Инструкция: +HTTP-ALTCONNECT.md. Native HTTP/WS fallback сохранён; для строгого XEP0156 нужны +HTTPS/WSS URLs. Настоящие Converse/Monal/Conversations UI и полный production +rollout не проверены. Общие ограничения сервера — COMPATIBILITY.md. diff --git a/docs/module-inventory.json b/docs/module-inventory.json new file mode 100644 index 0000000..9175bf8 --- /dev/null +++ b/docs/module-inventory.json @@ -0,0 +1,475 @@ +{ + "prosody_revision": "cafd74b9c5ea", + "community_revision_reported": "dddb6c34b964", + "note": "Literal dependency/source inventory for supplied revisions; status refers to implemented handlers, not complete module parity. See COMPATIBILITY.md and VALIDATION.md. Conditional dependencies and runtime load order not exhaustively resolved.", + "modules": [ + { + "module": "admin_shell", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_admin_shell.lua", + "sha256": "e0a11e78a26a415f508e0487141a3d66086a408670e97d1363fd8f024fdd2160", + "literal_dependencies": [ + "admin_socket" + ] + } + ] + }, + { + "module": "auth_internal_hashed", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_auth_internal_hashed.lua", + "sha256": "c6bbaede99e34c838afbb514780fb7fd40be3102d364cd586fe8f05a3c7b368d", + "literal_dependencies": [ + "tokenauth" + ] + } + ] + }, + { + "module": "blocklist", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_blocklist.lua", + "sha256": "de7c95f1f988fc281560361a8eb14081e3efdeebebf89741fcb1bba25202931a", + "literal_dependencies": [] + } + ] + }, + { + "module": "bookmarks", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_bookmarks.lua", + "sha256": "e08745b43a0b6d0b344aaca5cc1888e20022d4de9cec54135eecf36a573ab01d", + "literal_dependencies": [ + "pep" + ] + } + ] + }, + { + "module": "bosh", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_bosh.lua", + "sha256": "cc5cb45d49467465d65629e5da46be226abdb1a2012ef4792afdabc2243d7b0c", + "literal_dependencies": [ + "c2s", + "http", + "http_altconnect" + ] + } + ] + }, + { + "module": "carbons", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_carbons.lua", + "sha256": "0af69b32eb636bbd63757cdb5f0bf729d1304c4b5e527bb6baa2094872517db7", + "literal_dependencies": [] + } + ] + }, + { + "module": "cloud_notify", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_cloud_notify.lua", + "sha256": "ffaaabc9e4a7a2e08f744937a85a91af61fa7043a41d6559e26209d49467eb46", + "literal_dependencies": [] + } + ] + }, + { + "module": "csi_simple", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_csi_simple.lua", + "sha256": "170958270a8502fa92117daee20cb9f5b656d6098b4b8d2eb545ab4d723700bc", + "literal_dependencies": [ + "csi" + ] + } + ] + }, + { + "module": "disco", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_disco.lua", + "sha256": "4bb50c2552248f852ea7ba63bc535b81f7ae1ec2fc627645987a97c70c10be6f", + "literal_dependencies": [] + } + ] + }, + { + "module": "http_file_share", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_http_file_share.lua", + "sha256": "f4bded677570e6d6fff2fd3278a222457278b0509d53bca2492495bd37eb806a", + "literal_dependencies": [ + "disco", + "http" + ] + } + ] + }, + { + "module": "mam", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_mam/mod_mam.lua", + "sha256": "fde9d1820b8fb10c8848089987c776a682d0972112b8a664fbaf8ab21489176b", + "literal_dependencies": [ + "muc_mam" + ] + } + ] + }, + { + "module": "muc", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/muc/mod_muc.lua", + "sha256": "e808268c7af15f29501672adf77e9552397db89d0a5e25d6ce0b17455f3cd9ed", + "literal_dependencies": [ + "adhoc", + "disco", + "muc_unique" + ] + } + ] + }, + { + "module": "muc_mam", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_muc_mam.lua", + "sha256": "3d432674daa67babbcb836c12df6d3ca34f574fbbe084549f54f24de7034ee31", + "literal_dependencies": [ + "muc" + ] + } + ] + }, + { + "module": "pep", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_pep.lua", + "sha256": "b28ec7965480de6a62aecca3dd6ef48eae2a37b24bb288ae637c8d676a929919", + "literal_dependencies": [] + } + ] + }, + { + "module": "pep_simple", + "rust_status": "empty_upstream_noop", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_pep_simple.lua", + "sha256": "1d80b3e3d5dd37564847286272e0ee6323d58bb481bb366697fb2316411843b5", + "literal_dependencies": [] + } + ] + }, + { + "module": "ping", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_ping.lua", + "sha256": "587f3b3791d453ea8538505aa0734474ff77686f18a76ff78253c957e6fd4673", + "literal_dependencies": [] + } + ] + }, + { + "module": "private", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_private.lua", + "sha256": "257c8cc571e2ca5d1d9a726ffb9b8db73874dc2c7da4e2354a9198683c88c77a", + "literal_dependencies": [] + } + ] + }, + { + "module": "pubsub", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_pubsub/mod_pubsub.lua", + "sha256": "e6a8de1d70bf4705953716f64388dec1df2b8617eb3fa7eababa2e24f735e335", + "literal_dependencies": [ + "disco" + ] + } + ] + }, + { + "module": "register", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_register.lua", + "sha256": "20e01f67dc69d0987071e24b3a0e49cf51bd0daa4682eb7209beb5a654e0155c", + "literal_dependencies": [ + "register_ibr", + "user_account_management", + "watchregistrations" + ] + } + ] + }, + { + "module": "roster", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_roster.lua", + "sha256": "8521a32488415481f4f06af714898f80767a87e992e1d185bbf711af4e63d030", + "literal_dependencies": [] + } + ] + }, + { + "module": "s2s", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_s2s.lua", + "sha256": "ff5a322bf0e7e679a9b467c8c4af998856abdb49f0fa9e4dd2879e7ccdde369a", + "literal_dependencies": [ + "s2s_auth_dane_in" + ] + } + ] + }, + { + "module": "s2s_auth_certs", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_s2s_auth_certs.lua", + "sha256": "8e08b302687fac666cc74d188af79606304b67e42901125e7b5b1e0b7974bec0", + "literal_dependencies": [] + } + ] + }, + { + "module": "sasl2", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "community", + "path": "custom_plugins/mod_sasl2/mod_sasl2.lua", + "sha256": "825e967420cc59c7b943da62b5f22c8901121d858c88d359aed93fbe9efc2f36", + "literal_dependencies": [] + } + ] + }, + { + "module": "sasl_ssdp", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "community", + "path": "custom_plugins/mod_sasl_ssdp/mod_sasl_ssdp.lua", + "sha256": "a9340b988a4de73167aa166082d036d1ae44f33e5640c305c7a0279c1605b83f", + "literal_dependencies": [] + }, + { + "source": "community", + "path": "custom_plugins/share/lua/5.4/mod_sasl_ssdp.lua", + "sha256": "a9340b988a4de73167aa166082d036d1ae44f33e5640c305c7a0279c1605b83f", + "literal_dependencies": [] + } + ] + }, + { + "module": "saslauth", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_saslauth.lua", + "sha256": "9c2403d7271fcd73cc1dea63fcf05b10059467f3ae3ebaf26a42e3b682c8e3ea", + "literal_dependencies": [] + } + ] + }, + { + "module": "seclabels", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "community", + "path": "custom_plugins/mod_seclabels/mod_seclabels.lua", + "sha256": "d6226f251edbab1e10371b655c4ab8ce0b6d2a40710a9b6f1c9e520c8f0a14d7", + "literal_dependencies": [] + } + ] + }, + { + "module": "smacks", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_smacks.lua", + "sha256": "5c978b69b24a8a96042c2a52225995ada33956721083a925ff12bedea779f313", + "literal_dependencies": [] + } + ] + }, + { + "module": "storage_internal", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_storage_internal.lua", + "sha256": "48daddc50dfbd310bf19c9ae04cdf47523a87ae3d7c66d6e5ba894e5135fe129", + "literal_dependencies": [] + } + ] + }, + { + "module": "time", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_time.lua", + "sha256": "606571ee5960b98683f03a94bf693a010d76e505f12b77e1d222cb78beef5adf", + "literal_dependencies": [] + } + ] + }, + { + "module": "tls", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_tls.lua", + "sha256": "c14fde3023f4080546c9f8a61f6e0b08dd0ee6854ddc9c685560987be548dd55", + "literal_dependencies": [] + } + ] + }, + { + "module": "turn_external", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_turn_external.lua", + "sha256": "9ac80a1b85fcea9b43022293cd09127bb26c5f8ea96446ab97579abee995765f", + "literal_dependencies": [ + "external_services" + ] + } + ] + }, + { + "module": "uptime", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_uptime.lua", + "sha256": "d34df20d7e4e114060584667bdc80fd2f407921b4c78e1de1d150fb4c6730854", + "literal_dependencies": [ + "adhoc" + ] + } + ] + }, + { + "module": "vcard_legacy", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_vcard_legacy.lua", + "sha256": "7f4697723ce6803f58158390fc3b49428451336a61d9ab2a4b0035b681fa5baf", + "literal_dependencies": [ + "pep" + ] + } + ] + }, + { + "module": "version", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_version.lua", + "sha256": "7664626d35c3b6d0a0a872f6daaf66255aeeae80a65e1f560c17ac56e69b8db1", + "literal_dependencies": [] + } + ] + }, + { + "module": "websocket", + "rust_status": "implemented_with_limits_see_COMPATIBILITY", + "candidates": [ + { + "source": "core", + "path": "prosody-cafd74b9c5ea/plugins/mod_websocket.lua", + "sha256": "fc596425939d94a7843dbe5e188b8e4b9ee896822c88e43e4d34ee51b4cd6184", + "literal_dependencies": [ + "c2s", + "http", + "http_altconnect" + ] + } + ] + } + ], + "runtime_version": "0.3.0" +} diff --git a/docs/validation-logs-0.4.0/rust-xmpp-clippy.txt b/docs/validation-logs-0.4.0/rust-xmpp-clippy.txt new file mode 100644 index 0000000..70d9ff5 --- /dev/null +++ b/docs/validation-logs-0.4.0/rust-xmpp-clippy.txt @@ -0,0 +1,2 @@ + Checking rust-xmpp v0.4.0 (/workspace/scratch/362577cdf2e0/rust-xmpp) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.09s diff --git a/docs/validation-logs-0.4.0/rust-xmpp-release.txt b/docs/validation-logs-0.4.0/rust-xmpp-release.txt new file mode 100644 index 0000000..0465d53 --- /dev/null +++ b/docs/validation-logs-0.4.0/rust-xmpp-release.txt @@ -0,0 +1,2 @@ + Compiling rust-xmpp v0.4.0 (/workspace/scratch/362577cdf2e0/rust-xmpp) + Finished `release` profile [optimized] target(s) in 19.43s diff --git a/docs/validation-logs-0.4.0/rust-xmpp-tests-final.txt b/docs/validation-logs-0.4.0/rust-xmpp-tests-final.txt new file mode 100644 index 0000000..678f459 --- /dev/null +++ b/docs/validation-logs-0.4.0/rust-xmpp-tests-final.txt @@ -0,0 +1,320 @@ + Compiling rust-xmpp v0.4.0 (/workspace/scratch/362577cdf2e0/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 16.63s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-e35239525a452455) + +running 24 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 federation::tests::stream_restarts_compare_canonical_domains ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::independent_framing ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.55s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-2bce05be4db51fc4) + +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-fab6f1412fe32176) + +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-28547367898e6a30) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... 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 smart_enable_and_message_selection ... ok +test equal_cursors_and_normalized_query_jids ... ok +test real_archive_sid_and_sender_privacy ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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.05s + + Running tests/auth.rs (target/debug/deps/auth-1f5f13ca9abe0947) + +running 2 tests +test traversal_and_malicious_account_records_rejected ... ok +test create_and_read_hashed_accounts ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.69s + + Running tests/cli.rs (target/debug/deps/cli-60b3380ae4ed4cfb) + +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-00f0036749bc58da) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test storage_paths_encode_like_prosody ... ok +test include_cycles_rejected ... ok +test declarations_and_overrides ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test tables_cannot_recurse_forever ... ok +test supplied_config_matches_original_prosody_loader ... ok +test runtime_capabilities_are_unavailable ... ok +test all_formats_round_trip_actual_config ... 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-7423d59484b1f328) + +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-295ef60d9761ac35) + +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-06c96afda506be83) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-51387f71e0080e24) + +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.09s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-8eaeecccd4759bb4) + +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.15s + + Running tests/http_transport.rs (target/debug/deps/http_transport-fb9e1bc0b9cc3c07) + +running 7 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_reorders_two_requests_without_replaying_stanzas ... ok +test websocket_frames_are_independent_xml ... ok +test bosh_retry_survives_aborted_http_request_without_replaying_stanzas ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + + Running tests/http_upload.rs (target/debug/deps/http_upload-826d9cdb6f07c106) + +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.07s + + Running tests/message_routing.rs (target/debug/deps/message_routing-300d1f16a0279559) + +running 2 tests +test headline_uses_all_nonnegative_available_resources_and_errors_never_fallback ... ok +test unknown_resource_falls_back_to_available_bare_jid ... 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-6094a40d7e4e9fd4) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-f7d401e9bdda6874) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-c3519d804f557b73) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test authorization_and_conflict ... ok +test iq_response_requires_expected_sender ... ok +test join_lock_chat_leave ... ok +test nick_invite_destroy_and_local_creation ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test persistence_roundtrip ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test archive_history_and_permissions ... ok +test reserved_nickname_blocks_other_users ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-5a83afb0c392f358) + +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/pubsub_native.rs (target/debug/deps/pubsub_native-452af5a83e8c375b) + +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-d9aa4e5ae21f08f0) + +running 18 tests +test native_string_max_items_satisfies_numeric_publish_precondition ... ok +test modern_bookmarks_notify_legacy_capability ... ok +test configurable_limits_component_creation_and_publisher ... ok +test capability_discovery_replays_and_filters_notifications ... ok +test configure_requires_owner ... 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 publish_options_conflict_is_recoverable_and_preserves_item ... ok +test pep_persists_namespaces_and_enforces_owner ... 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 payload_policy_applies_to_subscription_and_caps_replays ... ok +test subscription_options_disable_events_and_require_subscriber ... 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.31s + + Running tests/push.rs (target/debug/deps/push-50e169110c02e394) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... ok +test stable_client_registration_and_targeted_notification ... ok +test reenable_cancels_old_errors_and_failure_counts_are_per_account ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-84c92c3ddfcfccb6) + +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/sasl_accounts.rs (target/debug/deps/sasl_accounts-3f676750cb5e4878) + +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.58s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-c4b373401915ee8a) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test domain_roster_and_block_jid_normalization ... ok +test malformed_roster_iq_never_mutates ... ok +test blocklist_enforcement_api ... 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 bookmarks_private_pep_bidirectional_and_private ... ok +test subscription_handshake_and_cancel ... ok +test legacy_bookmarks_respect_host_limit_and_read_over_256 ... 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.27s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-4e29b17b141e97a4) + +running 6 tests +test empty_namespace_preserved ... ok +test mixed_binary_roundtrip ... ok +test stanza_roundtrip ... ok +test path_and_atomic_update ... ok +test concurrent_updates_do_not_lose_entries ... ok +test rejects_cycles_code_and_bad_files ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + 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 + diff --git a/docs/validation-logs-0.4.0/xmpp-admin-socket.txt b/docs/validation-logs-0.4.0/xmpp-admin-socket.txt new file mode 100644 index 0000000..c0d8444 --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-admin-socket.txt @@ -0,0 +1,24 @@ + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.06s + Running tests/admin_socket.rs (target/debug/deps/admin_socket-fab6f1412fe32176) + +running 1 test +test prosody_wire_commands_password_prompt_and_safe_errors ... FAILED + +failures: + +---- prosody_wire_commands_password_prompt_and_safe_errors stdout ---- + +thread 'prosody_wire_commands_password_prompt_and_safe_errors' (29) panicked at tests/admin_socket.rs:25:62: +called `Result::unwrap()` on an `Err` value: create admin Unix socket + +Caused by: + Operation not permitted (os error 1) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + prosody_wire_commands_password_prompt_and_safe_errors + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +error: test failed, to rerun pass `--test admin_socket` diff --git a/docs/validation-logs-0.4.0/xmpp-bind2.txt b/docs/validation-logs-0.4.0/xmpp-bind2.txt new file mode 100644 index 0000000..b96dfff --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-bind2.txt @@ -0,0 +1 @@ +PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback diff --git a/docs/validation-logs-0.4.0/xmpp-http.txt b/docs/validation-logs-0.4.0/xmpp-http.txt new file mode 100644 index 0000000..db4b75a --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-http.txt @@ -0,0 +1 @@ +PASS: integrated WSS + HTTPS BOSH SASL/bind, CSI, inherited BOSH namespaces, roster, bidirectional chat, upload IQ/PUT/GET/reuse diff --git a/docs/validation-logs-0.4.0/xmpp-large-archive.txt b/docs/validation-logs-0.4.0/xmpp-large-archive.txt new file mode 100644 index 0000000..856c1c9 --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-large-archive.txt @@ -0,0 +1,12 @@ + Blocking waiting for file lock on package cache + Blocking waiting for file lock on package cache + Blocking waiting for file lock on package cache + Blocking waiting for file lock on build directory + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s + Running tests/archive_mam.rs (target/debug/deps/archive_mam-28547367898e6a30) + +running 1 test +test archive_larger_than_128mib_streams ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13 filtered out; finished in 0.86s + diff --git a/docs/validation-logs-0.4.0/xmpp-native-federation.txt b/docs/validation-logs-0.4.0/xmpp-native-federation.txt new file mode 100644 index 0000000..234eb85 --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-native-federation.txt @@ -0,0 +1,8 @@ + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-8eaeecccd4759bb4) + +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.74s + diff --git a/docs/validation-logs-0.4.0/xmpp-native-oracle.txt b/docs/validation-logs-0.4.0/xmpp-native-oracle.txt new file mode 100644 index 0000000..1de2135 --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-native-oracle.txt @@ -0,0 +1,43 @@ + Blocking waiting for file lock on package cache + Blocking waiting for file lock on package cache + Blocking waiting for file lock on package cache + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.12s + Running tests/csi_native.rs (target/debug/deps/csi_native-295ef60d9761ac35) + +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-5a83afb0c392f358) + +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.05s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-452af5a83e8c375b) + +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-50e169110c02e394) + +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-84c92c3ddfcfccb6) + +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.22s + diff --git a/docs/validation-logs-0.4.0/xmpp-network.txt b/docs/validation-logs-0.4.0/xmpp-network.txt new file mode 100644 index 0000000..66c27ce --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-network.txt @@ -0,0 +1,2 @@ +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 diff --git a/docs/validation-logs-0.4.0/xmpp-sasl-sha1.txt b/docs/validation-logs-0.4.0/xmpp-sasl-sha1.txt new file mode 100644 index 0000000..c33a4eb --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-sasl-sha1.txt @@ -0,0 +1 @@ +PASS: independent SCRAM-SHA-1 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection diff --git a/docs/validation-logs-0.4.0/xmpp-sasl-sha256.txt b/docs/validation-logs-0.4.0/xmpp-sasl-sha256.txt new file mode 100644 index 0000000..30e8156 --- /dev/null +++ b/docs/validation-logs-0.4.0/xmpp-sasl-sha256.txt @@ -0,0 +1 @@ +PASS: independent SCRAM-SHA-256 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection diff --git a/docs/validation-logs-0.5.0/admin.txt b/docs/validation-logs-0.5.0/admin.txt new file mode 100644 index 0000000..de3553a --- /dev/null +++ b/docs/validation-logs-0.5.0/admin.txt @@ -0,0 +1,25 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.82s + Running tests/admin_socket.rs (target/debug/deps/admin_socket-572bc055bcf12a3a) + +running 1 test +test prosody_wire_commands_password_prompt_and_safe_errors ... FAILED + +failures: + +---- prosody_wire_commands_password_prompt_and_safe_errors stdout ---- + +thread 'prosody_wire_commands_password_prompt_and_safe_errors' (996) panicked at tests/admin_socket.rs:25:62: +called `Result::unwrap()` on an `Err` value: create admin Unix socket + +Caused by: + Operation not permitted (os error 1) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + prosody_wire_commands_password_prompt_and_safe_errors + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +error: test failed, to rerun pass `--test admin_socket` diff --git a/docs/validation-logs-0.5.0/bind2.txt b/docs/validation-logs-0.5.0/bind2.txt new file mode 100644 index 0000000..b96dfff --- /dev/null +++ b/docs/validation-logs-0.5.0/bind2.txt @@ -0,0 +1 @@ +PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback diff --git a/docs/validation-logs-0.5.0/clippy.txt b/docs/validation-logs-0.5.0/clippy.txt new file mode 100644 index 0000000..2c95bd3 --- /dev/null +++ b/docs/validation-logs-0.5.0/clippy.txt @@ -0,0 +1,2 @@ + Checking rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.01s diff --git a/docs/validation-logs-0.5.0/federation.txt b/docs/validation-logs-0.5.0/federation.txt new file mode 100644 index 0000000..4772432 --- /dev/null +++ b/docs/validation-logs-0.5.0/federation.txt @@ -0,0 +1,9 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.09s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-1d17c1cfea5f4714) + +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 1.65s + diff --git a/docs/validation-logs-0.5.0/http.txt b/docs/validation-logs-0.5.0/http.txt new file mode 100644 index 0000000..db4b75a --- /dev/null +++ b/docs/validation-logs-0.5.0/http.txt @@ -0,0 +1 @@ +PASS: integrated WSS + HTTPS BOSH SASL/bind, CSI, inherited BOSH namespaces, roster, bidirectional chat, upload IQ/PUT/GET/reuse diff --git a/docs/validation-logs-0.5.0/large.txt b/docs/validation-logs-0.5.0/large.txt new file mode 100644 index 0000000..cb2e277 --- /dev/null +++ b/docs/validation-logs-0.5.0/large.txt @@ -0,0 +1,9 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.79s + Running tests/archive_mam.rs (target/debug/deps/archive_mam-973755ec43c22385) + +running 1 test +test archive_larger_than_128mib_streams ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13 filtered out; finished in 0.86s + diff --git a/docs/validation-logs-0.5.0/native-preauth.txt b/docs/validation-logs-0.5.0/native-preauth.txt new file mode 100644 index 0000000..b332034 --- /dev/null +++ b/docs/validation-logs-0.5.0/native-preauth.txt @@ -0,0 +1,9 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.12s + Running tests/preauth_native.rs (target/debug/deps/preauth_native-ccb4b485a667f0f8) + +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.27s + diff --git a/docs/validation-logs-0.5.0/native.txt b/docs/validation-logs-0.5.0/native.txt new file mode 100644 index 0000000..4f887fb --- /dev/null +++ b/docs/validation-logs-0.5.0/native.txt @@ -0,0 +1,49 @@ + Blocking waiting for file lock on build directory + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 11.11s + Running tests/csi_native.rs (target/debug/deps/csi_native-eba98d9cb4d5f495) + +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.04s + + Running tests/muc_native.rs (target/debug/deps/muc_native-addf0f4a2dc96ddd) + +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.08s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-ccb4b485a667f0f8) + +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.15s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-28e6095e22e2420b) + +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.12s + + Running tests/push.rs (target/debug/deps/push-99eb4de72a25a307) + +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-1275958eb2356dc6) + +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.09s + diff --git a/docs/validation-logs-0.5.0/preauth-network.txt b/docs/validation-logs-0.5.0/preauth-network.txt new file mode 100644 index 0000000..d785829 --- /dev/null +++ b/docs/validation-logs-0.5.0/preauth-network.txt @@ -0,0 +1 @@ +PASS: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation diff --git a/docs/validation-logs-0.5.0/push-discovery.txt b/docs/validation-logs-0.5.0/push-discovery.txt new file mode 100644 index 0000000..acedd0c --- /dev/null +++ b/docs/validation-logs-0.5.0/push-discovery.txt @@ -0,0 +1 @@ +PASS: TLS account/domain discovery with PEP and push enabled/disabled; real push enable, publish options, per-node and service-wide disable diff --git a/docs/validation-logs-0.5.0/release.txt b/docs/validation-logs-0.5.0/release.txt new file mode 100644 index 0000000..e6dfef2 --- /dev/null +++ b/docs/validation-logs-0.5.0/release.txt @@ -0,0 +1,2 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 21.78s diff --git a/docs/validation-logs-0.5.0/sasl1.txt b/docs/validation-logs-0.5.0/sasl1.txt new file mode 100644 index 0000000..c33a4eb --- /dev/null +++ b/docs/validation-logs-0.5.0/sasl1.txt @@ -0,0 +1 @@ +PASS: independent SCRAM-SHA-1 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection diff --git a/docs/validation-logs-0.5.0/sasl256.txt b/docs/validation-logs-0.5.0/sasl256.txt new file mode 100644 index 0000000..30e8156 --- /dev/null +++ b/docs/validation-logs-0.5.0/sasl256.txt @@ -0,0 +1 @@ +PASS: independent SCRAM-SHA-256 over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection diff --git a/docs/validation-logs-0.5.0/tcp.txt b/docs/validation-logs-0.5.0/tcp.txt new file mode 100644 index 0000000..66c27ce --- /dev/null +++ b/docs/validation-logs-0.5.0/tcp.txt @@ -0,0 +1,2 @@ +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 diff --git a/docs/validation-logs-0.5.0/tests.txt b/docs/validation-logs-0.5.0/tests.txt new file mode 100644 index 0000000..b4192e6 --- /dev/null +++ b/docs/validation-logs-0.5.0/tests.txt @@ -0,0 +1,353 @@ + Compiling rust-xmpp v0.5.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 9.46s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-587bbb396e7c5a05) + +running 24 tests +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +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::stream_restarts_compare_canonical_domains ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test http_transport::tests::independent_framing ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-43dc57ea24368ad6) + +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-572bc055bcf12a3a) + +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-973755ec43c22385) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test equal_cursors_and_normalized_query_jids ... ok +test real_archive_sid_and_sender_privacy ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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-bd49e01cf4c2f281) + +running 2 tests +test traversal_and_malicious_account_records_rejected ... ok +test create_and_read_hashed_accounts ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.72s + + Running tests/cli.rs (target/debug/deps/cli-b896cdfe001bea67) + +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.02s + + Running tests/config.rs (target/debug/deps/config-1478d42081f7ab0f) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test declarations_and_overrides ... ok +test storage_paths_encode_like_prosody ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test include_cycles_rejected ... ok +test tables_cannot_recurse_forever ... 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-72cb213d3dbb7d6c) + +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-eba98d9cb4d5f495) + +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-c5d9714de0c98247) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-ff48fac0b9f28fda) + +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.23s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-1d17c1cfea5f4714) + +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.61s + + Running tests/http_transport.rs (target/debug/deps/http_transport-c4d9ba6090cac490) + +running 7 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_reorders_two_requests_without_replaying_stanzas ... ok +test websocket_frames_are_independent_xml ... ok +test bosh_retry_survives_aborted_http_request_without_replaying_stanzas ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + + Running tests/http_upload.rs (target/debug/deps/http_upload-a7eb3b455efd4574) + +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.06s + + Running tests/message_routing.rs (target/debug/deps/message_routing-f782bf5ad5366e9d) + +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-aeab2ef041578295) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-2cf55e8d47991b19) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-a4be20068ea0d649) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test join_lock_chat_leave ... ok +test authorization_and_conflict ... ok +test iq_response_requires_expected_sender ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test archive_history_and_permissions ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-addf0f4a2dc96ddd) + +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/pep_discovery.rs (target/debug/deps/pep_discovery-e81fd010a9642251) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Running tests/preauth.rs (target/debug/deps/preauth-0088370dd8976061) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test concurrent_redemption_has_exactly_one_winner ... ok +test valid_token_autoapproves_and_sends_reciprocal_request ... 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 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-ccb4b485a667f0f8) + +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-28e6095e22e2420b) + +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-102bbd30c0a0e29d) + +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 capability_discovery_replays_and_filters_notifications ... ok +test configure_requires_owner ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test open_model_and_notifications ... ok +test reads_prosody_legacy_fixture ... 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.31s + + Running tests/push.rs (target/debug/deps/push-99eb4de72a25a307) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-1275958eb2356dc6) + +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/sasl_accounts.rs (target/debug/deps/sasl_accounts-c5837c508cc81fd7) + +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/storage_accounts.rs (target/debug/deps/storage_accounts-8a7eb307d9ed32f8) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... 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.43s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-1e1d2b48f1bf84f1) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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.03s + + 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 + diff --git a/docs/validation-logs-0.6.0/load.json b/docs/validation-logs-0.6.0/load.json new file mode 100644 index 0000000..a62c48b --- /dev/null +++ b/docs/validation-logs-0.6.0/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.117, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.599, + "setup_latency": { + "samples": 500, + "median_ms": 54.78, + "p95_ms": 73.082, + "max_ms": 85.834 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.17, + "p95_ms": 24.847, + "max_ms": 28.831 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.239, + "metrics_before": { + "rss_kib": 6804, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24808, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25508, + "cpu_seconds": 8.02, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25636, + "cpu_seconds": 8.04, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25636, + "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 + } +} diff --git a/docs/validation-logs-0.6.0/verify.txt b/docs/validation-logs-0.6.0/verify.txt new file mode 100644 index 0000000..116dcca --- /dev/null +++ b/docs/validation-logs-0.6.0/verify.txt @@ -0,0 +1,498 @@ + Compiling rust-xmpp v0.6.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.38s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-8e6956f919afb7dd) + +running 24 tests +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test http_transport::tests::independent_framing ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test sasl::tests::reject_malformed_and_downgrade ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-3e14cc5e7aa0895f) + +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-8c2b01cbac1302f6) + +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-fa7aa68472eca402) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... ok +test real_archive_sid_and_sender_privacy ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... ok +test zero_page_dates_and_invalid_forms ... ok +test equal_cursors_and_normalized_query_jids ... 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.06s + + Running tests/auth.rs (target/debug/deps/auth-eebaaafbf6ac0386) + +running 2 tests +test traversal_and_malicious_account_records_rejected ... ok +test create_and_read_hashed_accounts ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.74s + + Running tests/cli.rs (target/debug/deps/cli-21d91f9b255982aa) + +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.02s + + Running tests/config.rs (target/debug/deps/config-91531c799cda7652) + +running 12 tests +test include_cycles_rejected ... ok +test declarations_and_overrides ... ok +test component_can_explicitly_enable_global_modules ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test storage_paths_encode_like_prosody ... ok +test tables_cannot_recurse_forever ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... ok +test supplied_config_matches_original_prosody_loader ... 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-69485b32335fb875) + +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-10dd4e4347920cda) + +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-ec340cd291ffc214) + +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-8473fc8f96b99004) + +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.09s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-3893c914190e6790) + +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.41s + + Running tests/http_transport.rs (target/debug/deps/http_transport-52d4995a1a8bfae2) + +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-10d3b973882378f6) + +running 5 tests +test https_endpoint_uses_real_tls ... ok +test percent_escape_case_and_equivalent_url_spelling ... ok +test expired_upload_releases_quota ... ok +test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... 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.06s + + Running tests/message_routing.rs (target/debug/deps/message_routing-633d3d5413f5f3ab) + +running 2 tests +test headline_uses_all_nonnegative_available_resources_and_errors_never_fallback ... ok +test unknown_resource_falls_back_to_available_bare_jid ... 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-5acc3b2816c611e4) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... 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.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-5ec5d23a3e219c75) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-d851ccc84dafeffd) + +running 18 tests +test join_lock_chat_leave ... ok +test configured_lock_timeout_expires_without_owner_submission ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test iq_response_requires_expected_sender ... ok +test authorization_and_conflict ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test persistence_roundtrip ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test reserved_nickname_blocks_other_users ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok +test archive_history_and_permissions ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-aa6750b2ebee43c8) + +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/pep_discovery.rs (target/debug/deps/pep_discovery-3104ee43a0080af3) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + + Running tests/preauth.rs (target/debug/deps/preauth-3aa913bdaa6593d6) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... 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 standard_adhoc_command_returns_contact_uri ... ok +test identity_scope_does_not_burn_on_wrong_sender_or_leak_to_prosody ... ok +test valid_token_autoapproves_and_sends_reciprocal_request ... ok +test local_invitation_updates_both_rosters_without_granting_reverse_subscription ... ok +test invitations_expire_are_single_use_and_owner_scoped ... 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-23e5663ef0756bc1) + +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-a4cb06be28318f35) + +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-3639e617b2b52513) + +running 18 tests +test configure_requires_owner ... ok +test modern_bookmarks_notify_legacy_capability ... ok +test configurable_limits_component_creation_and_publisher ... ok +test native_string_max_items_satisfies_numeric_publish_precondition ... ok +test capability_discovery_replays_and_filters_notifications ... ok +test open_model_and_notifications ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test reads_prosody_legacy_fixture ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... ok +test native_notification_fields_and_retract_policy_are_independent ... ok +test payload_policy_applies_to_subscription_and_caps_replays ... 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.25s + + Running tests/push.rs (target/debug/deps/push-ec027616ed738bd1) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-f61d2cf0ed75c5f6) + +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-741d9a64d370399a) + +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 random_conflicts_get_distinct_resources_for_same_owner ... ok +test replacement_revokes_hibernating_resume_and_survives_old_cleanup ... ok +test kick_new_leaves_existing_session_unchanged ... 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-009861adf6f23d85) + +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.62s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-5f102e29d7d67bfc) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test domain_roster_and_block_jid_normalization ... ok +test malformed_roster_iq_never_mutates ... ok +test blocklist_enforcement_api ... 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.42s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-27729d166567a13d) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_roundtrip ... ok +test path_and_atomic_update ... ok +test concurrent_updates_do_not_lose_entries ... ok +test rejects_cycles_code_and_bad_files ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + 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 + + Checking rust-xmpp v0.6.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.46s + Compiling rust-xmpp v0.6.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 21.75s +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: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback +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 invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 7.117, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.599, + "setup_latency": { + "samples": 500, + "median_ms": 54.78, + "p95_ms": 73.082, + "max_ms": 85.834 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.17, + "p95_ms": 24.847, + "max_ms": 28.831 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.239, + "metrics_before": { + "rss_kib": 6804, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24808, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25508, + "cpu_seconds": 8.02, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25636, + "cpu_seconds": 8.04, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25636, + "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 + } +} + Compiling rust-xmpp v0.6.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.49s + Running tests/csi_native.rs (target/debug/deps/csi_native-10dd4e4347920cda) + +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.05s + + Running tests/muc_native.rs (target/debug/deps/muc_native-aa6750b2ebee43c8) + +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.05s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-23e5663ef0756bc1) + +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.13s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-a4cb06be28318f35) + +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.12s + + Running tests/push.rs (target/debug/deps/push-ec027616ed738bd1) + +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.00s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-f61d2cf0ed75c5f6) + +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 0.97s + + Compiling rust-xmpp v0.6.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.15s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-3893c914190e6790) + +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 1.05s + diff --git a/docs/validation-logs-0.6.1/load.json b/docs/validation-logs-0.6.1/load.json new file mode 100644 index 0000000..3a13a73 --- /dev/null +++ b/docs/validation-logs-0.6.1/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 6.467, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.617, + "setup_latency": { + "samples": 500, + "median_ms": 55.521, + "p95_ms": 80.465, + "max_ms": 104.211 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.434, + "p95_ms": 21.835, + "max_ms": 28.842 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.583, + "metrics_before": { + "rss_kib": 6984, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24764, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25300, + "cpu_seconds": 7.55, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25364, + "cpu_seconds": 7.57, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25364, + "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 + } +} diff --git a/docs/validation-logs-0.6.1/reproduce-0.6.0-android.txt b/docs/validation-logs-0.6.1/reproduce-0.6.0-android.txt new file mode 100644 index 0000000..bfdcb32 --- /dev/null +++ b/docs/validation-logs-0.6.1/reproduce-0.6.0-android.txt @@ -0,0 +1 @@ +PASS: reproduced 0.6.0 TLS1.2 Android y,, rejection diff --git a/docs/validation-logs-0.6.1/reproduce-0.6.0-web.txt b/docs/validation-logs-0.6.1/reproduce-0.6.0-web.txt new file mode 100644 index 0000000..4467d6f --- /dev/null +++ b/docs/validation-logs-0.6.1/reproduce-0.6.0-web.txt @@ -0,0 +1,23 @@ +SERVER LOG: +Rust XMPP experimental: localhost; configured listeners started + +Traceback (most recent call last): + File "/workspace/scratch/362577cdf2e0/rust-xmpp-next/tests/sasl_web_network.py", line 172, in + main() + File "/workspace/scratch/362577cdf2e0/rust-xmpp-next/tests/sasl_web_network.py", line 155, in main + asyncio.run(exercise(https_port, ssl.create_default_context(cafile=str(cert)))) + File "/opt/codex/runtimes/codex-primary-runtime/dependencies/python/lib/python3.12/asyncio/runners.py", line 195, in run + return runner.run(main) + ^^^^^^^^^^^^^^^^ + File "/opt/codex/runtimes/codex-primary-runtime/dependencies/python/lib/python3.12/asyncio/runners.py", line 118, in run + return self._loop.run_until_complete(task) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/opt/codex/runtimes/codex-primary-runtime/dependencies/python/lib/python3.12/asyncio/base_events.py", line 691, in run_until_complete + return future.result() + ^^^^^^^^^^^^^^^ + File "/workspace/scratch/362577cdf2e0/rust-xmpp-next/tests/sasl_web_network.py", line 118, in exercise + await authenticate(ws, sasl2) + File "/workspace/scratch/362577cdf2e0/rust-xmpp-next/tests/sasl_web_network.py", line 82, in authenticate + assert attrs.get('h') == expected_ssdp, (attrs.get('h'), expected_ssdp) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssertionError: ('UzQajgapjuoNRETKCNV1g1zapAbBiaY9mIhExQJWVpQ=', 'Rgy1OtqF8B/re2ip4rVzs2herVHnECfKbsLqKQgUMgo=') diff --git a/docs/validation-logs-0.6.1/verify.txt b/docs/validation-logs-0.6.1/verify.txt new file mode 100644 index 0000000..9a3dd27 --- /dev/null +++ b/docs/validation-logs-0.6.1/verify.txt @@ -0,0 +1,518 @@ + Compiling rust-xmpp v0.6.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.46s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-0d50b3846665d98d) + +running 24 tests +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... 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 sasl::tests::reject_malformed_and_downgrade ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-8259f7125d6ef3c7) + +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-d604f5168a5bd15e) + +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-3e1178e10bc00cb9) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test smart_enable_and_message_selection ... ok +test equal_cursors_and_normalized_query_jids ... ok +test real_archive_sid_and_sender_privacy ... ok +test zero_page_dates_and_invalid_forms ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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-793ffbaabad50035) + +running 2 tests +test traversal_and_malicious_account_records_rejected ... ok +test create_and_read_hashed_accounts ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s + + Running tests/cli.rs (target/debug/deps/cli-873bc4e47b8a4c2f) + +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-c02c199093267002) + +running 12 tests +test include_cycles_rejected ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test storage_paths_encode_like_prosody ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_config_matches_original_prosody_loader ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... 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-1437a4fce5b30727) + +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-eefc40693c54ec61) + +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-a22a1a0222784e58) + +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-b301f540f3b4b403) + +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.11s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-9cdad036fb137095) + +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.85s + + Running tests/http_transport.rs (target/debug/deps/http_transport-b487f7015d7f989c) + +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_disconnect_delivers_eof_without_graceful_close ... ok +test websocket_close_allows_graceful_engine_cleanup ... 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-455ac757029487d1) + +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.06s + + Running tests/message_routing.rs (target/debug/deps/message_routing-2f6634bf8501e870) + +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-68237955d6a9d6c6) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-19bc3fcc28c85e9a) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-75175b044a10141c) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test authorization_and_conflict ... ok +test iq_response_requires_expected_sender ... ok +test join_lock_chat_leave ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test nick_invite_destroy_and_local_creation ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test reserved_nickname_blocks_other_users ... ok +test archive_history_and_permissions ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_native.rs (target/debug/deps/muc_native-dd2a34b938a462e6) + +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/pep_discovery.rs (target/debug/deps/pep_discovery-20c6d4b031101804) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s + + Running tests/preauth.rs (target/debug/deps/preauth-1adba20c5e7bdc71) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test concurrent_redemption_has_exactly_one_winner ... 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.06s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-6cf664c184a325ba) + +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-fe86a689c0fe3e49) + +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-2d16475df2cbcbf9) + +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 capability_discovery_replays_and_filters_notifications ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test configure_requires_owner ... ok +test open_model_and_notifications ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... 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 roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... 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.35s + + Running tests/push.rs (target/debug/deps/push-4696fd1434eda878) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-819c2ec87bfa7698) + +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-307ed03439280c4e) + +running 7 tests +test increment_skips_occupied_suffixes_and_preserves_sessions ... ok +test kick_new_leaves_existing_session_unchanged ... ok +test invalid_resume_owner_or_ack_does_not_evict_active_session ... 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-977514f680e30a4e) + +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/storage_accounts.rs (target/debug/deps/storage_accounts-b74657400e2100a6) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test roster_preserves_subscription_and_metadata ... ok +test subscription_handshake_and_cancel ... ok +test bookmarks_private_pep_bidirectional_and_private ... ok +test legacy_bookmarks_respect_host_limit_and_read_over_256 ... ok +test password_change_after_purge_never_recreates_account ... ok +test legacy_migration_clears_source_after_success ... ok +test vcard_pep_conversion_and_avatar ... 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.40s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-954181747a0c2595) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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-a4012b293cfa1d84) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.37s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-4fd51f9ace6103de) + +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.55s + + 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 + + Checking rust-xmpp v0.6.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.80s + Compiling rust-xmpp v0.6.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 23.08s +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: WSS SCRAM-SHA-256 SASL1/SASL2 with GS2 y, absent CB/PLUS, SSDP without CB separator, server signatures, bind and ping +PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback +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 invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 6.467, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.617, + "setup_latency": { + "samples": 500, + "median_ms": 55.521, + "p95_ms": 80.465, + "max_ms": 104.211 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.434, + "p95_ms": 21.835, + "max_ms": 28.842 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.583, + "metrics_before": { + "rss_kib": 6984, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24764, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25300, + "cpu_seconds": 7.55, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25364, + "cpu_seconds": 7.57, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25364, + "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 + } +} + Compiling rust-xmpp v0.6.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.46s + Running tests/csi_native.rs (target/debug/deps/csi_native-eefc40693c54ec61) + +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.05s + + Running tests/muc_native.rs (target/debug/deps/muc_native-dd2a34b938a462e6) + +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.05s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-6cf664c184a325ba) + +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.13s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-fe86a689c0fe3e49) + +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.12s + + Running tests/push.rs (target/debug/deps/push-4696fd1434eda878) + +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-819c2ec87bfa7698) + +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.04s + + Compiling rust-xmpp v0.6.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.98s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-9cdad036fb137095) + +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 1.07s + diff --git a/docs/validation-logs-0.7.0/load.json b/docs/validation-logs-0.7.0/load.json new file mode 100644 index 0000000..ca4faa0 --- /dev/null +++ b/docs/validation-logs-0.7.0/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.064, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.696, + "setup_latency": { + "samples": 500, + "median_ms": 66.672, + "p95_ms": 83.122, + "max_ms": 91.585 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.572, + "p95_ms": 23.767, + "max_ms": 26.397 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.058, + "metrics_before": { + "rss_kib": 7508, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25632, + "cpu_seconds": 1.22, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26264, + "cpu_seconds": 7.95, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26328, + "cpu_seconds": 7.98, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26328, + "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 + } +} diff --git a/docs/validation-logs-0.7.0/verify.txt b/docs/validation-logs-0.7.0/verify.txt new file mode 100644 index 0000000..ddb52fe --- /dev/null +++ b/docs/validation-logs-0.7.0/verify.txt @@ -0,0 +1,535 @@ + Compiling rust-xmpp v0.7.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 19.66s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-94408acd7a0ac611) + +running 24 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_transport::tests::independent_framing ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test sasl::tests::reject_malformed_and_downgrade ... 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 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 sasl::tests::rfc5802_sha1_vector ... 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. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-5eaae65862e46c47) + +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-1c75663c5ae506f4) + +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-035bbe3cb222446b) + +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 append_is_incremental_and_recovers_torn_tail ... ok +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test equal_cursors_and_normalized_query_jids ... ok +test mam_paging_filters_and_forbidden ... ok +test real_archive_sid_and_sender_privacy ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... ok +test zero_page_dates_and_invalid_forms ... ok +test smart_enable_and_message_selection ... 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.05s + + Running tests/auth.rs (target/debug/deps/auth-07519da82829159b) + +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.14s + + Running tests/cli.rs (target/debug/deps/cli-940750580900fbea) + +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.02s + + Running tests/config.rs (target/debug/deps/config-82b0e76391ea092f) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test storage_paths_encode_like_prosody ... ok +test include_cycles_rejected ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test tables_cannot_recurse_forever ... ok +test runtime_capabilities_are_unavailable ... ok +test malformed_values_rejected ... ok +test supplied_config_matches_original_prosody_loader ... ok +test all_formats_round_trip_actual_config ... 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-dd5889487f938bdb) + +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-3f1ca0e0fdaf0c90) + +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-66fa553d6f7053c7) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-d8e65d6e85abde37) + +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.12s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-5961140cd6f418c0) + +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/http_transport.rs (target/debug/deps/http_transport-3aa1f7eba52556d6) + +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_disconnect_delivers_eof_without_graceful_close ... ok +test websocket_close_allows_graceful_engine_cleanup ... 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-a48ab84ef35b8806) + +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/logging.rs (target/debug/deps/logging-47d7193857972196) + +running 8 tests +test rejects_malformed_configuration ... 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 failed_reopen_keeps_all_original_handles ... ok +test native_console_shorthands_are_accepted ... ok +test json_records_and_bounded_ranges ... ok +test cloned_loggers_serialize_records ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/message_routing.rs (target/debug/deps/message_routing-f9d46600407b3493) + +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-1eb80572b618c9db) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-8e86b3b767458721) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-d860f607a708653f) + +running 18 tests +test iq_response_requires_expected_sender ... ok +test configured_lock_timeout_expires_without_owner_submission ... ok +test join_lock_chat_leave ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test authorization_and_conflict ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test archive_history_and_permissions ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-11459f1318ad17b6) + +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/pep_discovery.rs (target/debug/deps/pep_discovery-cd58b8684a06459f) + +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.12s + + Running tests/preauth.rs (target/debug/deps/preauth-8c162eaae149f65c) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok +test concurrent_redemption_has_exactly_one_winner ... ok +test identity_scope_does_not_burn_on_wrong_sender_or_leak_to_prosody ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... 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.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-2d454afd474bb9c6) + +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-5344fbb8f3e05c43) + +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-b3edae88ed37aae5) + +running 18 tests +test modern_bookmarks_notify_legacy_capability ... ok +test native_string_max_items_satisfies_numeric_publish_precondition ... ok +test configure_requires_owner ... ok +test configurable_limits_component_creation_and_publisher ... ok +test capability_discovery_replays_and_filters_notifications ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test open_model_and_notifications ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test reads_prosody_legacy_fixture ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test native_notification_fields_and_retract_policy_are_independent ... ok +test payload_policy_applies_to_subscription_and_caps_replays ... 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.25s + + Running tests/push.rs (target/debug/deps/push-f4ff9c794e3ba53f) + +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 stable_client_registration_and_targeted_notification ... ok +test reenable_cancels_old_errors_and_failure_counts_are_per_account ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-496913b1642f9356) + +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-17de9192f8a764f2) + +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-32232f5f8fb067d5) + +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.61s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-5f62739bd7c74ebb) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test blocklist_enforcement_api ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test purge_account_exact_files_preserves_others_and_extras ... 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 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.35s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-935fba296da8d62f) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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-80687cc90f5936fc) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.16s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-ca5afdc80bbf006f) + +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.18s + + 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 + + Checking rust-xmpp v0.7.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.18s + Compiling rust-xmpp v0.7.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 25.05s +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: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback +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 invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 7.064, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.696, + "setup_latency": { + "samples": 500, + "median_ms": 66.672, + "p95_ms": 83.122, + "max_ms": 91.585 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.572, + "p95_ms": 23.767, + "max_ms": 26.397 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.058, + "metrics_before": { + "rss_kib": 7508, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25632, + "cpu_seconds": 1.22, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26264, + "cpu_seconds": 7.95, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26328, + "cpu_seconds": 7.98, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26328, + "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 + } +} + Compiling rust-xmpp v0.7.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.57s + Running tests/csi_native.rs (target/debug/deps/csi_native-3f1ca0e0fdaf0c90) + +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.05s + + Running tests/muc_native.rs (target/debug/deps/muc_native-11459f1318ad17b6) + +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.05s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-2d454afd474bb9c6) + +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-5344fbb8f3e05c43) + +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.11s + + Running tests/push.rs (target/debug/deps/push-f4ff9c794e3ba53f) + +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-496913b1642f9356) + +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.13s + + Compiling rust-xmpp v0.7.0 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.29s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-5961140cd6f418c0) + +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.77s + diff --git a/docs/validation-logs-0.7.1/cached-auth-baseline.txt b/docs/validation-logs-0.7.1/cached-auth-baseline.txt new file mode 100644 index 0000000..0a2b239 --- /dev/null +++ b/docs/validation-logs-0.7.1/cached-auth-baseline.txt @@ -0,0 +1 @@ +PASS: reproduced 0.7.0 cached Bind2 rejection before SASL diff --git a/docs/validation-logs-0.7.1/cached-auth.txt b/docs/validation-logs-0.7.1/cached-auth.txt new file mode 100644 index 0000000..b610256 --- /dev/null +++ b/docs/validation-logs-0.7.1/cached-auth.txt @@ -0,0 +1,6 @@ +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 diff --git a/docs/validation-logs-0.7.1/gajim.txt b/docs/validation-logs-0.7.1/gajim.txt new file mode 100644 index 0000000..339bb78 --- /dev/null +++ b/docs/validation-logs-0.7.1/gajim.txt @@ -0,0 +1,5 @@ +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 diff --git a/docs/validation-logs-0.7.1/load.json b/docs/validation-logs-0.7.1/load.json new file mode 100644 index 0000000..844a152 --- /dev/null +++ b/docs/validation-logs-0.7.1/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.969, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.842, + "setup_latency": { + "samples": 500, + "median_ms": 72.641, + "p95_ms": 134.723, + "max_ms": 156.502 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.945, + "p95_ms": 22.704, + "max_ms": 26.79 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.825, + "metrics_before": { + "rss_kib": 7284, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25952, + "cpu_seconds": 1.09, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26672, + "cpu_seconds": 8.34, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26736, + "cpu_seconds": 8.38, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26736, + "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 + } +} diff --git a/docs/validation-logs-0.7.1/verify.txt b/docs/validation-logs-0.7.1/verify.txt new file mode 100644 index 0000000..3ba2453 --- /dev/null +++ b/docs/validation-logs-0.7.1/verify.txt @@ -0,0 +1,561 @@ + Compiling rust-xmpp v0.7.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 21.93s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-cb3b5faf47b90be4) + +running 25 tests +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test http_transport::tests::independent_framing ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... 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 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 sasl::tests::rfc5802_sha1_vector ... 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. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.74s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-04dd784ebbdc6a22) + +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-270a654d656f20bc) + +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-74c0c3a2d787e108) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... 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 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.05s + + Running tests/auth.rs (target/debug/deps/auth-15bdf52311e4d125) + +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.18s + + Running tests/cli.rs (target/debug/deps/cli-bd3db818e79c9ba7) + +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-3d3b807f59da864b) + +running 12 tests +test declarations_and_overrides ... ok +test storage_paths_encode_like_prosody ... ok +test component_can_explicitly_enable_global_modules ... ok +test include_cycles_rejected ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_config_matches_original_prosody_loader ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... 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-4621faf9c6106e3c) + +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-bd792c168a73fc16) + +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-9ea0a5b322af4953) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-82313e923ea22366) + +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-a0331520383db8c9) + +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.45s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-4a809f0acb5472cf) + +running 6 tests +test unsupported_security_policy_configuration_returns_explicit_error ... ok +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 security_catalogs_serve_both_versions_and_preserve_requested_target ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-bb64b91ad4c22400) + +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_disconnect_delivers_eof_without_graceful_close ... ok +test websocket_close_allows_graceful_engine_cleanup ... 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.07s + + Running tests/http_upload.rs (target/debug/deps/http_upload-0d7ad267ae1d5e2c) + +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/logging.rs (target/debug/deps/logging-ad40995f295543fc) + +running 10 tests +test json_records_and_bounded_ranges ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test filters_appends_escapes_and_redacts ... ok +test failed_reopen_keeps_all_original_handles ... ok +test native_console_shorthands_are_accepted ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test rejects_malformed_configuration ... ok +test reopen_rotated_files_updates_clones ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... 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-0093c2e215e8e639) + +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-2bb809ce73d21bce) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-e3eb4bb6fd095840) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-d6a4356d70f79304) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test authorization_and_conflict ... ok +test iq_response_requires_expected_sender ... ok +test nick_invite_destroy_and_local_creation ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test join_lock_chat_leave ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test persistence_roundtrip ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test archive_history_and_permissions ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_native.rs (target/debug/deps/muc_native-6501fca139d4ea41) + +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/pep_discovery.rs (target/debug/deps/pep_discovery-8efde2f8f7390fd3) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + + Running tests/preauth.rs (target/debug/deps/preauth-c5a32fecffd69807) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok +test concurrent_redemption_has_exactly_one_winner ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... 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-abc0cfe2a0307998) + +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-e221f58aa7e06c7c) + +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-80f3b89ef1cc628e) + +running 18 tests +test configurable_limits_component_creation_and_publisher ... ok +test native_string_max_items_satisfies_numeric_publish_precondition ... ok +test configure_requires_owner ... ok +test modern_bookmarks_notify_legacy_capability ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test capability_discovery_replays_and_filters_notifications ... ok +test open_model_and_notifications ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test reads_prosody_legacy_fixture ... 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.28s + + Running tests/push.rs (target/debug/deps/push-213e567bc780ee4f) + +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.11s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-78881b51cbfb907f) + +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-2bb513cbfef8c6db) + +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 replacement_revokes_hibernating_resume_and_survives_old_cleanup ... ok +test random_conflicts_get_distinct_resources_for_same_owner ... ok +test kick_new_leaves_existing_session_unchanged ... 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-aeaf27d25390f848) + +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.71s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-71820de6fca29c8b) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test roster_preserves_subscription_and_metadata ... 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.58s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-d2ac6ecb752b8632) + +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-b49894c170e72ed4) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.42s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-7561eb91fa3ea268) + +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.31s + + 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 + + Checking rust-xmpp v0.7.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.87s + Compiling rust-xmpp v0.7.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 25.73s +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: 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 invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 7.969, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.842, + "setup_latency": { + "samples": 500, + "median_ms": 72.641, + "p95_ms": 134.723, + "max_ms": 156.502 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.945, + "p95_ms": 22.704, + "max_ms": 26.79 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.825, + "metrics_before": { + "rss_kib": 7284, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25952, + "cpu_seconds": 1.09, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26672, + "cpu_seconds": 8.34, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26736, + "cpu_seconds": 8.38, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26736, + "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 + } +} + Compiling rust-xmpp v0.7.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.66s + Running tests/csi_native.rs (target/debug/deps/csi_native-bd792c168a73fc16) + +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.05s + + Running tests/muc_native.rs (target/debug/deps/muc_native-6501fca139d4ea41) + +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/preauth_native.rs (target/debug/deps/preauth_native-abc0cfe2a0307998) + +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.13s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-e221f58aa7e06c7c) + +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-213e567bc780ee4f) + +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.00s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-78881b51cbfb907f) + +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.00s + + Compiling rust-xmpp v0.7.1 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.38s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-a0331520383db8c9) + +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 1.07s + diff --git a/docs/validation-logs-0.7.2/baseline-0.7.1.txt b/docs/validation-logs-0.7.2/baseline-0.7.1.txt new file mode 100644 index 0000000..ce8316a --- /dev/null +++ b/docs/validation-logs-0.7.2/baseline-0.7.1.txt @@ -0,0 +1,101 @@ + Compiling rust-xmpp v0.7.1 (/tmp/xmpp071-authbaseline) + Finished `test` profile [unoptimized + debuginfo] target(s) in 13.63s + Running tests/iq_errors.rs (target/debug/deps/iq_errors-9f3d8fea07105697) + +running 2 tests +test server_errors_without_target_remain_unaddressed_and_escape_attributes ... FAILED +test pep_errors_identify_the_account_and_echo_the_request ... FAILED + +failures: + +---- server_errors_without_target_remain_unaddressed_and_escape_attributes stdout ---- + +thread 'server_errors_without_target_remain_unaddressed_and_escape_attributes' (519) panicked at tests/iq_errors.rs:29:9: +assertion `left == right` failed + left: None + right: Some("alice&bob@example.test") + +---- pep_errors_identify_the_account_and_echo_the_request stdout ---- + +thread 'pep_errors_identify_the_account_and_echo_the_request' (518) panicked at tests/iq_errors.rs:9:5: +assertion `left == right` failed + left: None + right: Some("alice@example.test") +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + pep_errors_identify_the_account_and_echo_the_request + server_errors_without_target_remain_unaddressed_and_escape_attributes + +test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +error: test failed, to rerun pass `--test iq_errors` + Blocking waiting for file lock on build directory + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.24s + Running tests/pep_client_interop.rs (target/debug/deps/pep_client_interop-56e0c80e02b9bca7) + +running 3 tests +test publish_options_match_implicit_notification_defaults_on_old_nodes ... FAILED +test missing_own_omemo_bundle_returns_recoverable_item_not_found ... ok +test avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... ok + +failures: + +---- publish_options_match_implicit_notification_defaults_on_old_nodes stdout ---- + +thread 'publish_options_match_implicit_notification_defaults_on_old_nodes' (19) panicked at tests/pep_client_interop.rs:198:5: +[""] +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + publish_options_match_implicit_notification_defaults_on_old_nodes + +test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +error: test failed, to rerun pass `--test pep_client_interop` + Compiling rust-xmpp v0.7.1 (/tmp/xmpp071-authbaseline) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.65s + Running tests/pep_client_interop.rs (target/debug/deps/pep_client_interop-56e0c80e02b9bca7) + +running 1 test +test migrated_native_configuration_can_roundtrip_access_model_edit ... FAILED + +failures: + +---- migrated_native_configuration_can_roundtrip_access_model_edit stdout ---- + +thread 'migrated_native_configuration_can_roundtrip_access_model_edit' (106) panicked at tests/pep_client_interop.rs:241:5: +assertion failed: !get.replies[0].contains("pubsub#payload_type") +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + migrated_native_configuration_can_roundtrip_access_model_edit + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.10s + +error: test failed, to rerun pass `--test pep_client_interop` + Compiling rust-xmpp v0.7.1 (/tmp/xmpp071-authbaseline) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s + Running tests/pep_client_interop.rs (target/debug/deps/pep_client_interop-56e0c80e02b9bca7) + +running 1 test +test migrated_native_configuration_can_roundtrip_access_model_edit ... FAILED + +failures: + +---- migrated_native_configuration_can_roundtrip_access_model_edit stdout ---- + +thread 'migrated_native_configuration_can_roundtrip_access_model_edit' (106) panicked at tests/pep_client_interop.rs:258:5: +[""] +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + migrated_native_configuration_can_roundtrip_access_model_edit + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.11s + +error: test failed, to rerun pass `--test pep_client_interop` diff --git a/docs/validation-logs-0.7.2/load.json b/docs/validation-logs-0.7.2/load.json new file mode 100644 index 0000000..da3d67a --- /dev/null +++ b/docs/validation-logs-0.7.2/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 6.652, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.558, + "setup_latency": { + "samples": 500, + "median_ms": 51.57, + "p95_ms": 70.794, + "max_ms": 90.506 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.511, + "p95_ms": 22.338, + "max_ms": 26.333 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.813, + "metrics_before": { + "rss_kib": 7756, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26100, + "cpu_seconds": 1.05, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26844, + "cpu_seconds": 7.63, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26844, + "cpu_seconds": 7.67, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26844, + "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 + } +} diff --git a/docs/validation-logs-0.7.2/verify.txt b/docs/validation-logs-0.7.2/verify.txt new file mode 100644 index 0000000..2685c8a --- /dev/null +++ b/docs/validation-logs-0.7.2/verify.txt @@ -0,0 +1,584 @@ + Compiling rust-xmpp v0.7.2 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.18s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-8ec155c80b6dfeaf) + +running 26 tests +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::independent_framing ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test server::authorization_tests::pep_diagnostics_exclude_payloads_and_identifiers ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-316204254fe9a06d) + +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-54969f77ebe78f0e) + +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-4f1e3e981d589aa6) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... ok +test real_archive_sid_and_sender_privacy ... 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 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.03s + + Running tests/auth.rs (target/debug/deps/auth-0b0ae2335249fb23) + +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 0.94s + + Running tests/cli.rs (target/debug/deps/cli-094eb9ac60021a27) + +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-35520fafebc2d691) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test include_cycles_rejected ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test runtime_capabilities_are_unavailable ... ok +test malformed_values_rejected ... ok +test storage_paths_encode_like_prosody ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test all_formats_round_trip_actual_config ... ok +test supplied_config_matches_original_prosody_loader ... ok +test loops_are_bounded ... ok + +test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/csi.rs (target/debug/deps/csi-dbcbf6a41b488f87) + +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-571cd230980015ef) + +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-46ceeadd92bb6ee8) + +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-9cb0b0aa245adc83) + +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.09s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-e0152fda4a1323e9) + +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.01s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-dbda475140801d86) + +running 6 tests +test security_catalog_default_is_a_real_label_and_module_can_be_disabled ... ok +test policy_catalog_selects_default_policy_and_maps_display_and_explicit_label ... ok +test security_catalogs_serve_both_versions_and_preserve_requested_target ... ok +test unsupported_security_policy_configuration_returns_explicit_error ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-2975f8ba02c03d6c) + +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-e33fcbbc2ff0311f) + +running 5 tests +test https_endpoint_uses_real_tls ... ok +test percent_escape_case_and_equivalent_url_spelling ... ok +test expired_upload_releases_quota ... ok +test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... 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.07s + + Running tests/iq_errors.rs (target/debug/deps/iq_errors-a8690e04adeeb529) + +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-7d8fd52fcdc4826f) + +running 10 tests +test failed_reopen_keeps_all_original_handles ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test native_console_shorthands_are_accepted ... ok +test rejects_malformed_configuration ... ok +test filters_appends_escapes_and_redacts ... ok +test json_records_and_bounded_ranges ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test reopen_rotated_files_updates_clones ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... 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-4f3df63dddc4fc6f) + +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-5b161dd6682b140f) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-fd6476ada4b03e72) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-38c899e5c6507d75) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test iq_response_requires_expected_sender ... ok +test join_lock_chat_leave ... ok +test authorization_and_conflict ... ok +test nick_invite_destroy_and_local_creation ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test archive_history_and_permissions ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_native.rs (target/debug/deps/muc_native-5fac279d2047e8c2) + +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/pep_client_interop.rs (target/debug/deps/pep_client_interop-5234b43d0a197db2) + +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 avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... ok +test publish_options_match_implicit_notification_defaults_on_old_nodes ... ok +test migrated_native_configuration_can_roundtrip_access_model_edit ... 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.21s + + Running tests/pep_discovery.rs (target/debug/deps/pep_discovery-0117c47dc045796e) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + + Running tests/preauth.rs (target/debug/deps/preauth-c0a9d9956f0660c0) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test concurrent_redemption_has_exactly_one_winner ... 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 local_invitation_updates_both_rosters_without_granting_reverse_subscription ... ok +test valid_token_autoapproves_and_sends_reciprocal_request ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-a9ed19090296a4bb) + +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-d8eeede39db97351) + +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-b6e76f1d9e8343d0) + +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 full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test reads_prosody_legacy_fixture ... ok +test open_model_and_notifications ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test payload_policy_applies_to_subscription_and_caps_replays ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... ok +test subscription_options_disable_events_and_require_subscriber ... 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.28s + + Running tests/push.rs (target/debug/deps/push-5ab8fb3185d73ee1) + +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.10s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-031392d8372a1a25) + +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-6d0e758f141ca6a9) + +running 7 tests +test kick_new_leaves_existing_session_unchanged ... ok +test invalid_resume_owner_or_ack_does_not_evict_active_session ... ok +test increment_skips_occupied_suffixes_and_preserves_sessions ... ok +test random_conflicts_get_distinct_resources_for_same_owner ... ok +test replacement_revokes_hibernating_resume_and_survives_old_cleanup ... 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-d407647416c71ac1) + +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.61s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-1249c1046b2daedb) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test domain_roster_and_block_jid_normalization ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test inbound_subscription_requires_pending_to_grant_to ... 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 purge_account_exact_files_preserves_others_and_extras ... ok +test roster_preserves_subscription_and_metadata ... 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 password_change_after_purge_never_recreates_account ... ok +test vcard_pep_conversion_and_avatar ... 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.53s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-4143c7a6d1f37ff7) + +running 6 tests +test empty_namespace_preserved ... ok +test mixed_binary_roundtrip ... ok +test stanza_roundtrip ... ok +test rejects_cycles_code_and_bad_files ... ok +test path_and_atomic_update ... 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.07s + + Running tests/tls_binding.rs (target/debug/deps/tls_binding-b54da6a3dac30688) + +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.61s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-c3c8506e59bf59bc) + +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.99s + + 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 + + Checking rust-xmpp v0.7.2 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.96s + Compiling rust-xmpp v0.7.2 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 23.74s +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: 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: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 6.652, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.558, + "setup_latency": { + "samples": 500, + "median_ms": 51.57, + "p95_ms": 70.794, + "max_ms": 90.506 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.511, + "p95_ms": 22.338, + "max_ms": 26.333 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.813, + "metrics_before": { + "rss_kib": 7756, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26100, + "cpu_seconds": 1.05, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26844, + "cpu_seconds": 7.63, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26844, + "cpu_seconds": 7.67, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26844, + "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 + } +} + Compiling rust-xmpp v0.7.2 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.57s + Running tests/csi_native.rs (target/debug/deps/csi_native-571cd230980015ef) + +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.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-5fac279d2047e8c2) + +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/preauth_native.rs (target/debug/deps/preauth_native-a9ed19090296a4bb) + +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.15s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-d8eeede39db97351) + +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.12s + + Running tests/push.rs (target/debug/deps/push-5ab8fb3185d73ee1) + +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-031392d8372a1a25) + +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.10s + + Compiling rust-xmpp v0.7.2 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.23s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-e0152fda4a1323e9) + +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.56s + diff --git a/docs/validation-logs-0.7.3/baseline-0.7.2.txt b/docs/validation-logs-0.7.3/baseline-0.7.2.txt new file mode 100644 index 0000000..2daf5c6 --- /dev/null +++ b/docs/validation-logs-0.7.3/baseline-0.7.2.txt @@ -0,0 +1,25 @@ + Compiling rust-xmpp v0.7.2 (/tmp/xmpp072-orderbaseline) + Finished `test` profile [unoptimized + debuginfo] target(s) in 15.25s + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-ed2adfea48c80983) + +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 ... FAILED + +failures: + +---- first_pep_item_is_current_device_list_even_with_retained_old_items stdout ---- + +thread 'first_pep_item_is_current_device_list_even_with_retained_old_items' (463) panicked at tests/pep_item_order.rs:68:9: +assertion `left == right` failed: + left: Some("old") + right: Some("current") +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + first_pep_item_is_current_device_list_even_with_retained_old_items + +test result: FAILED. 0 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.14s + +error: test failed, to rerun pass `--test pep_item_order` diff --git a/docs/validation-logs-0.7.3/dns.json b/docs/validation-logs-0.7.3/dns.json new file mode 100644 index 0000000..ba105c1 --- /dev/null +++ b/docs/validation-logs-0.7.3/dns.json @@ -0,0 +1,16 @@ +[ + { + "name": "wthome.ru", + "type": "DS", + "Status": 0, + "AD": false, + "Answer": null + }, + { + "name": "wthome.ru", + "type": "DNSKEY", + "Status": 0, + "AD": false, + "Answer": null + } +] diff --git a/docs/validation-logs-0.7.3/load.json b/docs/validation-logs-0.7.3/load.json new file mode 100644 index 0000000..c5255f0 --- /dev/null +++ b/docs/validation-logs-0.7.3/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 6.869, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.56, + "setup_latency": { + "samples": 500, + "median_ms": 49.877, + "p95_ms": 77.482, + "max_ms": 85.857 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.502, + "p95_ms": 22.717, + "max_ms": 25.971 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.027, + "metrics_before": { + "rss_kib": 7592, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25772, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26420, + "cpu_seconds": 7.81, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26484, + "cpu_seconds": 7.84, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26484, + "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 + } +} diff --git a/docs/validation-logs-0.7.3/production-summary.json b/docs/validation-logs-0.7.3/production-summary.json new file mode 100644 index 0000000..922b38e --- /dev/null +++ b/docs/validation-logs-0.7.3/production-summary.json @@ -0,0 +1,13 @@ +{ + "version": "0.7.2", + "authorization_successes": 9, + "pubsub_iq_records": 126, + "omemo_iq_results": 41, + "omemo_iq_errors": 0, + "pubsub_error_classes": { + "item-not-found": 4, + "forbidden": 1 + }, + "user_confirmed_force_stop": true, + "caveat": "Original records contain neither item counts nor payload structure; no exact deployed PEP cause established" +} diff --git a/docs/validation-logs-0.7.3/verify.txt b/docs/validation-logs-0.7.3/verify.txt new file mode 100644 index 0000000..188c3f1 --- /dev/null +++ b/docs/validation-logs-0.7.3/verify.txt @@ -0,0 +1,601 @@ + Compiling rust-xmpp v0.7.3 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.51s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-0f663f8738159bba) + +running 27 tests +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test http_transport::tests::independent_framing ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... 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 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 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 sasl::tests::rfc5802_sha1_vector ... 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. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.58s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-f111db137f292507) + +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-1724b74b0c8303d7) + +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-1fad2a650578c1c3) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test equal_cursors_and_normalized_query_jids ... ok +test real_archive_sid_and_sender_privacy ... ok +test smart_enable_and_message_selection ... ok +test zero_page_dates_and_invalid_forms ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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.06s + + Running tests/auth.rs (target/debug/deps/auth-253cdd8ca8b681a5) + +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.03s + + Running tests/cli.rs (target/debug/deps/cli-3b3903d6bbbd0db6) + +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-81c382262d5787ea) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test include_cycles_rejected ... ok +test declarations_and_overrides ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test storage_paths_encode_like_prosody ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_config_matches_original_prosody_loader ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... 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-29a3dad586bedc07) + +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-b80e079dceed1e99) + +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-cdb0faa186811965) + +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-0d219a057693c1cc) + +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.09s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-86baa733ed74593f) + +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.19s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-087e526d304576d6) + +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 unsupported_security_policy_configuration_returns_explicit_error ... ok +test security_catalogs_serve_both_versions_and_preserve_requested_target ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-4ac27aefb7b10d67) + +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_disconnect_delivers_eof_without_graceful_close ... ok +test websocket_close_allows_graceful_engine_cleanup ... 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-8388a7005234b59f) + +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-5fbec3c5e9670c88) + +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-9911e21d602ab0af) + +running 10 tests +test native_console_shorthands_are_accepted ... ok +test rejects_malformed_configuration ... ok +test json_records_and_bounded_ranges ... ok +test filters_appends_escapes_and_redacts ... ok +test failed_reopen_keeps_all_original_handles ... 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 reopen_rotated_files_updates_clones ... 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-6218f6512add553f) + +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-24d58593458fb64a) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-a80bb9e58bf933ab) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-6afe34edd1bdf71a) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test iq_response_requires_expected_sender ... ok +test join_lock_chat_leave ... ok +test authorization_and_conflict ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok +test archive_history_and_permissions ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-09a0b03a209e7a60) + +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/pep_client_interop.rs (target/debug/deps/pep_client_interop-413fe2b7907d0a87) + +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 avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... ok +test native_itemreply_controls_publisher_visibility_in_reads_and_replays ... ok +test migrated_native_configuration_can_roundtrip_access_model_edit ... 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.26s + + Running tests/pep_discovery.rs (target/debug/deps/pep_discovery-a9cecb10c170ccf6) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test node_discovery_keeps_node_identity_errors_and_access_checks ... ok +test account_disco_advertises_pep_without_requiring_a_node ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-ee0388686830a663) + +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.28s + + Running tests/preauth.rs (target/debug/deps/preauth-ba8801cabfc94056) + +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 unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test concurrent_redemption_has_exactly_one_winner ... 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.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-cba4ee3b226e1595) + +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-bcfd8bc2adaa8ad8) + +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-f05a14153b319232) + +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 reads_prosody_legacy_fixture ... ok +test open_model_and_notifications ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test native_notification_fields_and_retract_policy_are_independent ... ok +test payload_policy_applies_to_subscription_and_caps_replays ... 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.25s + + Running tests/push.rs (target/debug/deps/push-1b427f1bc8b33016) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... ok +test stable_client_registration_and_targeted_notification ... ok +test reenable_cancels_old_errors_and_failure_counts_are_per_account ... 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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-a839a83d42b1c66e) + +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-49eaa11cd201a8b9) + +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 random_conflicts_get_distinct_resources_for_same_owner ... ok +test replacement_revokes_hibernating_resume_and_survives_old_cleanup ... ok +test kick_new_leaves_existing_session_unchanged ... 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-8dbe73b1fae9056d) + +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.65s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-1445bba0c39f0315) + +running 20 tests +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test broken_roster_is_not_overwritten ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test blocklist_enforcement_api ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test roster_preserves_subscription_and_metadata ... ok +test subscription_handshake_and_cancel ... ok +test bookmarks_private_pep_bidirectional_and_private ... ok +test legacy_bookmarks_respect_host_limit_and_read_over_256 ... ok +test legacy_migration_clears_source_after_success ... ok +test password_change_after_purge_never_recreates_account ... ok +test vcard_pep_conversion_and_avatar ... 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.55s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-b6f8e0d1a4b7c489) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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-da733d5b850507ca) + +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.18s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-0f35e80a04c03ca0) + +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.61s + + 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 + + Checking rust-xmpp v0.7.3 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.58s + Compiling rust-xmpp v0.7.3 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `release` profile [optimized] target(s) in 23.12s +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: 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: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation +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 +{ + "total_load_wall_seconds": 6.869, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.56, + "setup_latency": { + "samples": 500, + "median_ms": 49.877, + "p95_ms": 77.482, + "max_ms": 85.857 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.502, + "p95_ms": 22.717, + "max_ms": 25.971 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.027, + "metrics_before": { + "rss_kib": 7592, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25772, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26420, + "cpu_seconds": 7.81, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26484, + "cpu_seconds": 7.84, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26484, + "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 + } +} + Compiling rust-xmpp v0.7.3 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.50s + Running tests/csi_native.rs (target/debug/deps/csi_native-b80e079dceed1e99) + +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.06s + + Running tests/muc_native.rs (target/debug/deps/muc_native-09a0b03a209e7a60) + +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.08s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-ee0388686830a663) + +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-cba4ee3b226e1595) + +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-bcfd8bc2adaa8ad8) + +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.14s + + Running tests/push.rs (target/debug/deps/push-1b427f1bc8b33016) + +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-a839a83d42b1c66e) + +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.05s + + Compiling rust-xmpp v0.7.3 (/workspace/scratch/362577cdf2e0/rust-xmpp-next) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.33s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-86baa733ed74593f) + +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.77s + diff --git a/docs/validation-logs-0.7.4/dns.json b/docs/validation-logs-0.7.4/dns.json new file mode 100644 index 0000000..516e8a9 --- /dev/null +++ b/docs/validation-logs-0.7.4/dns.json @@ -0,0 +1,14 @@ +[ + { + "type": "DS", + "Status": 0, + "AD": false, + "Answer": null + }, + { + "type": "DNSKEY", + "Status": 0, + "AD": false, + "Answer": null + } +] diff --git a/docs/validation-logs-0.7.4/load.json b/docs/validation-logs-0.7.4/load.json new file mode 100644 index 0000000..262e0b8 --- /dev/null +++ b/docs/validation-logs-0.7.4/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.856, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.632, + "setup_latency": { + "samples": 500, + "median_ms": 57.745, + "p95_ms": 76.618, + "max_ms": 120.568 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.512, + "p95_ms": 19.865, + "max_ms": 23.436 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.919, + "metrics_before": { + "rss_kib": 7328, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26316, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26872, + "cpu_seconds": 8.19, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26872, + "cpu_seconds": 8.23, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26872, + "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 + } +} diff --git a/docs/validation-logs-0.7.4/production-summary.json b/docs/validation-logs-0.7.4/production-summary.json new file mode 100644 index 0000000..2ee9f6c --- /dev/null +++ b/docs/validation-logs-0.7.4/production-summary.json @@ -0,0 +1,13 @@ +{ + "server_version": "0.7.3", + "json_records": 201, + "own_omemo_iq": 12, + "own_omemo_errors": 0, + "own_omemo_device_xml_counts": [ + 1, + 2, + 6 + ], + "confirmed_browser_origin": "https://conversejs.wthome.ru", + "exact_pep_cause_proven": false +} diff --git a/docs/validation-logs-0.7.4/verify.txt b/docs/validation-logs-0.7.4/verify.txt new file mode 100644 index 0000000..55cc8e9 --- /dev/null +++ b/docs/validation-logs-0.7.4/verify.txt @@ -0,0 +1,615 @@ + Compiling rust-xmpp v0.7.4 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 10.54s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-803cf50cf0e8de10) + +running 28 tests +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test http_transport::tests::independent_framing ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test server::authorization_tests::http_interfaces_follow_secure_override_wildcards_and_global_fallback ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... 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 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-639caac9c9347e9b) + +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-32190fb1abeaf6ce) + +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-a5559b36ae2aa127) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test smart_enable_and_message_selection ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test real_archive_sid_and_sender_privacy ... ok +test equal_cursors_and_normalized_query_jids ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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.03s + + Running tests/auth.rs (target/debug/deps/auth-a9d6041897920545) + +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.00s + + Running tests/cli.rs (target/debug/deps/cli-53d7a1e358e7d369) + +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-4040e6dfb24c249a) + +running 12 tests +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test include_cycles_rejected ... ok +test storage_paths_encode_like_prosody ... ok +test tables_cannot_recurse_forever ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test malformed_values_rejected ... ok +test supplied_config_matches_original_prosody_loader ... ok +test all_formats_round_trip_actual_config ... 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-4ca167ddb91d0fc9) + +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-d041e21e009bbc52) + +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-eadcfca212638c36) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-7da8bc62f8b68cdd) + +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.11s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-7d5f9ba5124f0a69) + +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.56s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-639b09dd1b5f76d6) + +running 6 tests +test security_catalog_default_is_a_real_label_and_module_can_be_disabled ... ok +test policy_catalog_selects_default_policy_and_maps_display_and_explicit_label ... ok +test unsupported_security_policy_configuration_returns_explicit_error ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-1c63f193a0a4af74) + +running 10 tests +test bosh_preserves_namespace_inherited_from_body ... ok +test configured_origin_is_enforced ... 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.07s + + Running tests/http_upload.rs (target/debug/deps/http_upload-1381735c3ebc04db) + +running 5 tests +test https_endpoint_uses_real_tls ... ok +test percent_escape_case_and_equivalent_url_spelling ... ok +test expired_upload_releases_quota ... ok +test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... 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.06s + + Running tests/iq_errors.rs (target/debug/deps/iq_errors-2249c68c2d34c618) + +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-16dea78ec19a30c7) + +running 10 tests +test filters_appends_escapes_and_redacts ... ok +test failed_reopen_keeps_all_original_handles ... ok +test rejects_malformed_configuration ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test json_records_and_bounded_ranges ... ok +test native_console_shorthands_are_accepted ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test reopen_rotated_files_updates_clones ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... 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-ab69d1f2c8c6177b) + +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-a295ef107acaee5c) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-0a686e2e50055022) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-e861064da7929f6d) + +running 18 tests +test multiple_resources_share_nick_and_private_delivery ... ok +test authorization_and_conflict ... ok +test iq_response_requires_expected_sender ... ok +test join_lock_chat_leave ... ok +test configured_lock_timeout_expires_without_owner_submission ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test archive_history_and_permissions ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-fc2050db5077d8e1) + +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/pep_client_interop.rs (target/debug/deps/pep_client_interop-0c293151e8f101ce) + +running 7 tests +test native_component_notification_type_applies_to_events_and_subscription_replay ... ok +test migrated_native_configuration_can_roundtrip_access_model_edit ... ok +test avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... ok +test missing_own_omemo_bundle_returns_recoverable_item_not_found ... ok +test publish_options_match_implicit_notification_defaults_on_old_nodes ... 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.22s + + Running tests/pep_diagnostics.rs (target/debug/deps/pep_diagnostics-20943bf26cdbd41d) + +running 6 tests +test nonempty_xml_can_be_an_empty_android_device_list ... ok +test arbitrary_node_payload_and_wrong_namespace_do_not_leak ... ok +test avatar_classification_does_not_inspect_or_expose_avatar_data ... ok +test only_first_direct_item_is_parsed_and_protocols_are_distinct ... ok +test signed_integer_limits_and_duplicates_match_java_parser ... ok +test payloadless_notification_is_visible_without_identifiers ... 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-d50c3b42eb7e4c23) + +running 4 tests +test standalone_component_does_not_claim_pep_automatic_subscriptions ... ok +test account_discovery_does_not_intercept_server_or_resource_caps ... ok +test account_disco_advertises_pep_without_requiring_a_node ... 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.10s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-ab67e6702f7b8148) + +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-879f01cf0315d06a) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... ok +test concurrent_redemption_has_exactly_one_winner ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... 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.02s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-bc3fe2eaa9595f26) + +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-e39eafdcc3aee56a) + +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-fa3f4fe295d50d65) + +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 full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test reads_prosody_legacy_fixture ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... 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.21s + + Running tests/push.rs (target/debug/deps/push-7169ca8cf36ed529) + +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.09s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-d1d036709248a3eb) + +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-bc36749ef517480a) + +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 random_conflicts_get_distinct_resources_for_same_owner ... ok +test replacement_revokes_hibernating_resume_and_survives_old_cleanup ... 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-481c2d84ef36181f) + +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.61s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-7e80a3e53eb6658b) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test privacy_migration_only_default_deny_jids ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test roster_preserves_subscription_and_metadata ... ok +test subscription_handshake_and_cancel ... ok +test bookmarks_private_pep_bidirectional_and_private ... ok +test legacy_bookmarks_respect_host_limit_and_read_over_256 ... 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.40s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-c3e634841a13e72d) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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.03s + + Running tests/tls_binding.rs (target/debug/deps/tls_binding-411070859d2a479a) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.22s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-6c43ed36f67edd62) + +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.56s + + 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 + + Checking rust-xmpp v0.7.4 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.91s + Compiling rust-xmpp v0.7.4 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `release` profile [optimized] target(s) in 22.72s +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: 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: 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 +{ + "total_load_wall_seconds": 7.856, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.632, + "setup_latency": { + "samples": 500, + "median_ms": 57.745, + "p95_ms": 76.618, + "max_ms": 120.568 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.512, + "p95_ms": 19.865, + "max_ms": 23.436 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.919, + "metrics_before": { + "rss_kib": 7328, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26316, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26872, + "cpu_seconds": 8.19, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26872, + "cpu_seconds": 8.23, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26872, + "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 + } +} + Compiling rust-xmpp v0.7.4 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.54s + Running tests/csi_native.rs (target/debug/deps/csi_native-d041e21e009bbc52) + +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.04s + + Running tests/muc_native.rs (target/debug/deps/muc_native-fc2050db5077d8e1) + +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.07s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-ab67e6702f7b8148) + +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-bc3fe2eaa9595f26) + +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-e39eafdcc3aee56a) + +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.14s + + Running tests/push.rs (target/debug/deps/push-7169ca8cf36ed529) + +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.00s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-d1d036709248a3eb) + +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.08s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-7d5f9ba5124f0a69) + +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.84s + diff --git a/docs/validation-logs-0.7.4/wss.txt b/docs/validation-logs-0.7.4/wss.txt new file mode 100644 index 0000000..4174c97 --- /dev/null +++ b/docs/validation-logs-0.7.4/wss.txt @@ -0,0 +1 @@ +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 diff --git a/docs/validation-logs-0.7.5/load.json b/docs/validation-logs-0.7.5/load.json new file mode 100644 index 0000000..a45c50e --- /dev/null +++ b/docs/validation-logs-0.7.5/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.057, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.598, + "setup_latency": { + "samples": 500, + "median_ms": 54.637, + "p95_ms": 77.295, + "max_ms": 99.823 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.144, + "p95_ms": 24.011, + "max_ms": 26.702 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.178, + "metrics_before": { + "rss_kib": 8016, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26416, + "cpu_seconds": 1.13, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26896, + "cpu_seconds": 8.07, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27024, + "cpu_seconds": 8.12, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27024, + "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 + } +} diff --git a/docs/validation-logs-0.7.5/verify.txt b/docs/validation-logs-0.7.5/verify.txt new file mode 100644 index 0000000..1f3e51c --- /dev/null +++ b/docs/validation-logs-0.7.5/verify.txt @@ -0,0 +1,630 @@ + Compiling rust-xmpp v0.7.5 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 15.76s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-82050128bf39b211) + +running 42 tests +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::literal_pchars_and_percent_encoding_are_supported ... 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::federation_domains_are_dns_names_and_case_insensitive ... ok +test http_config::tests::mounts_inherit_per_app_and_expand_virtual_host ... ok +test http_proxy::tests::config_validation_and_network_boundaries ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test http_config::tests::routes_hosts_and_default_without_overriding_known_hosts ... ok +test http_proxy::tests::empty_native_lua_table_disables_proxy_trust ... 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_proxy::tests::legacy_chain_uses_rightmost_untrusted_and_tls_is_preserved ... ok +test http_proxy::tests::malformed_forwarded_and_unknown_identifiers_are_ignored ... ok +test http_proxy::tests::untrusted_socket_ignores_spoofed_headers ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test http_transport::tests::independent_framing ... ok +test http_transport::tests::configured_mount_host_origin_and_secure_dispatch ... ok +test sasl::tests::reject_malformed_and_downgrade ... 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 sasl::tests::rfc5802_sha1_vector ... 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 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 server::authorization_tests::upload_requires_registered_local_account_or_explicit_remote_access ... ok +test sasl::tests::plus_channel_binding_and_ssdp_are_authenticated ... ok +test sasl::tests::wrong_proof_nonce_binding_and_duplicates ... ok +test admin::tests::real_admin_xml_protocol_over_duplex ... ok + +test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-377432f4975b0ef2) + +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-60ff816f875ce854) + +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-bc184f6b5ab7ce87) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test real_archive_sid_and_sender_privacy ... ok +test equal_cursors_and_normalized_query_jids ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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-c679558ea6cdf277) + +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.07s + + Running tests/cli.rs (target/debug/deps/cli-fc1065c871976ffa) + +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-3e36621125566e58) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test storage_paths_encode_like_prosody ... ok +test include_cycles_rejected ... ok +test declarations_and_overrides ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test tables_cannot_recurse_forever ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test supplied_config_matches_original_prosody_loader ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... 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-832960dd0df3a528) + +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-148f570db31d5dd6) + +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-c806f622a2327333) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-1e4e8fd1f66c50a9) + +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-77d28b70fe7d38c4) + +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.49s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-c46f4981189b1000) + +running 6 tests +test policy_catalog_selects_default_policy_and_maps_display_and_explicit_label ... ok +test unsupported_security_policy_configuration_returns_explicit_error ... ok +test security_catalog_default_is_a_real_label_and_module_can_be_disabled ... ok +test security_catalogs_serve_both_versions_and_preserve_requested_target ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-fbbcad47163197da) + +running 10 tests +test configured_origin_is_enforced ... ok +test transport_paths_and_cors_preflight_match_prosody ... ok +test bosh_preserves_namespace_inherited_from_body ... 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-cfa2cfed4019227c) + +running 5 tests +test https_endpoint_uses_real_tls ... ok +test percent_escape_case_and_equivalent_url_spelling ... ok +test expired_upload_releases_quota ... ok +test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... 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.04s + + Running tests/iq_errors.rs (target/debug/deps/iq_errors-40a75cb8dfb0a40d) + +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-cd29b4331cd89243) + +running 10 tests +test native_console_shorthands_are_accepted ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test filters_appends_escapes_and_redacts ... ok +test json_records_and_bounded_ranges ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... ok +test reopen_rotated_files_updates_clones ... ok +test rejects_malformed_configuration ... ok +test failed_reopen_keeps_all_original_handles ... 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-4a73e1cbdd6e1aa1) + +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-76ed44ab2d599ea5) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... 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.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-37c825cc3ea20c94) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-91f0ad40cc5344f6) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test join_lock_chat_leave ... ok +test iq_response_requires_expected_sender ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test authorization_and_conflict ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test nick_invite_destroy_and_local_creation ... ok +test persistence_roundtrip ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test reserved_nickname_blocks_other_users ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok +test archive_history_and_permissions ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_native.rs (target/debug/deps/muc_native-87e08379f6ca1961) + +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/pep_client_interop.rs (target/debug/deps/pep_client_interop-53e2b6a59b2e16d7) + +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 migrated_native_configuration_can_roundtrip_access_model_edit ... ok +test publish_options_match_implicit_notification_defaults_on_old_nodes ... ok +test native_itemreply_controls_publisher_visibility_in_reads_and_replays ... ok +test avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... 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.21s + + Running tests/pep_diagnostics.rs (target/debug/deps/pep_diagnostics-180e71a5cec61981) + +running 6 tests +test avatar_classification_does_not_inspect_or_expose_avatar_data ... ok +test arbitrary_node_payload_and_wrong_namespace_do_not_leak ... ok +test nonempty_xml_can_be_an_empty_android_device_list ... ok +test payloadless_notification_is_visible_without_identifiers ... ok +test signed_integer_limits_and_duplicates_match_java_parser ... ok +test only_first_direct_item_is_parsed_and_protocols_are_distinct ... 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-4b9db0cc35d29f96) + +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.09s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-81a70c7ff6ca952b) + +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.09s + + Running tests/preauth.rs (target/debug/deps/preauth-227f312ab49756fd) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... ok +test unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue ... ok +test standard_adhoc_command_returns_contact_uri ... 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.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-ba8827b1440a5310) + +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-6a1b402d5da8c99d) + +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-3a9a3b5e39926ded) + +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 open_model_and_notifications ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test reads_prosody_legacy_fixture ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... 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-3cb3ed92683fa31d) + +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 push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... 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.10s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-ce9cf78e309e793d) + +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-b90b8760ce8f25af) + +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-6ea1d2a7b6db7456) + +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.77s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-b5423127cd34d3c1) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test blocklist_enforcement_api ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test privacy_migration_only_default_deny_jids ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test roster_preserves_subscription_and_metadata ... 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-b7bcf905a9de8c93) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_roundtrip ... ok +test path_and_atomic_update ... ok +test concurrent_updates_do_not_lose_entries ... ok +test rejects_cycles_code_and_bad_files ... 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-69b7b40270b8f760) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.38s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-1153986af4597f01) + +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.50s + + 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 + + Checking rust-xmpp v0.7.5 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.19s + Finished `release` profile [optimized] target(s) in 0.17s +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: 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: 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 +{ + "total_load_wall_seconds": 7.057, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.598, + "setup_latency": { + "samples": 500, + "median_ms": 54.637, + "p95_ms": 77.295, + "max_ms": 99.823 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.144, + "p95_ms": 24.011, + "max_ms": 26.702 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.178, + "metrics_before": { + "rss_kib": 8016, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26416, + "cpu_seconds": 1.13, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26896, + "cpu_seconds": 8.07, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27024, + "cpu_seconds": 8.12, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27024, + "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 + } +} + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s + Running tests/csi_native.rs (target/debug/deps/csi_native-148f570db31d5dd6) + +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-87e08379f6ca1961) + +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.04s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-81a70c7ff6ca952b) + +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.11s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-ba8827b1440a5310) + +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-6a1b402d5da8c99d) + +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.11s + + Running tests/push.rs (target/debug/deps/push-3cb3ed92683fa31d) + +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.00s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-ce9cf78e309e793d) + +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 0.94s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.06s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-77d28b70fe7d38c4) + +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 1.02s + diff --git a/docs/validation-logs-0.7.6/load.json b/docs/validation-logs-0.7.6/load.json new file mode 100644 index 0000000..601069a --- /dev/null +++ b/docs/validation-logs-0.7.6/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 9.221, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.859, + "setup_latency": { + "samples": 500, + "median_ms": 78.827, + "p95_ms": 112.823, + "max_ms": 141.228 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 24.812, + "p95_ms": 54.825, + "max_ms": 60.843 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 5.825, + "metrics_before": { + "rss_kib": 7884, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25176, + "cpu_seconds": 1.33, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25836, + "cpu_seconds": 11.24, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25964, + "cpu_seconds": 11.28, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25964, + "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 + } +} diff --git a/docs/validation-logs-0.7.6/verify.txt b/docs/validation-logs-0.7.6/verify.txt new file mode 100644 index 0000000..42dd33a --- /dev/null +++ b/docs/validation-logs-0.7.6/verify.txt @@ -0,0 +1,700 @@ + Compiling rust-xmpp v0.7.6 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 21.15s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-8c63400e5c4c6cfd) + +running 42 tests +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test http_config::tests::external_prefix_and_standard_port ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... 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::literal_pchars_and_percent_encoding_are_supported ... ok +test http_config::tests::mounts_inherit_per_app_and_expand_virtual_host ... ok +test http_proxy::tests::config_validation_and_network_boundaries ... ok +test http_config::tests::invalid_paths_types_urls_and_conflicts_are_errors ... ok +test http_config::tests::routes_hosts_and_default_without_overriding_known_hosts ... ok +test http_proxy::tests::empty_native_lua_table_disables_proxy_trust ... ok +test http_proxy::tests::forwarded_tls_termination_cannot_upgrade_a_plaintext_chain_hop ... ok +test http_proxy::tests::forwarded_mode_stops_at_first_untrusted_hop ... ok +test http_proxy::tests::malformed_forwarded_and_unknown_identifiers_are_ignored ... 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_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test http_transport::tests::independent_framing ... ok +test http_transport::tests::configured_mount_host_origin_and_secure_dispatch ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test sasl::tests::reject_malformed_and_downgrade ... 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 sasl::tests::rfc5802_sha1_vector ... 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 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 sasl::tests::sha256_independent_python_vector ... 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 http_service::tests::upload_internal_mount_is_independent_of_advertised_proxy_prefix ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::wrong_proof_nonce_binding_and_duplicates ... ok +test admin::tests::real_admin_xml_protocol_over_duplex ... ok + +test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.55s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-e4ff7f7ab6900090) + +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-20a24d7888fb3d71) + +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-9a2ce126ac5ca462) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test smart_enable_and_message_selection ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test real_archive_sid_and_sender_privacy ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... ok +test equal_cursors_and_normalized_query_jids ... ok +test mam_paging_filters_and_forbidden ... ok +test zero_page_dates_and_invalid_forms ... 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-1a5b4e31bda494a1) + +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.06s + + Running tests/cli.rs (target/debug/deps/cli-ead5546ecaaea45b) + +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-26cb96cb2c038350) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test storage_paths_encode_like_prosody ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test include_cycles_rejected ... ok +test runtime_capabilities_are_unavailable ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test malformed_values_rejected ... ok +test supplied_config_matches_original_prosody_loader ... ok +test all_formats_round_trip_actual_config ... 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-45840fa56ab4c893) + +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-9982d36a6738b640) + +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-9c772f507c33e3db) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-e9a8073a49956050) + +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.09s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-68c6c256f6cbdf11) + +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.78s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-ffd6caccc8bddf27) + +running 6 tests +test security_catalog_default_is_a_real_label_and_module_can_be_disabled ... ok +test policy_catalog_selects_default_policy_and_maps_display_and_explicit_label ... ok +test unsupported_security_policy_configuration_returns_explicit_error ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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_transport.rs (target/debug/deps/http_transport-b2ef3139821aa520) + +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-9133f9f24f096c3f) + +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-ff07c05778936a52) + +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-4dc010295ba29838) + +running 10 tests +test native_console_shorthands_are_accepted ... ok +test rejects_malformed_configuration ... ok +test json_records_and_bounded_ranges ... ok +test filters_appends_escapes_and_redacts ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test reopen_rotated_files_updates_clones ... ok +test failed_reopen_keeps_all_original_handles ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... 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-bddf29ab2acbf3fc) + +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-1307110d1c3f8fb5) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-468aee0482b49686) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_extensions.rs (target/debug/deps/muc_extensions-c626cbbb5a6cc01e) + +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 restricted_private_messages_allow_self_and_optional_moderators ... ok +test language_config_sections_discovery_and_native_affiliation_hats ... ok +test mam_all_rooms_presence_logging_public_queries_and_optional_totals ... ok +test large_optional_history_does_not_prevent_checkpointing_membership ... ok +test recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims ... ok +test offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames ... 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.02s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-bb84792daf8fea66) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... ok +test iq_response_requires_expected_sender ... ok +test authorization_and_conflict ... ok +test join_lock_chat_leave ... ok +test multiple_resources_share_nick_and_private_delivery ... ok +test nick_invite_destroy_and_local_creation ... ok +test occupant_id_is_stable_and_spoofed_ids_removed ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test reserved_nickname_blocks_other_users ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test archive_history_and_permissions ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_history.rs (target/debug/deps/muc_history-fc6ad14e9eb5f6d8) + +running 10 tests +test only_direct_history_children_are_parsed ... ok +test capacity_changes_trim_and_zero_disables_history ... ok +test maxchars_counts_transmitted_utf8_bytes_including_address_and_delay ... ok +test explicit_count_and_zero_limit ... ok +test replay_carries_room_delay_and_recipient ... ok +test snapshot_restores_last_message_without_adding_another_delay ... ok +test ordinary_history_is_bounded_and_returns_oldest_first ... ok +test default_count_applies_only_without_valid_explicit_constraints ... ok +test subjects_and_hints_follow_native_history_eligibility ... ok +test since_and_seconds_use_stricter_cutoff_inclusive ... 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-40aa33dbad2786fc) + +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_recovery.rs (target/debug/deps/muc_recovery-a8afb3d5aa0be41e) + +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_checkpoint_roundtrip_filters_sensitive_extensions ... ok +test native_last_message_roundtrip_preserves_namespaces_and_rejects_other_rooms ... 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-351dd2f053066e1f) + +running 3 tests +test changed_configuration_invalidates_auto_recovery_without_disclosing_password ... ok +test unrelated_disconnect_does_not_delete_dormant_temporary_room ... 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.02s + + Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-ffac265d1eb5376e) + +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-0f72705ba5ce6285) + +running 7 tests +test native_component_notification_type_applies_to_events_and_subscription_replay ... ok +test migrated_native_configuration_can_roundtrip_access_model_edit ... ok +test missing_own_omemo_bundle_returns_recoverable_item_not_found ... ok +test publish_options_match_implicit_notification_defaults_on_old_nodes ... 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.20s + + Running tests/pep_diagnostics.rs (target/debug/deps/pep_diagnostics-17af0581294ab3be) + +running 6 tests +test nonempty_xml_can_be_an_empty_android_device_list ... ok +test only_first_direct_item_is_parsed_and_protocols_are_distinct ... ok +test payloadless_notification_is_visible_without_identifiers ... ok +test arbitrary_node_payload_and_wrong_namespace_do_not_leak ... ok +test avatar_classification_does_not_inspect_or_expose_avatar_data ... 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-a11c796f4debeb08) + +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.10s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-5f051a5d54283beb) + +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-c43f69e946965b00) + +running 10 tests +test duplicate_or_wrong_namespace_tokens_do_not_qualify ... 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 standard_adhoc_command_returns_contact_uri ... ok +test unknown_client_token_is_preserved_on_remote_and_pending_delivery ... ok +test concurrent_redemption_has_exactly_one_winner ... 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.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-7242a9a43fba03b4) + +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-92af6f31c1e48f07) + +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-9fba800780f2177c) + +running 18 tests +test modern_bookmarks_notify_legacy_capability ... ok +test configurable_limits_component_creation_and_publisher ... ok +test native_string_max_items_satisfies_numeric_publish_precondition ... ok +test configure_requires_owner ... ok +test capability_discovery_replays_and_filters_notifications ... ok +test open_model_and_notifications ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test reads_prosody_legacy_fixture ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... 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.27s + + Running tests/push.rs (target/debug/deps/push-97e7d520b80871ab) + +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 stable_client_registration_and_targeted_notification ... ok +test reenable_cancels_old_errors_and_failure_counts_are_per_account ... 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-c6b965c639c64507) + +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-3f3b5cb4cb4d5260) + +running 7 tests +test invalid_resume_owner_or_ack_does_not_evict_active_session ... ok +test kick_new_leaves_existing_session_unchanged ... ok +test increment_skips_occupied_suffixes_and_preserves_sessions ... 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-045ffe60552069b1) + +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.61s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-43492690c1f15dbb) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test blocklist_enforcement_api ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... 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 purge_account_exact_files_preserves_others_and_extras ... ok +test roster_preserves_subscription_and_metadata ... 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.35s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-c0d62dc92db88555) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_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-5796569f42fd65a0) + +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.17s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-e0c91961b4cf0d80) + +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.41s + + 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 + + Checking rust-xmpp v0.7.6 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.30s + Compiling rust-xmpp v0.7.6 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `release` profile [optimized] target(s) in 26.69s +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: 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: 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 +{ + "total_load_wall_seconds": 9.221, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.859, + "setup_latency": { + "samples": 500, + "median_ms": 78.827, + "p95_ms": 112.823, + "max_ms": 141.228 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 24.812, + "p95_ms": 54.825, + "max_ms": 60.843 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 5.825, + "metrics_before": { + "rss_kib": 7884, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25176, + "cpu_seconds": 1.33, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25836, + "cpu_seconds": 11.24, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25964, + "cpu_seconds": 11.28, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25964, + "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 + } +} + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.10s + Running tests/csi_native.rs (target/debug/deps/csi_native-9982d36a6738b640) + +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.03s + + Running tests/muc_native.rs (target/debug/deps/muc_native-40aa33dbad2786fc) + +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.05s + + Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-ffac265d1eb5376e) + +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-5f051a5d54283beb) + +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.12s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-7242a9a43fba03b4) + +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.15s + + Running tests/pubsub_native.rs (target/debug/deps/pubsub_native-92af6f31c1e48f07) + +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.12s + + Running tests/push.rs (target/debug/deps/push-97e7d520b80871ab) + +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-c6b965c639c64507) + +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.06s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-68c6c256f6cbdf11) + +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.89s + diff --git a/docs/validation-logs-0.7.7/load.json b/docs/validation-logs-0.7.7/load.json new file mode 100644 index 0000000..450e7ef --- /dev/null +++ b/docs/validation-logs-0.7.7/load.json @@ -0,0 +1,54 @@ +{ + "total_load_wall_seconds": 7.447, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.653, + "setup_latency": { + "samples": 500, + "median_ms": 57.419, + "p95_ms": 87.729, + "max_ms": 107.7 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.618, + "p95_ms": 19.347, + "max_ms": 30.719 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.483, + "metrics_before": { + "rss_kib": 7792, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26488, + "cpu_seconds": 1.17, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 27100, + "cpu_seconds": 8.93, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27228, + "cpu_seconds": 8.95, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27228, + "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 + } +} diff --git a/docs/validation-logs-0.7.7/verify.txt b/docs/validation-logs-0.7.7/verify.txt new file mode 100644 index 0000000..6324ecb --- /dev/null +++ b/docs/validation-logs-0.7.7/verify.txt @@ -0,0 +1,710 @@ + Compiling rust-xmpp v0.7.7 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `test` profile [unoptimized + debuginfo] target(s) in 19.99s + Running unittests src/lib.rs (target/debug/deps/rust_xmpp-150c76f4af45a245) + +running 42 tests +test http_config::tests::external_prefix_and_standard_port ... ok +test federation::tests::federation_domains_are_dns_names_and_case_insensitive ... ok +test http_config::tests::literal_pchars_and_percent_encoding_are_supported ... ok +test federation::tests::starttls_must_be_an_immediate_stream_feature ... ok +test http_config::tests::mounts_inherit_per_app_and_expand_virtual_host ... ok +test federation::tests::external_must_be_in_the_advertised_mechanisms ... ok +test federation::tests::stream_restarts_compare_canonical_domains ... ok +test http_config::tests::invalid_paths_types_urls_and_conflicts_are_errors ... ok +test http_proxy::tests::empty_native_lua_table_disables_proxy_trust ... ok +test http_proxy::tests::config_validation_and_network_boundaries ... ok +test http_proxy::tests::forwarded_mode_stops_at_first_untrusted_hop ... ok +test http_proxy::tests::legacy_chain_uses_rightmost_untrusted_and_tls_is_preserved ... ok +test http_config::tests::routes_hosts_and_default_without_overriding_known_hosts ... ok +test http_proxy::tests::forwarded_tls_termination_cannot_upgrade_a_plaintext_chain_hop ... ok +test http_proxy::tests::malformed_forwarded_and_unknown_identifiers_are_ignored ... ok +test http_proxy::tests::untrusted_socket_ignores_spoofed_headers ... ok +test http_transport::tests::bosh_processing_capacity_is_bounded ... ok +test logging::tests::console_aliases_share_one_sink_with_combined_levels ... ok +test http_transport::tests::independent_framing ... ok +test http_transport::tests::websocket_preserves_inherited_namespaces_and_spaced_declarations ... ok +test sasl::tests::reject_malformed_and_downgrade ... ok +test http_transport::tests::configured_mount_host_origin_and_secure_dispatch ... 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_internal_mount_is_independent_of_advertised_proxy_prefix ... 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 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 sasl::tests::rfc5802_sha1_vector ... ok +test http_service::tests::abandoned_slots_release_quota_without_pruning_existing_or_active_files ... ok +test sasl::tests::sha256_independent_python_vector ... 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. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.53s + + Running unittests src/main.rs (target/debug/deps/rust_xmpp-445febd27e8f3cf7) + +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-5287dac92abb3b7c) + +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-8f0bd6e0e03357c6) + +running 14 tests +test archive_larger_than_128mib_streams ... ignored, large-file scalability regression; run explicitly +test mam_preferences_persist ... ok +test append_is_incremental_and_recovers_torn_tail ... ok +test legacy_records_binary_cache_and_roundtrip ... ok +test corrupt_archive_reports_server_error_and_namespace_stripping_is_precise ... ok +test real_archive_sid_and_sender_privacy ... ok +test archive_roundtrip_preserves_default_namespace_resets ... ok +test smart_enable_and_message_selection ... ok +test equal_cursors_and_normalized_query_jids ... ok +test zero_page_dates_and_invalid_forms ... ok +test muc_archive_filters_stanza_kind_instead_of_sender_jid ... 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.03s + + Running tests/auth.rs (target/debug/deps/auth-b5d3cee551ae4e8e) + +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 0.94s + + Running tests/cli.rs (target/debug/deps/cli-0c6f4cc83192a942) + +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-a81a5227876e2b38) + +running 12 tests +test component_can_explicitly_enable_global_modules ... ok +test declarations_and_overrides ... ok +test include_cycles_rejected ... ok +test storage_paths_encode_like_prosody ... ok +test include_has_own_global_scope_and_parent_scope_is_restored ... ok +test tables_cannot_recurse_forever ... ok +test supplied_configuration_scopes_arithmetic_and_modules ... ok +test runtime_capabilities_are_unavailable ... ok +test supplied_config_matches_original_prosody_loader ... ok +test malformed_values_rejected ... ok +test all_formats_round_trip_actual_config ... 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-8168f6315350ab36) + +running 2 tests +test native_importance_rules ... ok +test bounded_queue_flush_delay_activity_and_grace ... 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-080e448c5a608c9b) + +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-91d6e5d72a8ede02) + +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.01s + + Running tests/debug_legacy.rs (target/debug/deps/debug_legacy-3f9bfb057e4fad24) + +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.08s + + Running tests/federation_tls.rs (target/debug/deps/federation_tls-62c6da8449b77725) + +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.37s + + Running tests/gajim_features.rs (target/debug/deps/gajim_features-47e8a08797238552) + +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 security_catalogs_serve_both_versions_and_preserve_requested_target ... ok +test unsupported_security_policy_configuration_returns_explicit_error ... ok +test actual_example_policy_configuration_exposes_classifications_as_catalog_labels ... 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-189784aeccb181fe) + +running 3 tests +test bad_advertisement_types_and_route_collisions_are_rejected ... ok +test native_link_order_schemes_and_loaded_module_gates ... ok +test public_prefix_and_host_paths_inherit_independently ... 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-1330a033e594f703) + +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-64a16f53335a61be) + +running 5 tests +test https_endpoint_uses_real_tls ... ok +test percent_escape_case_and_equivalent_url_spelling ... ok +test expired_upload_releases_quota ... ok +test persisted_prosody_archive_and_hs256_slot_survive_without_sidecar ... 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-bf0c5f383ecb8f28) + +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-87b997807942092b) + +running 10 tests +test native_console_shorthands_are_accepted ... ok +test json_records_and_bounded_ranges ... ok +test failed_reopen_keeps_all_original_handles ... ok +test filters_appends_escapes_and_redacts ... ok +test explicit_sink_levels_select_exactly_and_deduplicate ... ok +test overlapping_shorthand_destinations_receive_each_record_once ... ok +test reopen_rotated_files_updates_clones ... ok +test shared_sink_unions_levels_and_preserves_distinct_destinations ... ok +test rejects_malformed_configuration ... 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-3fb91c46e6950055) + +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-f2c4eb5408549247) + +running 3 tests +test moderators_cannot_kick_or_demote_other_moderators ... ok +test nested_affiliations_do_not_fall_back_to_obsolete_flat_entries ... ok +test binary_occupant_id_salt_survives_saves_and_restarts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/muc_domain_bans.rs (target/debug/deps/muc_domain_bans-858c89ddd7af27c8) + +running 2 tests +test applying_domain_ban_removes_remote_occupants_and_blocks_rejoin ... ok +test native_domain_bans_block_remote_users_but_allow_explicit_members ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/muc_extensions.rs (target/debug/deps/muc_extensions-c8a4104b45c9ae10) + +running 10 tests +test broadcast_roles_hide_visitors_and_configuration_announces_transition ... ok +test disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted ... ok +test messages_cannot_spoof_room_identity_hats_or_occupant_identifiers ... ok +test restricted_private_messages_allow_self_and_optional_moderators ... ok +test large_optional_history_does_not_prevent_checkpointing_membership ... ok +test language_config_sections_discovery_and_native_affiliation_hats ... ok +test offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames ... ok +test recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims ... ok +test mam_all_rooms_presence_logging_public_queries_and_optional_totals ... 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.02s + + Running tests/muc_flow.rs (target/debug/deps/muc_flow-757d7a2e964609c2) + +running 18 tests +test configured_lock_timeout_expires_without_owner_submission ... 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 iq_response_requires_expected_sender ... ok +test restored_temporary_room_is_deleted_after_last_disconnect ... ok +test persistence_roundtrip ... ok +test switching_to_members_only_removes_unaffiliated_users ... ok +test room_vcard_owner_only_and_avatar_presence ... ok +test visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice ... ok +test persistent_destroy_creates_prosody_tombstone ... ok +test preserves_unknown_binary_mixed_room_fields ... ok +test reserved_nickname_blocks_other_users ... ok +test maintenance_expires_idle_archives_and_tombstones ... ok +test archive_history_and_permissions ... ok +test native_muc_archive_type_and_anonymity_are_preserved ... ok + +test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/muc_history.rs (target/debug/deps/muc_history-6e21802bc9b753c8) + +running 10 tests +test only_direct_history_children_are_parsed ... ok +test replay_carries_room_delay_and_recipient ... ok +test maxchars_counts_transmitted_utf8_bytes_including_address_and_delay ... ok +test default_count_applies_only_without_valid_explicit_constraints ... ok +test ordinary_history_is_bounded_and_returns_oldest_first ... ok +test explicit_count_and_zero_limit ... ok +test subjects_and_hints_follow_native_history_eligibility ... ok +test since_and_seconds_use_stricter_cutoff_inclusive ... ok +test capacity_changes_trim_and_zero_disables_history ... ok +test snapshot_restores_last_message_without_adding_another_delay ... 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-0f9f1eb3dd1ce7c0) + +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_recovery.rs (target/debug/deps/muc_recovery-76fa05b7cd18a364) + +running 7 tests +test claim_is_one_time_and_does_not_change_untrusted_role ... ok +test recovery_claim_requires_exact_authenticated_resource_and_expires ... ok +test pending_snapshot_keeps_original_checkpoint_time ... ok +test native_checkpoint_roundtrip_filters_sensitive_extensions ... ok +test native_last_message_roundtrip_preserves_namespaces_and_rejects_other_rooms ... 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-9d1edf924d87cc6c) + +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.02s + + Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-cc7f8932b8e82b29) + +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-e23d4cbb223d6fa6) + +running 7 tests +test native_component_notification_type_applies_to_events_and_subscription_replay ... ok +test migrated_native_configuration_can_roundtrip_access_model_edit ... ok +test native_itemreply_controls_publisher_visibility_in_reads_and_replays ... ok +test publish_options_match_implicit_notification_defaults_on_old_nodes ... ok +test missing_own_omemo_bundle_returns_recoverable_item_not_found ... ok +test avatar_and_omemo_publish_options_support_readback_and_caps_notifications ... 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.23s + + Running tests/pep_diagnostics.rs (target/debug/deps/pep_diagnostics-b8a1b78b0697f49a) + +running 6 tests +test avatar_classification_does_not_inspect_or_expose_avatar_data ... ok +test arbitrary_node_payload_and_wrong_namespace_do_not_leak ... ok +test payloadless_notification_is_visible_without_identifiers ... ok +test signed_integer_limits_and_duplicates_match_java_parser ... ok +test nonempty_xml_can_be_an_empty_android_device_list ... ok +test only_first_direct_item_is_parsed_and_protocols_are_distinct ... 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-87093e55b5cdcffd) + +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.11s + + Running tests/pep_item_order.rs (target/debug/deps/pep_item_order-311710a093a3504a) + +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.09s + + Running tests/preauth.rs (target/debug/deps/preauth-5c67ceb36bbdd6fc) + +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 concurrent_redemption_has_exactly_one_winner ... ok +test prosody_numeric_expiry_is_consumed_and_other_entries_preserved ... 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 local_invitation_updates_both_rosters_without_granting_reverse_subscription ... ok +test valid_token_autoapproves_and_sends_reciprocal_request ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-6c8c8c05f739b311) + +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-43a81c6bf159463d) + +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-112cd46e3666e5b4) + +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 capability_discovery_replays_and_filters_notifications ... ok +test open_model_and_notifications ... ok +test configure_requires_owner ... ok +test nonpersistent_items_stay_in_memory_and_can_be_made_persistent ... ok +test reads_prosody_legacy_fixture ... ok +test full_jid_block_applies_to_reads_subscription_replays_and_events ... ok +test publish_options_conflict_is_recoverable_and_preserves_item ... ok +test pep_persists_namespaces_and_enforces_owner ... ok +test roster_presence_access_and_foreign_subscription_denied ... ok +test remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending ... ok +test remote_caps_replay_and_open_node_privacy_revoke ... ok +test subscription_options_disable_events_and_require_subscriber ... ok +test native_notification_fields_and_retract_policy_are_independent ... ok +test payload_policy_applies_to_subscription_and_caps_replays ... 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.29s + + Running tests/push.rs (target/debug/deps/push-0af9a738015fbba0) + +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 stable_client_registration_and_targeted_notification ... ok +test push_summary_never_copies_foreign_namespace_body ... ok +test queue_coalesces_and_importance_matches_native ... ok +test reenable_cancels_old_errors_and_failure_counts_are_per_account ... 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.11s + + Running tests/reference_prosody.rs (target/debug/deps/reference_prosody-8d33b6d7bd95b280) + +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-3e8f1493eee6bb59) + +running 7 tests +test increment_skips_occupied_suffixes_and_preserves_sessions ... ok +test kick_new_leaves_existing_session_unchanged ... ok +test invalid_resume_owner_or_ack_does_not_evict_active_session ... 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-63a83f4b84b54046) + +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.70s + + Running tests/storage_accounts.rs (target/debug/deps/storage_accounts-23beb1e05ae247b8) + +running 20 tests +test broken_roster_is_not_overwritten ... ok +test account_purge_refuses_symlink_store_without_touching_other_files ... ok +test inbound_subscription_requires_pending_to_grant_to ... ok +test malformed_legacy_vcard_and_private_bookmarks_rejected ... ok +test blocklist_enforcement_api ... ok +test malformed_roster_iq_never_mutates ... ok +test domain_roster_and_block_jid_normalization ... ok +test private_and_vcard_authorization ... ok +test privacy_migration_only_default_deny_jids ... ok +test reads_timestamp_blocklist_and_keeps_metadata ... ok +test purge_account_exact_files_preserves_others_and_extras ... ok +test roster_empty_name_and_foreign_group_ignored ... ok +test roster_preserves_subscription_and_metadata ... 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 vcard_pep_conversion_and_avatar ... ok +test legacy_migration_clears_source_after_success ... 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.57s + + Running tests/storage_compat.rs (target/debug/deps/storage_compat-e102eb9107cec0ed) + +running 6 tests +test empty_namespace_preserved ... ok +test stanza_roundtrip ... ok +test mixed_binary_roundtrip ... ok +test path_and_atomic_update ... ok +test concurrent_updates_do_not_lose_entries ... ok +test rejects_cycles_code_and_bad_files ... 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-61e2a6761426215d) + +running 4 tests +test unknown_and_eddsa_algorithms_fail_closed ... ok +test malformed_der_and_pss_parameters_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.18s + + Running tests/tls_plus_network.rs (target/debug/deps/tls_plus_network-5bc7ff0baa3c53ce) + +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.20s + + 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 + + Checking rust-xmpp v0.7.7 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.82s + Compiling rust-xmpp v0.7.7 (/workspace/scratch/362577cdf2e0/recovered/rust-xmpp) + Finished `release` profile [optimized] target(s) in 27.17s +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: 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 +{ + "total_load_wall_seconds": 7.447, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.653, + "setup_latency": { + "samples": 500, + "median_ms": 57.419, + "p95_ms": 87.729, + "max_ms": 107.7 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.618, + "p95_ms": 19.347, + "max_ms": 30.719 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.483, + "metrics_before": { + "rss_kib": 7792, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26488, + "cpu_seconds": 1.17, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 27100, + "cpu_seconds": 8.93, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27228, + "cpu_seconds": 8.95, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27228, + "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 + } +} + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s + Running tests/csi_native.rs (target/debug/deps/csi_native-080e448c5a608c9b) + +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-0f9f1eb3dd1ce7c0) + +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.05s + + Running tests/muc_state_native.rs (target/debug/deps/muc_state_native-cc7f8932b8e82b29) + +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-311710a093a3504a) + +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.12s + + Running tests/preauth_native.rs (target/debug/deps/preauth_native-6c8c8c05f739b311) + +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-43a81c6bf159463d) + +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.12s + + Running tests/push.rs (target/debug/deps/push-0af9a738015fbba0) + +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-8d33b6d7bd95b280) + +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.06s + + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.09s + Running tests/federation_tls.rs (target/debug/deps/federation_tls-62c6da8449b77725) + +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 1.04s + diff --git a/docs/validation-results-0.3.0.json b/docs/validation-results-0.3.0.json new file mode 100644 index 0000000..d88e0ca --- /dev/null +++ b/docs/validation-results-0.3.0.json @@ -0,0 +1,79 @@ +{ + "version": "0.3.0", + "date_utc": "2026-10-16", + "rustc": "1.99.1 (58cf8f9c3 2026-10-13)", + "ordinary_tests": { + "passed": 123, + "failed": 0, + "ignored": 6 + }, + "additional_native_tests": 4, + "additional_large_archive_tests": 1, + "clippy_all_targets": "passed_without_warnings", + "fmt": "passed", + "release_build": "passed", + "network": { + "tcp_modules": "passed", + "sigterm_shutdown": "passed", + "scram_sha1_sasl1_sasl2_ssdp": "passed", + "scram_sha256_sasl1_sasl2_ssdp": "passed", + "https_wss_bosh_upload": "passed", + "mutual_tls_federation_and_pool": "passed" + }, + "load_smoke": { + "total_load_wall_seconds": 2.738, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.604, + "setup_latency": { + "samples": 500, + "median_ms": 53.776, + "p95_ms": 80.986, + "max_ms": 100.219 + }, + "ping_rounds": 3, + "ping_round_trip": { + "samples": 1500, + "median_ms": 12.512, + "p95_ms": 15.492, + "max_ms": 19.709 + }, + "messages_sent": 500, + "messages_received": 500, + "message_batch_seconds": 0.025, + "metrics_before": { + "rss_kib": 7712, + "cpu_seconds": 0.0, + "fd_count": 10 + }, + "metrics_connected": { + "rss_kib": 24268, + "cpu_seconds": 0.96, + "fd_count": 510 + }, + "metrics_after_workload": { + "rss_kib": 24288, + "cpu_seconds": 1.27, + "fd_count": 510 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 24352, + "cpu_seconds": 1.3, + "fd_count": 10 + }, + "sampled_peak_rss_kib": 24352, + "fd_delta_after_disconnect": 0, + "failures": [], + "scope": "One loopback host, one account with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks." + }, + "not_validated": [ + "target Docker runtime", + "AF_UNIX binding in this restricted environment", + "real client applications and push gateway", + "production data and rollback", + "10GiB upload", + "long-duration load" + ], + "full_prosody_replacement": false +} diff --git a/docs/validation-results-0.4.0.json b/docs/validation-results-0.4.0.json new file mode 100644 index 0000000..914bdf0 --- /dev/null +++ b/docs/validation-results-0.4.0.json @@ -0,0 +1,136 @@ +{ + "version": "0.4.0", + "date_utc": "2026-10-20", + "rustc": "1.99.1 (58cf8f9c3 2026-10-13)", + "ordinary_tests": { + "passed": 148, + "failed": 0, + "ignored": 8 + }, + "additional_native_tests": 5, + "additional_large_archive_tests": 1, + "additional_native_federation_tests": 1, + "clippy_all_targets": "passed_without_warnings", + "fmt": "passed", + "release_build": "passed", + "network": { + "tcp_modules": "passed", + "sigterm_shutdown": "passed", + "scram_sha1_sasl1_sasl2_ssdp": "passed", + "scram_sha256_sasl1_sasl2_ssdp": "passed", + "https_wss_bosh_upload": "passed", + "sasl2_bind2": "passed", + "federation_rust_to_native_prosody_and_back": "passed", + "offline_hints_and_resource_fallback": "passed", + "bosh_cancelled_http_rid_retry": "passed" + }, + "load": { + "total_load_wall_seconds": 8.488, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.724, + "setup_latency": { + "samples": 500, + "median_ms": 60.052, + "p95_ms": 148.061, + "max_ms": 159.717 + }, + "ping_rounds": 100, + "ping_round_trip": { + "samples": 50000, + "median_ms": 15.0, + "p95_ms": 24.286, + "max_ms": 41.943 + }, + "accounts": 100, + "message_rounds": 100, + "messages_sent": 50000, + "messages_received": 50000, + "message_batch_seconds": 3.0, + "metrics_before": { + "rss_kib": 6836, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 23792, + "cpu_seconds": 1.04, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 24440, + "cpu_seconds": 21.55, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 24632, + "cpu_seconds": 21.58, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 24632, + "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." + }, + "limitations": { + "unix_admin_socket": "environment denied AF_UNIX bind with Operation not permitted; explicit ignored-test attempt failed", + "docker": "not available in development environment", + "real_clients_push_production_data": "not tested" + }, + "load_with_mam": { + "total_load_wall_seconds": 9.911, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.737, + "setup_latency": { + "samples": 500, + "median_ms": 62.469, + "p95_ms": 142.821, + "max_ms": 156.854 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 15.413, + "p95_ms": 23.158, + "max_ms": 26.824 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 6.83, + "metrics_before": { + "rss_kib": 7128, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24936, + "cpu_seconds": 1.01, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25752, + "cpu_seconds": 8.79, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25816, + "cpu_seconds": 8.81, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25816, + "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 + } + }, + "release_binary_sha256": "b19b85ef5040cf754203cd8c63db3dc7f81e5801684be2002de44a3fbc2e6441" +} diff --git a/docs/validation-results-0.5.0.json b/docs/validation-results-0.5.0.json new file mode 100644 index 0000000..3db439a --- /dev/null +++ b/docs/validation-results-0.5.0.json @@ -0,0 +1,94 @@ +{ + "version": "0.5.0", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 162, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "large_archive_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "features": { + "pep_account_discovery_monal_predicate": "passed", + "push_account_discovery_independent_of_pep": "passed", + "push_enable_options_disable": "passed", + "preauth_tls_mutual_roster_and_single_use": "passed", + "unknown_preauth_pending_offline_replay": "passed", + "native_invitation_token_roundtrip": "passed" + }, + "network": { + "tcp_modules_and_sigterm": "passed", + "https_wss_bosh_upload": "passed", + "scram_sha1_sha256_sasl1_sasl2": "passed", + "bind2": "passed", + "native_prosody_federation": "passed" + }, + "load": { + "total_load_wall_seconds": 7.608, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.593, + "setup_latency": { + "samples": 500, + "median_ms": 51.913, + "p95_ms": 87.632, + "max_ms": 114.769 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 16.334, + "p95_ms": 32.182, + "max_ms": 42.674 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.616, + "metrics_before": { + "rss_kib": 6716, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24908, + "cpu_seconds": 1.05, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25604, + "cpu_seconds": 7.31, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25668, + "cpu_seconds": 7.34, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25668, + "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": [ + "No real Monal/Conversations device or APNs/FCM gateway test", + "Docker not available", + "AF_UNIX admin test needs permitted host", + "Contact invitation subset only; no account registration invites/landing page", + "Token consumption and multiple roster files have no cross-store rollback on disk failure", + "Other functional differences remain in COMPATIBILITY.md" + ], + "release_binary_sha256": "a0a169607f0222caab301852bcb4392bc5476cabf6443816742b8cbf987584a1", + "admin_socket": "explicit ignored-test attempt failed: environment denied AF_UNIX bind (Operation not permitted)" +} diff --git a/docs/validation-results-0.6.0.json b/docs/validation-results-0.6.0.json new file mode 100644 index 0000000..f33b226 --- /dev/null +++ b/docs/validation-results-0.6.0.json @@ -0,0 +1,88 @@ +{ + "version": "0.6.0", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 172, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "network": { + "resource_policy_live_and_hibernating_sm": "passed", + "active_resume_isolation_and_partial_replay": "passed", + "sasl2_inline_resume_auth_owner_id_h_and_bind_fallback": "passed", + "tcp_modules_sigterm": "passed", + "https_wss_bosh_upload_graceful_sm_relogin_kick_new": "passed", + "scram_sha1_sha256_sasl1_sasl2": "passed", + "bind2": "passed", + "pep_push_discovery": "passed", + "preauth": "passed", + "native_prosody_federation": "passed" + }, + "load": { + "total_load_wall_seconds": 7.117, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.599, + "setup_latency": { + "samples": 500, + "median_ms": 54.78, + "p95_ms": 73.082, + "max_ms": 85.834 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.17, + "p95_ms": 24.847, + "max_ms": 28.831 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.239, + "metrics_before": { + "rss_kib": 6804, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24808, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25508, + "cpu_seconds": 8.02, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25636, + "cpu_seconds": 8.04, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25636, + "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 + } + }, + "release_binary_sha256": "b26764245bd10e8a8105af30aa17396d6056a7d367ab589d0393287b5f560625", + "limitations": [ + "No real Monal/Conversations device or APNs/FCM gateway test", + "No deployment or production data validation", + "AF_UNIX admin test requires permitted host; prior environment attempt denied", + "Large archive test was run for 0.5.0, not repeated in this release", + "Remaining functional differences are documented in COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.6.1.json b/docs/validation-results-0.6.1.json new file mode 100644 index 0000000..15f051b --- /dev/null +++ b/docs/validation-results-0.6.1.json @@ -0,0 +1,126 @@ +{ + "version": "0.6.1", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 178, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "new_regressions": { + "android_sasl_network": { + "status": "passed", + "cases": 32, + "tls_versions": [ + "1.2", + "1.3" + ], + "hashes": [ + "SHA-1", + "SHA-256" + ], + "sasl": [ + "1", + "2" + ], + "ssdp": [ + "off", + "on" + ], + "binding": "tls-server-end-point", + "bad_binding": "rejected" + }, + "tls_plus_network": { + "status": "passed", + "exchanges": 14, + "exporter": "real TLS1.3 rustls export", + "endpoint": "TLS1.2 and TLS1.3", + "ssdp_full_feature_lists": "verified", + "no_plus_disabled_policy": "passed" + }, + "sasl_web_network": { + "status": "passed", + "sasl": [ + "1", + "2" + ], + "ssdp_no_cb_separator": "verified" + }, + "certificate_binding": { + "status": "passed", + "tests": 4, + "algorithms": "RSA/ECDSA/DSA SHA1/224/256/384/512, MD5 upgrade, RSA-PSS; unsupported fail closed" + }, + "old_060_failure_reproduction": { + "android_tls12": "4 rejected exchanges reproduced", + "wss_ssdp": "hash mismatch reproduced" + } + }, + "network_previous_scenarios": "passed (TCP, SCRAM, Bind2, inline resume, discovery, preauth, resource policies, HTTPS BOSH/WSS/upload)", + "load": { + "total_load_wall_seconds": 6.467, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.617, + "setup_latency": { + "samples": 500, + "median_ms": 55.521, + "p95_ms": 80.465, + "max_ms": 104.211 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 12.434, + "p95_ms": 21.835, + "max_ms": 28.842 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.583, + "metrics_before": { + "rss_kib": 6984, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 24764, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25300, + "cpu_seconds": 7.55, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25364, + "cpu_seconds": 7.57, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25364, + "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 + } + }, + "release_binary_sha256": "62065ab91ae6fe5014695c52f5c85198db1160c73296f2295c60fb1fa8ce51d8", + "limitations": [ + "No real Android/iOS client/device session; monocles version-specific fallback differs", + "No APNs/FCM gateway or production data/deployment verification", + "Unsupported EdDSA/unknown certificate hashes do not supply endpoint binding", + "AF_UNIX admin needs permitted host; prior environment attempt denied", + "Large archive test last run in 0.5.0; remaining functional gaps in COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.7.0.json b/docs/validation-results-0.7.0.json new file mode 100644 index 0000000..98c000e --- /dev/null +++ b/docs/validation-results-0.7.0.json @@ -0,0 +1,112 @@ +{ + "version": "0.7.0", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 187, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "new_regressions": { + "logging_unit": { + "status": "passed", + "tests": 8, + "coverage": [ + "level maps/exact ranges/sets", + "plain/json timestamps and escaping", + "sensitive-key redaction", + "append0600", + "thread serialization", + "reopen rotation and failed-reopen preserves old handles" + ] + }, + "logging_network": { + "status": "passed", + "scenarios": 8, + "coverage": [ + "TLS/auth/bind correlation", + "wrong PLAIN password", + "invalid SCRAM proof", + "invalid TLS binding", + "disconnect during challenge", + "unknown account", + "corrupt account", + "SIGHUP reopen and login" + ], + "secret_and_message_body_leaks": "none in tested records" + }, + "sasl_retry_network": { + "status": "passed", + "cases": 20, + "coverage": "TLS1.2/1.3 SASL1/2, normalized names/authz, aborted retry, active exchange restart and canonical bind" + } + }, + "network_previous_scenarios": "passed: SCRAM/PLUS/SSDP/Android, WSS, TCP, Bind2, inline resume, discovery, preauth, resource policies, HTTPS BOSH/upload", + "load": { + "total_load_wall_seconds": 7.064, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.696, + "setup_latency": { + "samples": 500, + "median_ms": 66.672, + "p95_ms": 83.122, + "max_ms": 91.585 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.572, + "p95_ms": 23.767, + "max_ms": 26.397 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.058, + "metrics_before": { + "rss_kib": 7508, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25632, + "cpu_seconds": 1.22, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26264, + "cpu_seconds": 7.95, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26328, + "cpu_seconds": 7.98, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26328, + "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 + } + }, + "release_binary_sha256": "f6630bb076ab533f3a917c7375ad6ca5789e6e2cf60d78d2edd33320dbf23910", + "limitations": [ + "Remaining real Conversations/monocles rejection not reproduced; server log from actual attempt required", + "No real Android/iOS device or APNs/FCM gateway session", + "Logging coverage is operational/auth subset; no syslog/plugin sinks, full hot reload, mixed Lua log tables or all module logs", + "No production deployment/data/Docker verification", + "AF_UNIX admin needs permitted host; archive-large last run in0.5.0", + "Other functional gaps in COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.7.1.json b/docs/validation-results-0.7.1.json new file mode 100644 index 0000000..68ab2a0 --- /dev/null +++ b/docs/validation-results-0.7.1.json @@ -0,0 +1,107 @@ +{ + "version": "0.7.1", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 196, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "new_regressions": { + "cached_inline": { + "status": "passed", + "configurations": 6, + "credentials": "independent SCRAM-SHA-1 and PLAIN, correct/wrong password", + "ssdp": "off/on", + "fallback": "bare SASL2 success then legacy bind and ping", + "bind2_enabled": "inline bound retained", + "disabled_sm_cached_resume": "ignored safely", + "baseline_070_auth_behavior": "reproduced malformed request before SCRAM challenge" + }, + "gajim": { + "status": "passed", + "configurations": 4, + "discovery": "domain/own-account security labels+registration feature predicates", + "operations": [ + "catalog2/0 target echo", + "authenticated register GET", + "password change + fresh login", + "public registration gates + fresh login", + "cancellation IQ + rejected subsequent login" + ] + }, + "label_catalog_unit": { + "status": "passed", + "tests": 6, + "policy_adapter": "actual example config/classifications, field mapping, 15 invalid/unknown schemas, no ACL enforcement" + }, + "logging": "same target level unions and console alias unit; distinct targets and reopen tests pass" + }, + "previous_network_regressions": "passed", + "load": { + "total_load_wall_seconds": 7.969, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.842, + "setup_latency": { + "samples": 500, + "median_ms": 72.641, + "p95_ms": 134.723, + "max_ms": 156.502 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.945, + "p95_ms": 22.704, + "max_ms": 26.79 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.825, + "metrics_before": { + "rss_kib": 7284, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25952, + "cpu_seconds": 1.09, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26672, + "cpu_seconds": 8.34, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26736, + "cpu_seconds": 8.38, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26736, + "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 + } + }, + "release_binary_sha256": "5c787ccbfffe759b40ec31ecbb8538cb98bee6fa9bf06166570b500012368fdd", + "limitations": [ + "Real Conversations/monocles/Gajim device applications not run; reproduced wire paths match inspected source", + "Policy adapter is catalog metadata only; no ACL/enforcement", + "No production data/deployment/APNs/FCM verification", + "AF_UNIX admin requires permitted host; archive-large last run in0.5.0", + "Other functional gaps in COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.7.2.json b/docs/validation-results-0.7.2.json new file mode 100644 index 0000000..1add388 --- /dev/null +++ b/docs/validation-results-0.7.2.json @@ -0,0 +1,102 @@ +{ + "version": "0.7.2", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 206, + "failed": 0, + "ignored": 9 + }, + "native_module_tests": 6, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "pep": { + "baseline": "0.7.1 source ZIP reproduces IQ error from omission, false default precondition conflict, native form resubmission feature-not-implemented", + "unit_tests": 7, + "wire": "STARTTLS fresh/migrated stores, no-to publications, device selectors, config GET/SET/retry, restart, foreign owner rejection, info JSON diagnostics redaction", + "native_fields": [ + "notification_type", + "itemreply", + "payload_type/type", + "publish_model" + ], + "security": "outcast and both local blocking directions; explicit open/subscribers models", + "omemo2_multi_device_limit": "test explicitly requests max_items=max; actual clients must configure their shared node retention; native default1 unchanged" + }, + "dnssec": { + "read_only_public_checks": [ + "Google DS: NOERROR no answer, AD=false", + "Google DNSKEY: NOERROR no answer, AD=false", + "Google SRV: 5 0 5222 wthome.ru. AD=false", + "Cloudflare independently confirms missing DS" + ], + "production_changes": false + }, + "previous_network_regressions": "passed", + "load": { + "total_load_wall_seconds": 6.652, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.558, + "setup_latency": { + "samples": 500, + "median_ms": 51.57, + "p95_ms": 70.794, + "max_ms": 90.506 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.511, + "p95_ms": 22.338, + "max_ms": 26.333 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 3.813, + "metrics_before": { + "rss_kib": 7756, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26100, + "cpu_seconds": 1.05, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26844, + "cpu_seconds": 7.63, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26844, + "cpu_seconds": 7.67, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26844, + "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 + } + }, + "release_binary_sha256": "ac16e6f452b2fddcba52c81064ef0fe6bc3982368439bb47079f8a059a18bc91", + "limitations": [ + "Production PEP failure IQ unavailable in supplied INFO0.7.1 log; concrete device failure cause unconfirmed", + "Actual Conversations/monocles/Monal/Gajim applications not run", + "Synthetic bundle bytes test transport/storage only; not cryptographic OMEMO handshake", + "Migrated metadata fixtures not a full production storage migration", + "Actual DNSSEC zone signing and registrar DS require operator configuration", + "No APNs/FCM, target Docker/UID/TLS, durable live sessions verification", + "AF_UNIX admin needs permitted host; archive-large last checked0.5.0", + "Full Prosody parity not achieved; see COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.7.3.json b/docs/validation-results-0.7.3.json new file mode 100644 index 0000000..2ec3b1e --- /dev/null +++ b/docs/validation-results-0.7.3.json @@ -0,0 +1,125 @@ +{ + "version": "0.7.3", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 208, + "failed": 0, + "ignored": 10 + }, + "native_module_tests": 7, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "new_regressions": { + "ordering": { + "baseline": "exact0.7.2 source archive returns old empty list before current populated list, failing first-item client parser", + "fixed": "newest-first for all/recent-N reads; no retention changes", + "native_oracle": "actual mod_pep on same records returns current,old", + "wire": "TLS Android first-item parser, current replacement, restart persistence" + }, + "logging": { + "target_scope": "safe target classification without actual target", + "successful_items": [ + "items_count", + "first_content_kind", + "first_device_count" + ], + "privacy": "wire test asserts no device IDs or raw node in INFO JSON; lib test no item IDs or key bytes", + "semantics": "structural counts only, not cryptographic validity" + } + }, + "production_log_analysis": { + "version": "0.7.2", + "authorization_successes": 9, + "pubsub_iq_records": 126, + "omemo_iq_results": 41, + "omemo_iq_errors": 0, + "pubsub_error_classes": { + "item-not-found": 4, + "forbidden": 1 + }, + "user_confirmed_force_stop": true, + "caveat": "Original records contain neither item counts nor payload structure; no exact deployed PEP cause established" + }, + "dnssec_read_only": [ + { + "name": "wthome.ru", + "type": "DS", + "Status": 0, + "AD": false, + "Answer": null + }, + { + "name": "wthome.ru", + "type": "DNSKEY", + "Status": 0, + "AD": false, + "Answer": null + } + ], + "previous_network_regressions": "passed", + "load": { + "total_load_wall_seconds": 6.869, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.56, + "setup_latency": { + "samples": 500, + "median_ms": 49.877, + "p95_ms": 77.482, + "max_ms": 85.857 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.502, + "p95_ms": 22.717, + "max_ms": 25.971 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.027, + "metrics_before": { + "rss_kib": 7592, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25772, + "cpu_seconds": 1.02, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26420, + "cpu_seconds": 7.81, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26484, + "cpu_seconds": 7.84, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26484, + "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 + } + }, + "release_binary_sha256": "21a1f5f8a1627182f1cbfe8f568c3156c265b2b991fd3c1790d8d3f7eea6c34f", + "limitations": [ + "Production multi-item config/payload unavailable; exact deployed PEP failure remains unproven", + "Actual Android/Monal/Gajim applications not run", + "Synthetic OMEMO payload fixtures test protocol/storage only", + "DNSSEC signing/registrar DS not changed", + "Other parity/deployment/APNs/FCM/live-session durability gaps in COMPATIBILITY.md", + "AF_UNIX admin needs permitted host; archive-large last checked0.5.0" + ] +} diff --git a/docs/validation-results-0.7.4.json b/docs/validation-results-0.7.4.json new file mode 100644 index 0000000..ff1cc35 --- /dev/null +++ b/docs/validation-results-0.7.4.json @@ -0,0 +1,137 @@ +{ + "version": "0.7.4", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 215, + "failed": 0, + "ignored": 10 + }, + "native_module_tests": 7, + "native_federation_tests": 1, + "clippy": "passed", + "fmt": "passed", + "release": "passed", + "wss": { + "original_configuration": "actual https://conversejs.wthome.ru Origin403, allowed https://wthome.ru control101", + "updated_configuration": "exact actual Origin101", + "verified_tls": true, + "configured_interfaces": [ + "127.0.0.1", + "::1" + ], + "checks": [ + "HTTP-only service without c2s/s2s", + "xmpp subprotocol requirements", + "both WebSocket paths", + "browser token-list Connection header", + "wrong Origin403/wrong subprotocol400/unknown path404", + "preauth RFC7395 close and websocket close", + "private TLS/HTTP diagnostics" + ], + "production_endpoint_probe": "DNS resolution failed in development environment; live endpoint not verified" + }, + "pep": { + "production_log_analysis": { + "server_version": "0.7.3", + "json_records": 201, + "own_omemo_iq": 12, + "own_omemo_errors": 0, + "own_omemo_device_xml_counts": [ + 1, + 2, + 6 + ], + "confirmed_browser_origin": "https://conversejs.wthome.ru", + "exact_pep_cause_proven": false + }, + "diagnostics": [ + "legacy/omemo2 device_protocol", + "unique Java signed32 valid_device_count and invalid_device_count", + "notification-generated content_present with no IDs/keys/XML" + ], + "unit_tests": 6, + "wire": "STARTTLS replay/live events, deliberate deliver_payloads=false yields payloadless notification while IQ intact, redaction", + "client_log_available": false, + "exact_production_cause_fixed": false + }, + "client_filter": "manual synthetic input emits only fixed warning categories, excludes fixture private data", + "dnssec_read_only": [ + { + "type": "DS", + "Status": 0, + "AD": false, + "Answer": null + }, + { + "type": "DNSKEY", + "Status": 0, + "AD": false, + "Answer": null + } + ], + "previous_regressions": "passed", + "load": { + "total_load_wall_seconds": 7.856, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.632, + "setup_latency": { + "samples": 500, + "median_ms": 57.745, + "p95_ms": 76.618, + "max_ms": 120.568 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.512, + "p95_ms": 19.865, + "max_ms": 23.436 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.919, + "metrics_before": { + "rss_kib": 7328, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26316, + "cpu_seconds": 1.15, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26872, + "cpu_seconds": 8.19, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 26872, + "cpu_seconds": 8.23, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 26872, + "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 + } + }, + "release_binary_sha256": "65fea1809856e88949e85f476a4349d919bcc7002561c2cb174e561213066a6e", + "limitations": [ + "Converse browser UI and live production endpoint not run; working YAML must be changed by operator", + "Remaining PEP broken status unexplained by supplied server log; no claim fixed", + "Current client master predicates may differ from installed app", + "Synthetic OMEMO bytes test transport/storage, not cryptography", + "DNSSEC zone signing/registrar DS unchanged", + "Other deployment/full data migration/APNs/FCM/parity limitations in COMPATIBILITY.md", + "Large archive last checked0.5.0, AF_UNIX admin needs target host validation" + ] +} diff --git a/docs/validation-results-0.7.5.json b/docs/validation-results-0.7.5.json new file mode 100644 index 0000000..bdd7d77 --- /dev/null +++ b/docs/validation-results-0.7.5.json @@ -0,0 +1,107 @@ +{ + "version": "0.7.5", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 229, + "failed": 0, + "ignored": 10 + }, + "fmt": "passed", + "clippy": "passed", + "release": "passed", + "native_module_tests": 7, + "native_federation_tests": 1, + "http": { + "configuration": [ + "global/host per-app http_paths", + "literal RFC3986 paths and percent encoding", + "host substitution", + "HTTP Host/fallback", + "external advertised URL prefix", + "custom upload internal mount independent of advertised URL" + ], + "proxy": [ + "IP/CIDR trust", + "native Lua empty trusted_proxies table", + "legacy XFF/XFP", + "RFC7239 Forwarded", + "untrusted spoofing rejected", + "per-request secure admission", + "effective peer logging", + "fixed safe custom route diagnostics" + ], + "wire": [ + "verified TLS terminating path rewriting loopback proxy", + "authenticated WS and BOSH restart/bind/ping in legacy and RFC7239 modes", + "cached BOSH RID retry", + "untrusted direct headers and TLSproxy rejected", + "trusted plaintext proto rejected", + "existing WSS and HTTP integration", + "upload custom path with host substitution IQ/PUT/GET/reuse" + ], + "native_divergence": "RFC7239 mode permits HTTPS termination on trusted HTTP backend; then all consumed preceding hops must be HTTPS" + }, + "load": { + "total_load_wall_seconds": 7.057, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.598, + "setup_latency": { + "samples": 500, + "median_ms": 54.637, + "p95_ms": 77.295, + "max_ms": 99.823 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 13.144, + "p95_ms": 24.011, + "max_ms": 26.702 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.178, + "metrics_before": { + "rss_kib": 8016, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26416, + "cpu_seconds": 1.13, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 26896, + "cpu_seconds": 8.07, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27024, + "cpu_seconds": 8.12, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27024, + "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 + } + }, + "release_binary_sha256": "bf742fcb7c77892e16dadaf6ddc3126b23a2513a80f69004325c77c171ef57bb", + "limitations": [ + "Runtime exactly one VirtualHost with components", + "No PROXYv1/v2 or arbitrary Lua HTTP apps or hot route reload", + "Production proxy and browser UI not run; operator must merge configuration", + "Earlier reported PEP broken status remains unresolved; no PEP semantic changes in this release", + "DNSSEC zone and registrar DS unchanged", + "No claim full Prosody parity; see COMPATIBILITY.md" + ] +} diff --git a/docs/validation-results-0.7.6.json b/docs/validation-results-0.7.6.json new file mode 100644 index 0000000..eae374d --- /dev/null +++ b/docs/validation-results-0.7.6.json @@ -0,0 +1,108 @@ +{ + "version": "0.7.6", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 259, + "failed": 0, + "ignored": 11 + }, + "fmt": "passed", + "clippy": "passed", + "release": "passed", + "native_module_tests": 8, + "native_federation_tests": 1, + "muc": { + "extensions": [ + "affiliation hats/legacy compat and spoof filtering", + "language/roominfo/config forms", + "private message and moderator policies", + "role-filtered presence/offline reserved affiliates", + "vcard/occupant-id disable flags", + "non-MAM history constraints/delay/subject", + "MAM public-room ACL/all_rooms/presence events/include_total/destroy cleanup" + ], + "state": [ + "native occupant map, multiple resources and last message", + "graceful SIGTERM freeze before transport teardown", + "temporary and persistent rooms", + "exact authenticated full JID reactivation on available presence", + "canonical config fingerprint validation independent of Lua key order", + "durable one-time consumption before publish", + "fixed 300s deadline across repeated restarts", + "pending nick reservations and capacity accounting", + "no oversized presence/history shutdown DoS" + ], + "wire": "live STARTTLS connections at SIGTERM; restart restores room roles/presence/messages; wrong resource/account denied; explicit rejoin works", + "native_oracle": "Rust writes three sessions; actual Prosody restores resources/role/payload; native freeze writes changed role and presence; Rust reads them", + "unit_files": [ + "muc_extensions.rs", + "muc_history.rs", + "muc_recovery.rs", + "muc_restart_state.rs" + ] + }, + "load": { + "total_load_wall_seconds": 9.221, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.859, + "setup_latency": { + "samples": 500, + "median_ms": 78.827, + "p95_ms": 112.823, + "max_ms": 141.228 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 24.812, + "p95_ms": 54.825, + "max_ms": 60.843 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 5.825, + "metrics_before": { + "rss_kib": 7884, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 25176, + "cpu_seconds": 1.33, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 25836, + "cpu_seconds": 11.24, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 25964, + "cpu_seconds": 11.28, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 25964, + "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 + } + }, + "release_binary_sha256": "4cb267ce940968aed1644ccee5c7bc37f8ddc08f80542f9cb4ec63f35b365e42", + "limitations": [ + "Recovery requires clean SIGTERM/Ctrl-C checkpoint; no new checkpoint on SIGKILL/power loss", + "300s grace/exact full JID/unchanged room config; otherwise ordinary client rejoin", + "Native checkpoints without Rust fingerprint are read by helper but do not authorize auto recovery", + "No cross-process TCP/TLS or SM resumption", + "History default100 configurable4096; occupants max4096; optional oversized checkpoint fields omitted", + "No arbitrary community Lua HTTP/MUC plugins; no complete full-server Prosody parity claim", + "Production migration/actual client interoperability/PEP broken status/DNSSEC remain unproven or unchanged" + ] +} diff --git a/docs/validation-results.json b/docs/validation-results.json new file mode 100644 index 0000000..b1f5de3 --- /dev/null +++ b/docs/validation-results.json @@ -0,0 +1,93 @@ +{ + "version": "0.7.7", + "date_utc": "2026-10-20", + "ordinary_tests": { + "passed": 262, + "failed": 0, + "ignored": 11 + }, + "fmt": "passed", + "clippy": "passed", + "release": "passed", + "native_module_tests": 8, + "native_federation_tests": 1, + "http_altconnect": { + "native_reference": "supplied plugins/mod_http_altconnect.lua cafd74b9c5ea", + "unit_tests": 3, + "network_configurations": 10, + "checks": [ + "HTTP and verified HTTPS XRD/JRD parity", + "MIME/CORS/GET/HEAD/OPTIONS and method405", + "query ignored, no JSONP", + "advertise_bosh/websocket combinations and loaded transport gates", + "discovery disabled while WSS still works", + "custom altconnect/BOSH/WS mounts and public URL prefix", + "local/global inheritance and HTTPS preference", + "HTTP native compatibility fallback", + "HTTP Host denial and overflowing port rejection", + "config type/path collision rejection" + ], + "public_urls": "configuration only; no request Host/Forwarded interpolation", + "standard_caveat": "XEP0156 requires HTTPS metadata and HTTPS/WSS href; supplied-native HTTP/WS fallback retained and documented" + }, + "load": { + "total_load_wall_seconds": 7.447, + "requested_resources": 500, + "authenticated_bound_resources": 500, + "connection_setup_concurrency": 50, + "setup_wall_seconds": 0.653, + "setup_latency": { + "samples": 500, + "median_ms": 57.419, + "p95_ms": 87.729, + "max_ms": 107.7 + }, + "ping_rounds": 10, + "ping_round_trip": { + "samples": 5000, + "median_ms": 14.618, + "p95_ms": 19.347, + "max_ms": 30.719 + }, + "accounts": 100, + "message_rounds": 10, + "messages_sent": 5000, + "messages_received": 5000, + "message_batch_seconds": 4.483, + "metrics_before": { + "rss_kib": 7792, + "cpu_seconds": 0.0, + "fd_count": 11 + }, + "metrics_connected": { + "rss_kib": 26488, + "cpu_seconds": 1.17, + "fd_count": 511 + }, + "metrics_after_workload": { + "rss_kib": 27100, + "cpu_seconds": 8.93, + "fd_count": 511 + }, + "metrics_two_seconds_after_disconnect": { + "rss_kib": 27228, + "cpu_seconds": 8.95, + "fd_count": 11 + }, + "sampled_peak_rss_kib": 27228, + "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 + } + }, + "release_binary_sha256": "4678c35c9ad53f2632f0f0245d842589a15dd4e02b9cb172f15dd7b035422294", + "limitations": [ + "Real production proxy/discovery/client UI not run; operator must enable module and publish well-known on HTTPS domain", + "Exactly one runtime VirtualHost; no Host Meta2/XEP0487", + "Previous parity/PEP/DNSSEC limitations unchanged; COMPATIBILITY.md" + ] +} diff --git a/examples/config.json b/examples/config.json new file mode 100644 index 0000000..cf730d9 --- /dev/null +++ b/examples/config.json @@ -0,0 +1,187 @@ +{ + "global": { + "admins": [ + "wt@wthome.ru" + ], + "allow_registration": true, + "allow_unencrypted_plain_auth": false, + "archive_expires_after": "never", + "c2s_ports": [ + 5222 + ], + "c2s_require_encryption": true, + "consider_bosh_secure": true, + "consider_websocket_secure": true, + "data_path": "/var/lib/prosody", + "default_archive_policy": true, + "external_addresses": [ + "88.204.18.107" + ], + "http_cors_override": { + "bosh": { + "enabled": true, + "origins": [ + "https://wthome.ru" + ] + }, + "websocket": { + "enabled": true, + "origins": [ + "https://wthome.ru", + "https://conversejs.wthome.ru" + ] + } + }, + "http_interfaces": [ + "*" + ], + "http_ports": [ + 5280 + ], + "https_interfaces": [ + "*" + ], + "https_ports": [ + 5281 + ], + "interfaces": [ + "0.0.0.0", + "::" + ], + "limits": { + "c2s": { + "rate": "100kb/s" + }, + "s2sin": { + "rate": "300kb/s" + } + }, + "log": { + "error": "*console", + "info": "*console" + }, + "modules_enabled": [ + "roster", + "saslauth", + "tls", + "disco", + "pep_simple", + "private", + "pep", + "vcard_legacy", + "carbons", + "mam", + "smacks", + "csi_simple", + "turn_external", + "register", + "blocklist", + "bookmarks", + "ping", + "time", + "version", + "uptime", + "websocket", + "bosh", + "http_altconnect", + "admin_shell", + "cloud_notify", + "s2s", + "s2s_auth_certs", + "seclabels", + "sasl_ssdp", + "sasl2" + ], + "pep_max_items": 10000, + "push_max_devices": 50, + "push_max_errors": 32, + "push_max_hibernation_timeout": 259200, + "push_notification_important_body": "New message", + "push_notification_with_body": true, + "push_notification_with_sender": true, + "s2s_ports": [ + 5269 + ], + "s2s_require_encryption": true, + "s2s_secure_auth": true, + "security_labels": { + "default_policy": "default", + "policies": { + "default": { + "classifications": [ + { + "default": true, + "name": "Unclassified" + }, + { + "name": "Confidential" + }, + { + "name": "Secret" + }, + { + "name": "Top Secret" + } + ] + } + } + }, + "smacks_hibernation_time": 300, + "smacks_max_unacked": 100, + "turn_external_host": "turn.wthome.ru", + "turn_external_port": 3478, + "turn_external_secret": "TEST-ONLY-NOT-A-REAL-SECRET", + "turn_external_ttl": 86400, + "vcard_avatar": { + "sync_to_pep": true, + "sync_to_vcard": true + } + }, + "hosts": { + "conference.wthome.ru": { + "component": "muc", + "options": { + "load_global_modules": false, + "mam_muc_archive_expires_after": "1w", + "max_history_messages": 100, + "modules_enabled": [ + "muc_mam" + ], + "muc_tombstones": true, + "name": "Conference Rooms", + "restrict_room_creation": "local" + } + }, + "pubsub.wthome.ru": { + "component": "pubsub", + "options": { + "load_global_modules": false, + "name": "PubSub Service", + "restrict_node_creation": false + } + }, + "upload.wthome.ru": { + "component": "http_file_share", + "options": { + "http_file_share_expires_after": "never", + "http_file_share_global_quota": 107374182400, + "http_file_share_size_limit": 10737418240, + "load_global_modules": false, + "name": "File Sharing" + } + }, + "wthome.ru": { + "component": null, + "options": { + "authentication": "internal_hashed", + "modules_enabled": [ + "mam" + ], + "ssl": { + "certificate": "/etc/prosody/certs/fullchain.pem", + "key": "/etc/prosody/certs/privkey.pem" + } + } + } + } +} \ No newline at end of file diff --git a/examples/config.toml b/examples/config.toml new file mode 100644 index 0000000..2dbccae --- /dev/null +++ b/examples/config.toml @@ -0,0 +1,143 @@ +[global] +admins = ["wt@wthome.ru"] +allow_registration = true +allow_unencrypted_plain_auth = false +archive_expires_after = "never" +c2s_ports = [5222] +c2s_require_encryption = true +consider_bosh_secure = true +consider_websocket_secure = true +data_path = "/var/lib/prosody" +default_archive_policy = true +external_addresses = ["88.204.18.107"] +http_interfaces = ["*"] +http_ports = [5280] +https_interfaces = ["*"] +https_ports = [5281] +interfaces = [ + "0.0.0.0", + "::", +] +modules_enabled = [ + "roster", + "saslauth", + "tls", + "disco", + "pep_simple", + "private", + "pep", + "vcard_legacy", + "carbons", + "mam", + "smacks", + "csi_simple", + "turn_external", + "register", + "blocklist", + "bookmarks", + "ping", + "time", + "version", + "uptime", + "websocket", + "bosh", + "admin_shell", + "cloud_notify", + "s2s", + "s2s_auth_certs", + "seclabels", + "sasl_ssdp", + "sasl2", +] +pep_max_items = 10000 +push_max_devices = 50 +push_max_errors = 32 +push_max_hibernation_timeout = 259200 +push_notification_important_body = "New message" +push_notification_with_body = true +push_notification_with_sender = true +s2s_ports = [5269] +s2s_require_encryption = true +s2s_secure_auth = true +smacks_hibernation_time = 300 +smacks_max_unacked = 100 +turn_external_host = "turn.wthome.ru" +turn_external_port = 3478 +turn_external_secret = "TEST-ONLY-NOT-A-REAL-SECRET" +turn_external_ttl = 86400 + +[global.http_cors_override.bosh] +enabled = true +origins = ["https://wthome.ru"] + +[global.http_cors_override.websocket] +enabled = true +origins = ["https://wthome.ru"] + +[global.limits.c2s] +rate = "100kb/s" + +[global.limits.s2sin] +rate = "300kb/s" + +[global.log] +error = "*console" +info = "*console" + +[global.security_labels] +default_policy = "default" + +[[global.security_labels.policies.default.classifications]] +default = true +name = "Unclassified" + +[[global.security_labels.policies.default.classifications]] +name = "Confidential" + +[[global.security_labels.policies.default.classifications]] +name = "Secret" + +[[global.security_labels.policies.default.classifications]] +name = "Top Secret" + +[global.vcard_avatar] +sync_to_pep = true +sync_to_vcard = true + +[hosts."conference.wthome.ru"] +component = "muc" + +[hosts."conference.wthome.ru".options] +load_global_modules = false +mam_muc_archive_expires_after = "1w" +max_history_messages = 100 +modules_enabled = ["muc_mam"] +muc_tombstones = true +name = "Conference Rooms" +restrict_room_creation = "local" + +[hosts."pubsub.wthome.ru"] +component = "pubsub" + +[hosts."pubsub.wthome.ru".options] +load_global_modules = false +name = "PubSub Service" +restrict_node_creation = false + +[hosts."upload.wthome.ru"] +component = "http_file_share" + +[hosts."upload.wthome.ru".options] +http_file_share_expires_after = "never" +http_file_share_global_quota = 107374182400 +http_file_share_size_limit = 10737418240 +load_global_modules = false +name = "File Sharing" + +[hosts."wthome.ru".options] +authentication = "internal_hashed" +modules_enabled = ["mam"] + +[hosts."wthome.ru".options.ssl] +certificate = "/etc/prosody/certs/fullchain.pem" +key = "/etc/prosody/certs/privkey.pem" diff --git a/examples/config.yaml b/examples/config.yaml new file mode 100644 index 0000000..6e8ea92 --- /dev/null +++ b/examples/config.yaml @@ -0,0 +1,138 @@ +global: + admins: + - wt@wthome.ru + allow_registration: true + allow_unencrypted_plain_auth: false + archive_expires_after: never + c2s_ports: + - 5222 + c2s_require_encryption: true + consider_bosh_secure: true + consider_websocket_secure: true + data_path: /var/lib/prosody + default_archive_policy: true + external_addresses: + - 88.204.18.107 + http_cors_override: + bosh: + enabled: true + origins: + - https://wthome.ru + websocket: + enabled: true + origins: + - https://wthome.ru + http_interfaces: + - '*' + http_ports: + - 5280 + https_interfaces: + - '*' + https_ports: + - 5281 + interfaces: + - 0.0.0.0 + - '::' + limits: + c2s: + rate: 100kb/s + s2sin: + rate: 300kb/s + log: + error: '*console' + info: '*console' + modules_enabled: + - roster + - saslauth + - tls + - disco + - pep_simple + - private + - pep + - vcard_legacy + - carbons + - mam + - smacks + - csi_simple + - turn_external + - register + - blocklist + - bookmarks + - ping + - time + - version + - uptime + - websocket + - bosh + - admin_shell + - cloud_notify + - s2s + - s2s_auth_certs + - seclabels + - sasl_ssdp + - sasl2 + pep_max_items: 10000 + push_max_devices: 50 + push_max_errors: 32 + push_max_hibernation_timeout: 259200 + push_notification_important_body: New message + push_notification_with_body: true + push_notification_with_sender: true + s2s_ports: + - 5269 + s2s_require_encryption: true + s2s_secure_auth: true + security_labels: + default_policy: default + policies: + default: + classifications: + - default: true + name: Unclassified + - name: Confidential + - name: Secret + - name: Top Secret + smacks_hibernation_time: 300 + smacks_max_unacked: 100 + turn_external_host: turn.wthome.ru + turn_external_port: 3478 + turn_external_secret: TEST-ONLY-NOT-A-REAL-SECRET + turn_external_ttl: 86400 + vcard_avatar: + sync_to_pep: true + sync_to_vcard: true +hosts: + conference.wthome.ru: + component: muc + options: + load_global_modules: false + mam_muc_archive_expires_after: 1w + max_history_messages: 100 + modules_enabled: + - muc_mam + muc_tombstones: true + name: Conference Rooms + restrict_room_creation: local + pubsub.wthome.ru: + component: pubsub + options: + load_global_modules: false + name: PubSub Service + restrict_node_creation: false + upload.wthome.ru: + component: http_file_share + options: + http_file_share_expires_after: never + http_file_share_global_quota: 107374182400 + http_file_share_size_limit: 10737418240 + load_global_modules: false + name: File Sharing + wthome.ru: + component: null + options: + authentication: internal_hashed + modules_enabled: + - mam + ssl: + certificate: /etc/prosody/certs/fullchain.pem + key: /etc/prosody/certs/privkey.pem diff --git a/examples/converse-origin.fragment.yaml b/examples/converse-origin.fragment.yaml new file mode 100644 index 0000000..ca4b8b9 --- /dev/null +++ b/examples/converse-origin.fragment.yaml @@ -0,0 +1,9 @@ +# Merge this fragment into the existing global settings; this is not a full config. +# Keep the current hosts, data_path, certificates and all other options. +global: + http_cors_override: + websocket: + enabled: true + origins: + - https://wthome.ru + - https://conversejs.wthome.ru diff --git a/examples/reverse-proxy.fragment.yaml b/examples/reverse-proxy.fragment.yaml new file mode 100644 index 0000000..910bb21 --- /dev/null +++ b/examples/reverse-proxy.fragment.yaml @@ -0,0 +1,17 @@ +# Merge into existing global config; this is NOT a complete server configuration. +# TLS terminates at the proxy, which strips public /xmpp/ and sets Host wthome.ru. +global: + http_interfaces: [127.0.0.1] + http_ports: [5280] + trusted_proxies: [127.0.0.1, '::1'] + http_legacy_x_forwarded: true + http_default_host: wthome.ru + http_external_url: https://wthome.ru/xmpp/ + http_paths: + websocket: /ws + bosh: /bind + http_cors_override: + websocket: + origins: [https://conversejs.wthome.ru] + bosh: + origins: [https://conversejs.wthome.ru] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..58eef2c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.1" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..9da2fa4 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +# Never recursively chown an existing Prosody volume. Match its existing owner. +if [ "$(id -u)" = 0 ]; then + data_dir=${XMPP_DATA_DIR:-/var/lib/prosody} + if [ ! -d "$data_dir" ]; then + mkdir -p "$data_dir" + chown "${XMPP_UID:-1000}:${XMPP_GID:-1000}" "$data_dir" + fi + runtime_uid=${XMPP_UID:-$(stat -c %u "$data_dir")} + runtime_gid=${XMPP_GID:-$(stat -c %g "$data_dir")} + case "$runtime_uid:$runtime_gid" in + *[!0-9:]*|:*|*:) echo 'XMPP_UID and XMPP_GID must be numeric' >&2; exit 1 ;; + esac + if [ "$runtime_uid" != 0 ]; then + exec gosu "$runtime_uid:$runtime_gid" rust-xmpp "$@" + fi +fi +exec rust-xmpp "$@" diff --git a/scripts/filter-android-pep-log.py b/scripts/filter-android-pep-log.py new file mode 100755 index 0000000..054f87b --- /dev/null +++ b/scripts/filter-android-pep-log.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Read client log lines on stdin; print only fixed PEP warning classifications. + +Usage: adb logcat -v brief | python3 scripts/filter-android-pep-log.py +Or: python3 scripts/filter-android-pep-log.py < client.log +Raw input, JIDs, XML, tokens, key material and message text are never printed. +These phrases describe inspected client source, not every client/version. +""" +import json +import sys + +PHRASES = { + "Own device publish attempt threshold exceeded": "empty-device-list-retry-limit", + "Own device list empty, attempting to publish": "empty-device-list-publish", + "Error received while publishing own device id": "device-list-publish-error", + "request for device bundles came back with something other than item-not-found": "bundle-read-error", + "Error received while publishing bundle": "bundle-publish-error", +} +for line in sys.stdin: + normalized = " ".join(line.split()) + for phrase, reason in PHRASES.items(): + if phrase in normalized: + print(json.dumps({"event": "client-pep-warning", "reason": reason}), flush=True) + break diff --git a/scripts/integration_http.py b/scripts/integration_http.py new file mode 100644 index 0000000..0497a79 --- /dev/null +++ b/scripts/integration_http.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Real binary integration: TLS WebSocket/BOSH auth, roster/chat and XEP-0363. +Requires Python websockets and openssl. Uses temporary data and loopback ports only. +""" +import argparse +import asyncio +import base64 +import json +import http.client +import urllib.parse +import pathlib +import socket +import ssl +import subprocess +import tempfile +import time +import urllib.request +import urllib.error +import xml.etree.ElementTree as ET +import websockets + +CLIENT = 'jabber:client' +BIND = 'urn:ietf:params:xml:ns:xmpp-bind' +BOSH = 'http://jabber.org/protocol/httpbind' +SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' +UPLOAD = 'urn:xmpp:http:upload:0' +SM = 'urn:xmpp:sm:3' +FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing' + + +def port(): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def plain(user): + return base64.b64encode(f'\0{user}\0integration-password'.encode()).decode() + + +class BoshClient: + def __init__(self, url, context): + self.url, self.context, self.sid, self.rid = url, context, None, 100 + + def send(self, stanza='', **attributes): + attributes.update({'xmlns': BOSH, 'rid': str(self.rid)}) + self.rid += 1 + if self.sid: + attributes['sid'] = self.sid + # Values are controlled constants in this integration fixture. + attrs = ' '.join(f'{k}="{v}"' for k, v in attributes.items()) + request = urllib.request.Request(self.url, f'{stanza}'.encode(), + headers={'Content-Type': 'text/xml'}, method='POST') + with urllib.request.urlopen(request, context=self.context, timeout=8) as response: + xml = response.read() + body = ET.fromstring(xml) + assert body.get('type') != 'terminate' or attributes.get('type') == 'terminate', xml + if body.get('sid'): + self.sid = body.get('sid') + return body + + def login(self, user): + first = self.send(to='localhost', wait='1', hold='1', ver='1.11') + assert first.find(f'.//{{{SASL}}}mechanism') is not None, ET.tostring(first) + auth = self.send(f'{plain(user)}') + assert auth.find(f'{{{SASL}}}success') is not None, ET.tostring(auth) + self.send(**{'xmlns:xmpp': 'urn:xmpp:xbosh', 'xmpp:restart': 'true', 'to': 'localhost'}) + bound = self.send(f'bosh') + jid = bound.find(f'.//{{{BIND}}}jid') + assert jid is not None and jid.text == f'{user}@localhost/bosh', ET.tostring(bound) + + def enable_resume(self): + body = self.send(f'') + enabled = body.find(f'{{{SM}}}enabled') + assert enabled is not None and enabled.get('id') and enabled.get('resume') in ('true', '1'), ET.tostring(body) + + +async def ws_receive(ws, predicate, timeout=8): + async with asyncio.timeout(timeout): + while True: + raw = await ws.recv() + node = ET.fromstring(raw) + if predicate(node): + return node + + +async def ws_login(ws, user): + opening = f'' + await ws.send(opening) + features = await ws_receive(ws, lambda n: n.tag.endswith('}features')) + assert features.find(f'.//{{{SASL}}}mechanism') is not None + await ws.send(f'{plain(user)}') + await ws_receive(ws, lambda n: n.tag == f'{{{SASL}}}success') + await ws.send(opening) + await ws_receive(ws, lambda n: n.tag.endswith('}features')) + await ws.send(f'websocket') + result = await ws_receive(ws, lambda n: n.tag == f'{{{CLIENT}}}iq' and n.get('id') == 'bind') + jid = result.find(f'.//{{{BIND}}}jid') + assert result.get('type') == 'result' and jid is not None and jid.text == f'{user}@localhost/websocket', ET.tostring(result) + + +async def ws_enable_resume(ws): + await ws.send(f'') + enabled = await ws_receive(ws, lambda n: n.tag in (f'{{{SM}}}enabled', f'{{{SM}}}failed')) + assert enabled.tag == f'{{{SM}}}enabled' and enabled.get('id') and enabled.get('resume') in ('true', '1'), ET.tostring(enabled) + + +async def exercise(https_port, context, custom_upload=False): + endpoint = f'https://localhost:{https_port}' + bosh = BoshClient(endpoint + '/http-bind', context) + await asyncio.to_thread(bosh.login, 'alice') + async with websockets.connect(f'wss://localhost:{https_port}/xmpp-websocket', ssl=context, + subprotocols=['xmpp'], open_timeout=8) as ws: + await ws_login(ws, 'bob') + await ws.send('') + await ws.send('') + await ws.send(f'') + await ws_receive(ws, lambda n: n.get('id') == 'after-csi' and n.get('type') == 'result') + namespaced = await asyncio.to_thread(bosh.send, '', **{'xmlns:c': CLIENT}) + assert any(n.get('id') == 'inherited-ns' and n.get('type') == 'result' for n in namespaced), ET.tostring(namespaced) + # Both become available before ordinary bare-JID routing. + await ws.send(f'') + await asyncio.to_thread(bosh.send, f'') + roster = await asyncio.to_thread(bosh.send, f'') + assert any(n.get('id') == 'r1' and n.get('type') == 'result' for n in roster), ET.tostring(roster) + roster = await asyncio.to_thread(bosh.send, f'') + assert roster.find('.//{jabber:iq:roster}item').get('jid') == 'bob@localhost' + await asyncio.to_thread(bosh.send, f'from-bosh') + incoming = await ws_receive(ws, lambda n: n.tag == f'{{{CLIENT}}}message' and n.get('id') == 'hello') + assert incoming.find(f'{{{CLIENT}}}body').text == 'from-bosh' + await ws.send(f'from-websocket') + poll = await asyncio.to_thread(bosh.send) + assert any(n.get('id') == 'reply' for n in poll), ET.tostring(poll) + await ws.send(f'') + await ws_receive(ws, lambda n: n.tag == f'{{{CLIENT}}}iq' and n.get('id') == 'ws-roster' and n.get('type') == 'result') + data = b'actual integrated HTTP upload\n' * 16384 + slots = await asyncio.to_thread(bosh.send, f'') + slot = slots.find(f'.//{{{UPLOAD}}}slot') + assert slot is not None, ET.tostring(slots) + put = slot.find(f'{{{UPLOAD}}}put') + if custom_upload: + assert urllib.parse.urlsplit(put.get('url')).path.startswith('/custom/upload-upload.localhost/'), put.get('url') + authorization = put.find(f'{{{UPLOAD}}}header').text + request = urllib.request.Request(put.get('url'), data, headers={'Authorization': authorization}, method='PUT') + def transfer(): + with urllib.request.urlopen(request, context=context, timeout=8) as response: + assert response.status == 201 + with urllib.request.urlopen(slot.find(f'{{{UPLOAD}}}get').get('url'), context=context, timeout=8) as response: + assert response.read() == data + parsed = urllib.parse.urlsplit(put.get('url')) + duplicate = http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=8) + duplicate.putrequest('PUT', parsed.path) + duplicate.putheader('Authorization', authorization) + duplicate.putheader('Content-Length', str(len(data))) + duplicate.endheaders() + # Server rejects a completed slot before accepting its body. + assert duplicate.getresponse().status == 409 + duplicate.close() + await asyncio.to_thread(transfer) + # Graceful transport shutdown must release even a resumable SM resource. + # kick_new makes an accidentally hibernating old resource fail this bind. + await asyncio.to_thread(bosh.enable_resume) + terminated = await asyncio.to_thread(bosh.send, type='terminate') + assert terminated.get('type') == 'terminate', ET.tostring(terminated) + again = BoshClient(endpoint + '/http-bind', context) + await asyncio.to_thread(again.login, 'alice') + await asyncio.to_thread(again.enable_resume) + ping = await asyncio.to_thread(again.send, f'') + assert any(n.get('id') == 'bosh-relogin' and n.get('type') == 'result' for n in ping), ET.tostring(ping) + await asyncio.to_thread(again.send, type='terminate') + await ws_enable_resume(ws) + await ws.send(f'') + await ws_receive(ws, lambda n: n.tag == f'{{{FRAMING}}}close') + async with websockets.connect(f'wss://localhost:{https_port}/xmpp-websocket', ssl=context, + subprotocols=['xmpp'], open_timeout=8) as ws: + await ws_login(ws, 'bob') + await ws_enable_resume(ws) + await ws.send(f'') + await ws_receive(ws, lambda n: n.get('id') == 'ws-relogin' and n.get('type') == 'result') + await ws.send(f'') + await ws_receive(ws, lambda n: n.tag == f'{{{FRAMING}}}close') + print('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') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--binary', default=str(pathlib.Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + parser.add_argument('--custom-upload-path', action='store_true') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='rust-xmpp-http-integration-') as work: + work = pathlib.Path(work) + cert, key = work / 'cert.pem', work / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,DNS:upload.localhost', + '-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)], check=True, capture_output=True) + tcp_port, http_port, https_port = port(), port(), port() + config = {'global': {'data_path': str(work / 'data'), 'interfaces': ['127.0.0.1'], 'http_interfaces': ['127.0.0.1'], + 'https_interfaces': ['127.0.0.1'], 'c2s_ports': [tcp_port], 's2s_ports': [], + 'http_ports': [http_port], 'https_ports': [https_port], 'allow_registration': False, + 'consider_bosh_secure': True, 'consider_websocket_secure': True, + 'modules_enabled': ['tls', 'saslauth', 'disco', 'roster', 'websocket', 'bosh', 'csi_simple', 'smacks', 'ping'], + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'conflict_resolve': 'kick_new'}}, + 'upload.localhost': {'component': 'http_file_share', 'options': {'http_host': 'localhost', 'http_external_url': f'https://localhost:{https_port}', + 'http_file_share_secret': 'integration-test-secret', 'http_file_share_size_limit': 1024*1024, + 'http_file_share_daily_quota': 10*1024*1024}}}} + if args.custom_upload_path: + config['hosts']['upload.localhost']['options']['http_paths'] = {'file_share': '/custom/upload-$host'} + path = work / 'config.json' + path.write_text(json.dumps(config)) + for user in ['alice', 'bob']: + subprocess.run([args.binary, 'create-user', str(path), user, '--password-stdin'], input='integration-password\n', text=True, check=True, capture_output=True) + log = open(work / 'server.log', 'w+') + process = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + if process.poll() is not None: + raise RuntimeError('server exited during startup') + try: + with socket.create_connection(('127.0.0.1', tcp_port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise RuntimeError('server startup timed out') + context = ssl.create_default_context(cafile=str(cert)) + asyncio.run(exercise(https_port, context, args.custom_upload_path)) + if args.custom_upload_path: + print('PASS: configured upload HTTP path with $host through integrated listener') + except BaseException: + log.flush() + log.seek(0) + print('SERVER LOG:\n' + log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + log.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/integration_xmpp.py b/scripts/integration_xmpp.py new file mode 100644 index 0000000..91f82db --- /dev/null +++ b/scripts/integration_xmpp.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Independent TCP integration tests for integrated Rust XMPP modules (isolated data).""" +import base64, json, signal, socket, ssl, subprocess, tempfile, time +from pathlib import Path +import xml.etree.ElementTree as ET +ROOT=Path(__file__).resolve().parents[1] +BIN=ROOT/'target/release/rust-xmpp' +C='jabber:client'; S='http://etherx.jabber.org/streams' +OPEN=f"" +def local(e):return e.tag.split('}')[-1] +class Client: + def __init__(self,port,cert,user,password,resource='main',bind=True): + self.sock=socket.create_connection(('127.0.0.1',port),timeout=5);self.buf=b'';self.queue=[];self.user=user;self.port=port;self.cert=cert;self.password=password + self.send(OPEN);self.until(b'');self.send("");self.until(b'/>') + self.sock=ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock,server_hostname='localhost');self.send(OPEN);self.until(b'') + payload=base64.b64encode(f'\0{user}\0{password}'.encode()).decode();self.send(f"{payload}");self.until(b'/>');self.send(OPEN);self.until(b'') + self.jid=f'{user}@localhost/{resource}' + if bind:self.send(f"{resource}");self.iq('bind') + def send(self,s):self.sock.sendall(s.encode()) + def until(self,mark): + while mark not in self.buf: + chunk=self.sock.recv(65536) + if not chunk:raise RuntimeError('EOF '+repr(self.buf)) + self.buf+=chunk + end=self.buf.index(mark)+len(mark);out,self.buf=self.buf[:end],self.buf[end:];return out + def read(self): + parser=ET.XMLPullParser(events=('start','end'));parser.feed(f"");list(parser.read_events());depth=0 + while True: + if not self.buf: + self.buf=self.sock.recv(65536) + if not self.buf:raise RuntimeError('EOF') + byte,self.buf=self.buf[:1],self.buf[1:];parser.feed(byte) + # Expat reparse deferral may withhold a short end-tag when fed bytewise. + # Flush at markup boundaries so an empty IQ result needs no following stanza. + if byte==b'>' and hasattr(parser,'flush'):parser.flush() + for event,node in parser.read_events(): + depth+=1 if event=='start' else -1 + if event=='end' and depth==0:return node + def take(self,predicate): + for i,e in enumerate(self.queue): + if predicate(e):return self.queue.pop(i) + while True: + e=self.read() + if predicate(e):return e + self.queue.append(e) + def iq(self,id,error=False): + e=self.take(lambda e:local(e)=='iq' and e.get('id')==id and e.get('type') in ('result','error')) + assert e.get('type')==('error' if error else 'result'),ET.tostring(e) + return e + def sync(self):self.send("");self.iq('sync') + def available(self):self.send('');self.sync() + def body(self,text):return self.take(lambda e:local(e)=='message' and any(local(c)=='body' and c.text==text for c in e)) + def close(self):self.sock.close() + +with tempfile.TemporaryDirectory(prefix='rust-xmpp-network-') as folder: + path=Path(folder);port=socket.socket();port.bind(('127.0.0.1',0));portnum=port.getsockname()[1];port.close() + subprocess.run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-keyout',str(path/'key.pem'),'-out',str(path/'cert.pem'),'-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + modules=['tls','saslauth','sasl2','sasl_ssdp','roster','disco','private','vcard_legacy','blocklist','mam','pep','bookmarks','smacks','carbons','ping','version','register','cloud_notify','time','uptime','csi_simple'] + config={'global':{'data_path':str(path/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[portnum],'s2s_ports':[],'http_ports':[],'https_ports':[],'allow_registration':False,'default_archive_policy':True,'modules_enabled':modules},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256','ssl':{'key':str(path/'key.pem'),'certificate':str(path/'cert.pem')}}},'conference.localhost':{'component':'muc','options':{'modules_enabled':['muc_mam'],'muc_room_default_persistent':True}},'pubsub.localhost':{'component':'pubsub','options':{'restrict_node_creation':False}}}} + (path/'config.json').write_text(json.dumps(config)) + for user in ['alice','bob']: + subprocess.run([str(BIN),'create-user',str(path/'config.json'),user,'--password-stdin'],input=f'{user}-secret\n',text=True,check=True,stdout=subprocess.DEVNULL) + server=subprocess.Popen([str(BIN),'serve',str(path/'config.json'),'--experimental'],stdout=subprocess.PIPE,stderr=subprocess.PIPE);clients=[] + try: + for _ in range(100): + if server.poll() is not None:raise RuntimeError(server.stderr.read().decode()) + try:s=socket.create_connection(('127.0.0.1',portnum),timeout=.1);s.close();break + except OSError:time.sleep(.05) + a=Client(portnum,path/'cert.pem','alice','alice-secret');b=Client(portnum,path/'cert.pem','bob','bob-secret');clients=[a,b];a.available();b.available() + a.send("Friends");a.iq('roster') + a.send("");assert any(e.get('jid')=='bob@localhost' for e in a.iq('getroster').iter()) + a.send("");b.take(lambda e:local(e)=='presence' and e.get('type')=='subscribe') + b.send("");b.sync();a.sync() + a.send("");assert any(e.get('subscription')=='to' for e in a.iq('substate').iter()) + a.send(f"") + ask=b.take(lambda e:local(e)=='iq' and e.get('id')=='client-iq');assert ask.get('from')==a.jid + b.send(f"Bob Client");assert a.iq('client-iq').get('from')==b.jid + a.send("archived");b.body('archived') + a.send("resource fallback");assert b.body('resource fallback').get('to')=='bob@localhost/obsolete-resource' + # Inactive CSI holds chat-state noise, but a real message flushes it in order. + b.send("");time.sleep(.05) + a.send("");a.sync() + assert not b.buf,repr(b.buf) + b.sock.settimeout(.15) + try: + unexpected=b.sock.recv(1);raise AssertionError(('CSI leaked unimportant output',unexpected)) + except socket.timeout:pass + finally:b.sock.settimeout(5) + a.send("CSI flush") + noise=b.take(lambda e:e.get('id')=='csi-noise');assert any(n.tag=='{urn:xmpp:delay}delay' for n in noise) + b.body('CSI flush') + b.send("");time.sleep(.05) + a.send("");a.sync();time.sleep(.05) + b.send(' ');b.take(lambda e:e.get('id')=='csi-keepalive') + b.send("") + a.send("");a.iq('mam');assert any(any(n.tag=='{urn:xmpp:mam:2}result' for n in e) for e in a.queue) + b.send("");b.iq('block') + a.send("blocked");a.sync() + b.send("");b.iq('unblock') + a.send("");a.iq('pep') + a.send("");assert any(n.tag=='{eu.siacs.conversations.axolotl}device' for n in a.iq('pepget').iter()) + for c,nick in [(a,'Alice'),(b,'Bob')]: + c.send(f"");c.take(lambda e:local(e)=='presence' and any(n.get('code')=='110' for n in e.iter())) + if c is a:c.send("");c.iq('unlock') + a.send("room chat");assert b.body('room chat').get('from')=='room@conference.localhost/Alice' + b.close();clients.remove(b);time.sleep(.1) + a.send("offline replay");a.sync() + a.send("discard headline");a.sync() + assert not any(e.get('id')=='offline-headline' and e.get('type')=='error' for e in a.queue),'offline headline must be silently discarded' + # no-permanent-store forbids MAM, but must not discard transient offline delivery. + a.send("transient offline") + # Bodyless receipts are also messages handled by native mod_message/mod_offline. + a.send("");a.sync() + b=Client(portnum,path/'cert.pem','bob','bob-secret','second');clients.append(b);b.available();b.body('offline replay');b.body('transient offline') + receipt=b.take(lambda e:e.get('id')=='offline-receipt');assert any(n.tag=='{urn:xmpp:receipts}received' for n in receipt) + b.send("");b.iq('offline-mam') + assert not any(n.tag=='{jabber:client}body' and n.text=='transient offline' for e in b.queue for n in e.iter()),'no-permanent-store leaked into MAM' + b.send("");enabled=b.take(lambda e:e.tag=='{urn:xmpp:sm:3}enabled');resume=enabled.get('id');b.close();clients.remove(b);time.sleep(.1) + a.send("resume replay");a.sync() + b=Client(portnum,path/'cert.pem','bob','bob-secret',bind=False);clients.append(b);b.send(f"");b.take(lambda e:e.tag=='{urn:xmpp:sm:3}resumed');b.body('resume replay');b.send("");b.sync() + print('PASS: integrated TCP roster/subscriptions, client IQ routing, CSI buffering/keepalive flush, MAM, blocking, OMEMO PEP, MUC, offline replay and SM resumption') + finally: + for c in clients:c.close() + server.send_signal(signal.SIGTERM) + try:server.wait(timeout=5) + except subprocess.TimeoutExpired:server.kill();server.wait() + + assert server.returncode==0, f'SIGTERM shutdown failed: {server.returncode}: '+server.stderr.read().decode() + print('PASS: graceful SIGTERM shutdown') diff --git a/scripts/load_smoke.py b/scripts/load_smoke.py new file mode 100644 index 0000000..4174ab0 --- /dev/null +++ b/scripts/load_smoke.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Isolated Linux multi-account TCP/STARTTLS load smoke; stdlib and openssl only. +Reports observations, not production capacity guarantees or memory-leak proof. +""" +import argparse +import asyncio +import base64 +import json +import os +import pathlib +import socket +import ssl +import statistics +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + +STREAM = "" + + +def proc_pid(child_pid): + """Resolve this direct child's procfs PID when /proc exposes the host namespace.""" + status = pathlib.Path('/proc/self/status').read_text() + parent = next(int(line.split()[1]) for line in status.splitlines() if line.startswith('Pid:')) + children = [p.name for p in pathlib.Path('/proc').iterdir() if p.name.isdigit()] + for candidate in children: + try: + fields = dict(line.split(':', 1) for line in pathlib.Path(f'/proc/{candidate}/status').read_text().splitlines() if ':' in line) + except (OSError, ProcessLookupError): + continue + namespaces = fields.get('NSpid', fields['Pid']).split() + if int(namespaces[-1]) == child_pid and int(fields['PPid'].strip()) == parent and fields['Name'].strip() == 'rust-xmpp': + return int(candidate) + raise RuntimeError('cannot resolve owned server process in procfs') + + +def process_metrics(pid): + try: + status = pathlib.Path(f'/proc/{pid}/status').read_text() + rss = next(int(line.split()[1]) for line in status.splitlines() if line.startswith('VmRSS:')) + fields = pathlib.Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split() + cpu = (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK') + return {'rss_kib': rss, 'cpu_seconds': cpu, 'fd_count': len(list(pathlib.Path(f'/proc/{pid}/fd').iterdir()))} + except (OSError, StopIteration): + return {'unavailable': True} + + +def distribution(values): + if not values: + return {} + values = sorted(values) + return {'samples': len(values), 'median_ms': round(statistics.median(values)*1000, 3), + 'p95_ms': round(values[min(len(values)-1, int(len(values)*.95))]*1000, 3), + 'max_ms': round(values[-1]*1000, 3)} + + +class Client: + def __init__(self, number, user, reader, writer): + self.number, self.user, self.reader, self.writer = number, user, reader, writer + self.jid = f"{user}@localhost/load-{number}" + + async def send(self, text): + self.writer.write(text.encode()) + await self.writer.drain() + + async def until(self, end): + return await asyncio.wait_for(self.reader.readuntil(end), 60) + + @classmethod + async def connect(cls, number, port, context, accounts): + reader, writer = await asyncio.wait_for(asyncio.open_connection('127.0.0.1', port), 30) + user = "load" if accounts == 1 else f"load-{number % accounts}" + client = cls(number, user, reader, writer) + try: + await client.send(STREAM) + assert b'starttls' in await client.until(b'') + await client.send("") + assert b'proceed' in await client.until(b'/>') + await writer.start_tls(context, server_hostname='localhost', ssl_handshake_timeout=30) + await client.send(STREAM) + assert b'PLAIN' in await client.until(b'') + auth = base64.b64encode(f'\0{user}\0load-test-password'.encode()).decode() + await client.send(f"{auth}") + assert b'success' in await client.until(b'/>') + await client.send(STREAM) + assert b'xmpp-bind' in await client.until(b'') + await client.send(f"load-{number}") + reply = await client.until(b'') + assert client.jid.encode() in reply, reply + return client + except BaseException: + writer.close() + raise + + async def ping(self, round_number): + start = time.perf_counter() + await self.send(f"") + reply = ET.fromstring(await self.until(b'')) + assert reply.get('type') == 'result' and reply.get('id') == f'ping-{round_number}' + return time.perf_counter()-start + + async def close(self): + try: + await self.send('') + except (OSError, ssl.SSLError): + pass + self.writer.close() + try: + await asyncio.wait_for(self.writer.wait_closed(), 10) + except (OSError, ssl.SSLError, TimeoutError): + pass + + +async def run_load(count, accounts, concurrency, rounds, message_rounds, port, context, pid): + clients, failures, setup_times, samples = [], [], [], [] + sampling = True + + async def sample(): + while sampling: + samples.append(process_metrics(pid)) + await asyncio.sleep(.1) + + sampler = asyncio.create_task(sample()) + gate = asyncio.Semaphore(concurrency) + started = time.perf_counter() + + async def connect(number): + async with gate: + start = time.perf_counter() + try: + client = await Client.connect(number, port, context, accounts) + clients.append(client) + setup_times.append(time.perf_counter()-start) + except Exception as error: + failures.append({'phase': 'connect', 'resource': number, 'error': repr(error)}) + + before = process_metrics(pid) + try: + await asyncio.gather(*(connect(i) for i in range(count))) + setup_seconds = time.perf_counter()-started + connected_metrics = process_metrics(pid) + ping_times = [] + for round_number in range(rounds): + results = await asyncio.gather(*(c.ping(round_number) for c in clients), return_exceptions=True) + for client, result in zip(clients, results): + if isinstance(result, Exception): + failures.append({'phase': 'ping', 'resource': client.number, 'error': repr(result)}) + else: + ping_times.append(result) + # Explicit full JIDs avoid presence fanout and deliver exactly one message per resource. + message_start = time.perf_counter() + clients.sort(key=lambda c: c.number) + delivered = 0 + for batch in range(message_rounds): + for i, client in enumerate(clients): + target = clients[(i+1) % len(clients)] + await client.send(f"load-smoke-{client.number}") + + async def receive_message(index, client): + reply = ET.fromstring(await client.until(b'')) + expected = clients[(index-1) % len(clients)] + assert reply.get('id') == f'm-{batch}-{expected.number}', ET.tostring(reply) + assert reply.get('from') == expected.jid, ET.tostring(reply) + assert reply.find('{jabber:client}body').text == f'load-smoke-{expected.number}' + + results = await asyncio.gather(*(receive_message(i, c) for i, c in enumerate(clients)), return_exceptions=True) + for client, result in zip(clients, results): + if isinstance(result, Exception): + failures.append({'phase': 'message', 'batch': batch, 'resource': client.number, 'error': repr(result)}) + else: + delivered += 1 + message_seconds = time.perf_counter()-message_start + steady = process_metrics(pid) + await asyncio.gather(*(c.close() for c in clients)) + await asyncio.sleep(2) + after = process_metrics(pid) + return {'total_load_wall_seconds': round(time.perf_counter()-started, 3), 'requested_resources': count, 'authenticated_bound_resources': len(clients), + 'connection_setup_concurrency': concurrency, 'setup_wall_seconds': round(setup_seconds, 3), + 'setup_latency': distribution(setup_times), 'ping_rounds': rounds, 'ping_round_trip': distribution(ping_times), + 'accounts': accounts, 'message_rounds': message_rounds, 'messages_sent': len(clients)*message_rounds, 'messages_received': delivered, 'message_batch_seconds': round(message_seconds, 3), + 'metrics_before': before, 'metrics_connected': connected_metrics, 'metrics_after_workload': steady, + 'metrics_two_seconds_after_disconnect': after, + 'sampled_peak_rss_kib': max((s['rss_kib'] for s in samples if 'rss_kib' in s), default=None), + 'fd_delta_after_disconnect': after['fd_count']-before['fd_count'] if 'fd_count' in after and 'fd_count' in before else None, + 'failures': failures, + 'scope': f'One loopback host, {accounts} accounts with distinct resources, TLS+PLAIN, ping and directed messages; not a production benchmark or proof of no memory leaks.'} + finally: + sampling = False + await sampler + await asyncio.gather(*(c.close() for c in clients), return_exceptions=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--binary', default=str(pathlib.Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + parser.add_argument('--clients', type=int, default=500) + parser.add_argument('--archive', action='store_true', help='enable persistent MAM and verify both account archives') + parser.add_argument('--accounts', type=int, default=1) + parser.add_argument('--message-rounds', type=int, default=1) + parser.add_argument('--concurrency', type=int, default=50) + parser.add_argument('--rounds', type=int, default=3) + args = parser.parse_args() + assert 1 <= args.clients <= 4000 and args.concurrency > 0 and args.rounds > 0 and 1 <= args.accounts <= args.clients and args.message_rounds > 0 + with tempfile.TemporaryDirectory(prefix='rust-xmpp-load-smoke-') as directory: + work = pathlib.Path(directory) + cert, key = work/'cert.pem', work/'key.pem' + 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(key), '-out', str(cert)], check=True, capture_output=True) + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + config = {'global': {'data_path': str(work/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], + 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'allow_registration': False, + 'modules_enabled': ['tls', 'saslauth', 'disco', 'ping'], + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}} + if args.archive: + config['global']['modules_enabled'].append('mam') + config['global']['default_archive_policy'] = True + config['global']['archive_expires_after'] = 'never' + path = work/'config.json' + path.write_text(json.dumps(config)) + for account in range(args.accounts): + user = 'load' if args.accounts == 1 else f'load-{account}' + subprocess.run([args.binary, 'create-user', str(path), user, '--password-stdin'], input='load-test-password\n', text=True, capture_output=True, check=True) + with open(work/'server.log', 'w+') as log: + process = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + if process.poll() is not None: + raise RuntimeError('server exited during startup') + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise RuntimeError('startup timed out') + time.sleep(.2) + context = ssl.create_default_context(cafile=str(cert)) + report = asyncio.run(run_load(args.clients, args.accounts, args.concurrency, args.rounds, args.message_rounds, port, context, proc_pid(process.pid))) + if args.archive: + archives = list((work/'data'/'localhost'/'archive').glob('*.list')) + records = sum(sum(1 for line in archive.open() if line.startswith('item(')) for archive in archives) + expected = report['messages_received'] * (1 if args.accounts == 1 else 2) + report['mam'] = {'archive_files': len(archives), 'records': records, 'expected_records': expected} + assert records == expected, report['mam'] + print(json.dumps(report, indent=2)) + if report['failures'] or report['authenticated_bound_resources'] != args.clients: + raise SystemExit(1) + except BaseException: + log.flush() + log.seek(0) + print('SERVER LOG:\n'+log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +if __name__ == '__main__': + main() diff --git a/scripts/make-demo.sh b/scripts/make-demo.sh new file mode 100755 index 0000000..65705f0 --- /dev/null +++ b/scripts/make-demo.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p demo +if [ -e demo/config.toml ]; then + echo 'demo/config.toml already exists; refusing to replace demo' >&2 + exit 1 +fi +umask 077 +openssl req -x509 -newkey rsa:2048 -nodes -days 30 \ + -keyout demo/key.pem -out demo/cert.pem \ + -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' 2>/dev/null +python3 - <<'PY' +from pathlib import Path +import json +p=Path('demo').resolve() +config={'global':{'data_path':str(p/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[55222], +'s2s_ports':[],'http_ports':[],'https_ports':[],'allow_registration':False, +'modules_enabled':['tls','saslauth','disco','ping','version']}, +'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256', +'ssl':{'key':str(p/'key.pem'),'certificate':str(p/'cert.pem')}}}}} +(p/'config.json').write_text(json.dumps(config,indent=2)) +PY +./target/release/rust-xmpp convert demo/config.json toml demo/config.toml +printf '%s\n' 'alice-test-only' | ./target/release/rust-xmpp create-user demo/config.toml alice --password-stdin +printf '%s\n' 'bob-test-only' | ./target/release/rust-xmpp create-user demo/config.toml bob --password-stdin +cat <<'TEXT' +Demo created. Local test credentials: + alice@localhost / alice-test-only + bob@localhost / bob-test-only +Start: ./target/release/rust-xmpp serve demo/config.toml --experimental +Manual clients: server localhost, port 55222, STARTTLS; trust demo/cert.pem. +Automated test: python3 scripts/smoke.py (starts and stops its own server). +TEXT diff --git a/scripts/native-federation.sh b/scripts/native-federation.sh new file mode 100755 index 0000000..37e949d --- /dev/null +++ b/scripts/native-federation.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Native transport interop: built Prosody cafd74b9c5ea + Lua 5.4/TLS dependencies. +set -euo pipefail +: "${PROSODY_SOURCE:?Set PROSODY_SOURCE to the built Prosody cafd74b9c5ea source tree}" +export PROSODY_SOURCE +export PROSODY_LUA="${PROSODY_LUA:-lua5.4}" +# Optional Debian multiarch dependency bundle, e.g. /tmp/xmpp-native/usr. +if [[ -n "${PROSODY_NATIVE_PREFIX:-}" ]]; then + export LD_LIBRARY_PATH="$PROSODY_NATIVE_PREFIX/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LUA_PATH="$PROSODY_NATIVE_PREFIX/share/lua/5.4/?.lua;$PROSODY_NATIVE_PREFIX/share/lua/5.4/?/init.lua;${LUA_PATH:-;}" + export LUA_CPATH="$PROSODY_NATIVE_PREFIX/lib/x86_64-linux-gnu/lua/5.4/?.so;${LUA_CPATH:-;}" +fi +cd "$(dirname "${BASH_SOURCE[0]}")/.." +exec "${CARGO:-cargo}" test --locked --test federation_tls native_prosody_tls_external_bidirectional -- --ignored --nocapture diff --git a/scripts/prosody_compat/README.md b/scripts/prosody_compat/README.md new file mode 100644 index 0000000..8174fdb --- /dev/null +++ b/scripts/prosody_compat/README.md @@ -0,0 +1,66 @@ +# Exact Prosody compatibility oracle + +This harness executes the supplied **Prosody cafd74b9c5ea** implementation with a real Lua 5.4 interpreter and its compiled native modules. It uses the original `startup.make_dummy_hosts` (the upstream prosodyctl bootstrap), `core.modulemanager`, `mod_storage_internal`, and `mod_auth_internal_hashed`. Storage, authentication, stanza serialization and binary archive indexing are not replaced with test doubles. It does not launch a network listener or access a production data directory. + +The storage/auth ignored integration test creates a new temporary directory for each of SHA-1 and SHA-256. It verifies: + +1. Rust creates mixed-key/binary key/value data, preferences, accounts and archives. +2. Original Prosody loads those records, verifies passwords, rotates passwords, creates another account with a binary salt, reads XML namespace attributes, appends and replaces archive entries, and builds a real `.lidx` binary index. +3. Rust reads the modified records and verifies the new passwords, appends another record without rewriting history, and invalidates the upstream index. +4. Original Prosody reads the final records again and authenticates the password changed by Rust. + +This caught and fixed real namespace interoperability problems: canonical Prosody attributes use the `\1` namespace separator, and reserved XML attributes are represented as literal `xml:lang`, etc. Reading also accepts the historical `|` separator. + +## Reproduce on Debian/Ubuntu + +Install build dependencies using your normal package manager: `lua5.4`, `liblua5.4-dev`, `lua-filesystem`, `lua-expat`, `lua-socket`, `lua-sec`, `libssl-dev`, `libicu-dev`, a C compiler, and `make`. + +Extract the supplied source archive into a new test-only directory, then build: + +```bash +cd prosody-cafd74b9c5ea +./configure --ostype=debian --lua-version=5.4 --lua-suffix=5.4 +make -j2 +``` + +From the Rust project: + +```bash +scripts/prosody_compat/verify.sh /absolute/path/to/prosody-cafd74b9c5ea lua5.4 +``` + +`CARGO` can select a cargo executable. `RUSTUP_TOOLCHAIN` defaults to the project toolchain `1.99.1`. If Lua dependencies are extracted to a private prefix instead of installed, set `LUA_PATH` and `LUA_CPATH` to that prefix before invoking the script. + +## Validation performed in this workspace + +The source was built with GCC against Lua 5.4.6, OpenSSL 3, and ICU 74. Real Lua modules came from Ubuntu packages extracted into a private prefix. The oracle completed all four native phases for SHA-1 and SHA-256 successfully. This verifies the tested persistence and password interoperability, **not full Prosody protocol parity**. + +## Additional native module oracles + +The same verification script also runs additional ignored tests against unmodified native modules: + +- `preauth_native`: actual mod_invites consumes a Rust token and creates a native token consumed by Rust; scoped tokens remain inaccessible to native code. +- `pubsub_native`: actual `mod_pep`/`util.pubsub` read Rust items, publish a replacement, and Rust reads it. +- `muc_native`: actual `mod_muc` restores a Rust room/affiliations; `mod_muc_mam` reads Rust `message'); + value.native = {changed=true, number=1.25}; + assert(kv:set('alice', value)); + local prefs = assert(sm.open('example.org', 'archive_prefs')); + assert(prefs:get('alice')[false] == 'roster'); + assert(prefs:set('alice', {[false]=false, ['bob@example.org']=true})); + assert(auth.test_password('alice', 'пароль-Original')); + assert(not auth.test_password('alice', 'wrong')); + assert(auth.set_password('alice', 'пароль-Native')); + assert(auth.create_user('bob', 'native-bob')); + local ok, stored, server = require('prosody.util.sasl.scram').hashers[arg[4] or 'SHA-256']('binary-password', string.char(0,255)..'salt', 10000); + assert(ok); + local hex = require 'prosody.util.hex'; + assert(sm.open('example.org', 'accounts'):set('binary', {salt=string.char(0,255)..'salt', iteration_count=10000,stored_key=hex.encode(stored),server_key=hex.encode(server)})); + assert(auth.test_password('binary', 'binary-password')); + local found = {}; + for id, stanza, when, peer in assert(archive:find('alice')) do + found[#found+1] = id; + assert(stanza.name == 'message'); + assert(stanza.attr['xml:lang'] == 'ru'); + assert(stanza.attr['urn:oracle'..string.char(1)..'flag'] == 'yes'); + assert(stanza:get_child_text('body') == 'Rust body'); + assert(when == 1767225600 + #found - 1); + assert(peer == 'bob@example.org'); + assert(stanza.attr.stamp == nil); + end + assert(#found == 2 and found[1]=='rust-one' and found[2]=='rust-two'); + local native = st.message({xmlns='jabber:client',from='bob@example.org',to='alice@example.org',['xml:lang']='ru', ['urn:oracle'..string.char(1)..'flag']='native'}, 'Native body'); + assert(archive:append('alice', 'native-three', native, 1767225602, 'bob@example.org')); + assert(archive:append('alice', 'rust-two', st.message({xmlns='jabber:client'}, 'Replaced by native'), 1767225603, 'bob@example.org')); + local iter = assert(archive:find('alice', {after='rust-one', with='bob@example.org'})); + assert(iter() == 'native-three');assert(iter() == 'rust-two');assert(iter() == nil); + -- This exercises original binary .lidx creation, not just .list loading. + print('PROSODY_NATIVE_ROUNDTRIP_OK '..(arg[4] or 'SHA-256')); +elseif phase == 'verify-final' then + assert(kv:get('alice').rust_final == true); + assert(auth.test_password('bob', 'rust-bob-new')); + local count=0;for id, stanza in assert(archive:find('alice')) do count=count+1;if count==4 then assert(id=='rust-final');assert(stanza:get_child_text('body')=='Final');end end + assert(count==4); + print('PROSODY_NATIVE_FINAL_OK '..(arg[4] or 'SHA-256')); +else + error('unknown phase'); +end diff --git a/scripts/prosody_compat/verify.sh b/scripts/prosody_compat/verify.sh new file mode 100755 index 0000000..e4a6a0c --- /dev/null +++ b/scripts/prosody_compat/verify.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo 'Usage: verify.sh /absolute/path/to/built/prosody-cafd74b9c5ea [lua5.4]' >&2 + exit 2 +fi +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +reference_dir=$(cd -- "$1" && pwd) +if ! grep -q '^node: cafd74b9c5ea' "$reference_dir/.hg_archival.txt"; then + echo 'Expected the supplied Prosody cafd74b9c5ea source revision.' >&2 + exit 2 +fi +for required in util/encodings.so util/hashes.so util/pposix.so plugins/mod_storage_internal.lua; do + test -f "$reference_dir/$required" || { echo "Missing $required; build Prosody first." >&2; exit 2; } +done +export PROSODY_REFERENCE_SOURCE="$reference_dir" +export PROSODY_REFERENCE_LUA="${2:-lua5.4}" +export RUSTUP_TOOLCHAIN="${RUSTUP_TOOLCHAIN:-1.99.1}" +cd -- "$project_dir" +"${CARGO:-cargo}" test --locked --test reference_prosody --test pubsub_native --test pep_item_order --test muc_native --test muc_state_native --test csi_native --test push --test preauth_native -- --ignored --nocapture diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100644 index 0000000..93f1cbd --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Real TCP/TLS integration test; stdlib only; requires make-demo.sh first.""" +import base64 +import hashlib +import hmac +import re +import signal +import socket +import ssl +import subprocess +import time +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT=Path(__file__).resolve().parents[1] +STREAM="" +class Client: + def __init__(self): + self.sock=socket.create_connection(('127.0.0.1',55222),timeout=5) + self.buf=b'' + self.send(STREAM) + assert b'') + self.send("") + assert b'proceed' in self.until(b'/>') + ctx=ssl.create_default_context(cafile=str(ROOT/'demo/cert.pem')) + self.sock=ctx.wrap_socket(self.sock,server_hostname='localhost') + self.send(STREAM) + assert b'PLAIN' in self.until(b'') + def send(self,s): self.sock.sendall(s.encode()) + def until(self,mark): + while mark not in self.buf: + chunk=self.sock.recv(65536) + if not chunk: raise RuntimeError('unexpected EOF') + self.buf+=chunk + end=self.buf.index(mark)+len(mark) + out,self.buf=self.buf[:end],self.buf[end:] + return out + def auth(self,user,password,fail=False): + encoded=base64.b64encode(('\0'+user+'\0'+password).encode()).decode() + self.send(f"{encoded}") + if fail: + assert b'not-authorized' in self.until(b'');return + assert b'success' in self.until(b'/>') + self.send(STREAM) + assert b'xmpp-bind' in self.until(b'') + self.send("smoke") + assert f'{user}@localhost/smoke'.encode() in self.until(b'') + def close(self): self.sock.close() + +def account_hash_crosscheck(): + record=(ROOT/'demo/data/localhost/accounts/alice.dat').read_text() + def value(k): return re.search(rf'{k}\s*=\s*"([^"]+)"',record).group(1) + iterations=int(re.search(r'iteration_count\s*=\s*(\d+)',record).group(1)) + salted=hashlib.pbkdf2_hmac('sha256',b'alice-test-only',value('salt').encode(),iterations) + stored=hashlib.sha256(hmac.new(salted,b'Client Key','sha256').digest()).hexdigest() + server=hmac.new(salted,b'Server Key','sha256').hexdigest() + assert stored==value('stored_key') and server==value('server_key') + +p=subprocess.Popen([str(ROOT/'target/release/rust-xmpp'),'serve',str(ROOT/'demo/config.toml'),'--experimental'],cwd=ROOT,stdout=subprocess.PIPE,stderr=subprocess.PIPE) +clients=[] +try: + for _ in range(100): + if p.poll() is not None: raise RuntimeError(p.stderr.read().decode()) + try: + probe=socket.create_connection(('127.0.0.1',55222),timeout=.1);probe.close();break + except OSError: time.sleep(.05) + else: raise RuntimeError('server did not start') + account_hash_crosscheck() + a=Client();clients.append(a) + a.auth('alice','wrong',fail=True);a.auth('alice','alice-test-only') + b=Client();clients.append(b);b.auth('bob','bob-test-only') + a.send("");b.send("") + b.send("");b.until(b'') + a.send("") + assert b"type='result'" in a.until(b'') + a.send("Hello & привет") + xml=b.until(b'');m=ET.fromstring(xml) + assert m.attrib['from']=='alice@localhost/smoke' + assert m.find('{jabber:client}body').text=='Hello & привет' + b.send("Reply") + assert ET.fromstring(a.until(b'')).find('{jabber:client}body').text=='Reply' + # Forwarding keeps extension namespace declarations from the stanza root. + a.send("data") + extension=ET.fromstring(b.until(b'')) + assert extension.find('{urn:test:extension}payload').text=='data' + assert extension.attrib['{http://www.w3.org/XML/1998/namespace}lang']=='ru' + a.send("test") + assert b'service-unavailable' in a.until(b'') + a.send("") + assert b'service-unavailable' in a.until(b'') + # Passwords must never be accepted on a plaintext stream. + raw=socket.create_connection(('127.0.0.1',55222),timeout=3) + raw.sendall(STREAM.encode());raw.recv(65536) + raw.sendall(b"eA==") + while True: + chunk=raw.recv(65536) + assert b', s: &str) -> Option { + Some(ModuleResult::reply(iq_result(iq, s))) +} +fn err(iq: Node<'_, '_>, s: &str) -> Option { + Some(ModuleResult::reply(iq_error(iq, s))) +} +fn text<'a>(v: &'a V, k: &str) -> Option<&'a str> { + v.get(k).and_then(V::as_str) +} +fn roster_item(jid: &str, v: &V) -> String { + let mut s = format!( + "'); + if let Some(groups) = v.get("groups") { + for (k, _) in groups.entries() { + if let Some(g) = k.as_str() { + s.push_str(&format!("{}", escape(g))) + } + } + } + s.push_str(""); + s +} +fn roster_version_number(v: &V) -> Option { + match v { + V::Integer(n) => Some(*n), + V::Number(n) + if n.is_finite() && n.fract() == 0.0 && *n >= 0.0 && *n < (i64::MAX as f64) => + { + Some(*n as i64) + } + V::String(v) => std::str::from_utf8(v).ok()?.parse().ok(), + _ => None, + } +} +fn bump(v: &mut V) -> Result<()> { + let mut meta = v.get_key(&V::Bool(false)).cloned().unwrap_or_default(); + ensure!( + !meta + .get("broken") + .is_some_and(|v| !matches!(v, V::Nil | V::Bool(false))), + "refusing to overwrite broken roster" + ); + if meta.get("version") != Some(&V::Bool(true)) { + meta.set( + "version", + V::Integer( + meta.get("version") + .and_then(roster_version_number) + .unwrap_or(0) + .saturating_add(1), + ), + ); + } + v.set_key(V::Bool(false), meta); + Ok(()) +} +fn version(v: &V) -> String { + match v.get_key(&V::Bool(false)).and_then(|v| v.get("version")) { + Some(v) if roster_version_number(v).is_some() => { + roster_version_number(v).unwrap().to_string() + } + Some(V::Bool(true)) => "true".into(), + _ => "1".into(), + } +} +pub fn handle( + root: &Path, + domain: &str, + jid: &str, + iq: Node<'_, '_>, +) -> Result> { + handle_with_vcard_store(root, domain, jid, iq, "vcard") +} +pub fn handle_with_vcard_store( + root: &Path, + domain: &str, + jid: &str, + iq: Node<'_, '_>, + vcard_store: &str, +) -> Result> { + match handle_account_inner(root, domain, jid, iq, vcard_store) { + Ok(r) => Ok(r), + Err(_) => Ok(err(iq, "internal-server-error")), + } +} +fn handle_account_inner( + root: &Path, + domain: &str, + jid: &str, + iq: Node<'_, '_>, + vcard_store: &str, +) -> Result> { + let Some(q) = iq.children().find(|n| n.is_element()) else { + return Ok(None); + }; + let ns = q.tag_name().namespace().unwrap_or(""); + if ![ + "jabber:iq:roster", + "jabber:iq:private", + "vcard-temp", + "urn:xmpp:blocking", + ] + .contains(&ns) + { + return Ok(None); + } + if iq.children().filter(|n| n.is_element()).count() != 1 || iq.attribute("id").is_none() { + return Ok(err(iq, "bad-request")); + } + let typ = iq.attribute("type").unwrap_or(""); + if !["get", "set"].contains(&typ) { + return Ok(None); + } + let own = bare(jid); + let Some((user, host)) = own.split_once('@') else { + return Ok(err(iq, "not-authorized")); + }; + if host != domain { + return Ok(err(iq, "not-authorized")); + } + let target = iq.attribute("to").map(bare).unwrap_or(own); + let self_target = target == own || target == domain; + if ns != "vcard-temp" && !self_target { + return Ok(err(iq, "forbidden")); + } + match ns { + "jabber:iq:roster" => { + if q.tag_name().name() != "query" { + return Ok(err(iq, "bad-request")); + } + if typ == "get" { + let data = storage::load(root, domain, Some(user), "roster")?.unwrap_or_default(); + let ver = version(&data); + if ver != "true" && q.attribute("ver") == Some(ver.as_str()) { + return Ok(reply(iq, "")); + } + let mut s = format!("", escape(&ver)); + for (k, v) in data.entries() { + if let Some(contact) = k.as_str() + && text(v, "subscription").is_some() + { + s.push_str(&roster_item(contact, v)); + } + } + s.push_str(""); + return Ok(reply(iq, &s)); + } + let items: Vec<_> = q.children().filter(|n| n.is_element()).collect(); + if items.len() != 1 || !items[0].has_tag_name(("jabber:iq:roster", "item")) { + return Ok(err(iq, "bad-request")); + } + let item = items[0]; + let Some(contact) = item.attribute("jid") else { + return Ok(err(iq, "bad-request")); + }; + let contact = match normalize_bare_jid(contact) { + Ok(c) => c, + Err(_) => return Ok(err(iq, "jid-malformed")), + }; + if contact == own { + return Ok(err(iq, "not-allowed")); + } + let remove = item.attribute("subscription") == Some("remove"); + let mut pushed = String::new(); + let mut removed = V::Nil; + let mut missing = false; + let data = storage::update(root, domain, Some(user), "roster", |data| { + ensure!( + !data + .get_key(&V::Bool(false)) + .and_then(|m| m.get("broken")) + .is_some_and(|v| !matches!(v, V::Nil | V::Bool(false))), + "refusing to overwrite broken roster" + ); + if remove && data.get(&contact).is_none() { + missing = true; + return Ok(()); + } + if remove { + removed = data.get(&contact).cloned().unwrap_or(V::Nil); + data.set(&contact, V::Nil); + set_pending(data, &contact, V::Nil); + pushed = format!("", escape(&contact)); + } else { + let mut v = data.get(&contact).cloned().unwrap_or_default(); + if v.get("subscription").is_none() { + v.set("subscription", "none".into()) + } + v.set( + "name", + item.attribute("name") + .filter(|n| !n.is_empty()) + .map(V::from) + .unwrap_or(V::Nil), + ); + let mut groups = V::table(); + for g in item + .children() + .filter(|n| n.has_tag_name(("jabber:iq:roster", "group"))) + { + if let Some(text) = g.text() { + groups.set(text, V::Bool(true)); + } + } + v.set("groups", groups); + pushed = roster_item(&contact, &v); + data.set(&contact, v); + } + bump(data)?; + Ok(()) + })?; + if missing { + return Ok(err(iq, "item-not-found")); + } + let mut result = ModuleResult::reply(iq_result(iq, "")); + result.deliveries.push((own.into(),format!("{pushed}",uuid::Uuid::new_v4(),version(&data)))); + if remove && removed != V::Nil { + let (to, from) = directions(&removed); + for (needed, kind) in [ + (to || text(&removed, "ask").is_some(), "unsubscribe"), + (from, "unsubscribed"), + ] { + if needed { + result.deliveries.push(( + contact.clone(), + format!( + "", + escape(own), + escape(&contact) + ), + )); + } + } + if let Some((u, h)) = contact.split_once('@') + && h == domain + && storage::load(root, domain, Some(u), "accounts")?.is_some() + { + let other = storage::update(root, domain, Some(u), "roster", |v| { + if let Some(mut e) = v.get(own).cloned() { + set_subscription(&mut e, false, false); + e.set("ask", V::Nil); + v.set(own, e); + } + set_pending(v, own, V::Nil); + bump(v)?; + Ok(()) + })?; + roster_push(&mut result, &contact, own, &other); + } + } + Ok(Some(result)) + } + "jabber:iq:private" => { + let children: Vec<_> = q.children().filter(|n| n.is_element()).collect(); + if q.tag_name().name() != "query" || children.len() != 1 { + return Ok(err(iq, "bad-request")); + } + let n = children[0]; + let Some(ns) = n.tag_name().namespace() else { + return Ok(err(iq, "bad-request")); + }; + if ns == "jabber:iq:private" { + return Ok(err(iq, "bad-request")); + } + let key = format!("{}:{ns}", n.tag_name().name()); + if typ == "set" { + storage::update(root, domain, Some(user), "private", |v| { + v.set( + &key, + if n.children().any(|c| c.is_element() || c.is_text()) { + storage::stanza_value(n) + } else { + V::Nil + }, + ); + Ok(()) + })?; + Ok(reply(iq, "")) + } else { + let data = storage::load(root, domain, Some(user), "private")?.unwrap_or_default(); + let value = data + .get(&key) + .cloned() + .unwrap_or_else(|| storage::stanza_value(n)); + Ok(reply( + iq, + &format!( + "{}", + storage::stanza_xml(&value)? + ), + )) + } + } + "vcard-temp" => { + if q.tag_name().name() != "vCard" { + return Ok(err(iq, "bad-request")); + } + if typ == "set" { + if target != own { + return Ok(err(iq, "forbidden")); + } + storage::save( + root, + domain, + Some(user), + vcard_store, + &storage::stanza_value(q), + )?; + Ok(reply(iq, "")) + } else { + let target_user = if target == domain { + None + } else { + match target.split_once('@') { + Some((u, h)) if h == domain => Some(u), + _ => return Ok(None), + } + }; + match storage::load(root, domain, target_user, vcard_store)? { + Some(v) => Ok(reply(iq, &storage::stanza_xml(&v)?)), + None => Ok(err(iq, "item-not-found")), + } + } + } + "urn:xmpp:blocking" => { + let name = q.tag_name().name(); + if typ == "get" && name == "blocklist" { + let data = load_blocklist(root, domain, user, true)?; + let mut s = "".to_string(); + for (k, v) in data.entries() { + if !matches!(v, V::Nil | V::Bool(false)) + && let Some(j) = k.as_str() + { + s.push_str(&format!("", escape(j))) + } + } + s.push_str(""); + return Ok(reply(iq, &s)); + } + if typ != "set" || !["block", "unblock"].contains(&name) { + return Ok(err(iq, "bad-request")); + } + let mut contacts = vec![]; + for n in q.children().filter(|n| n.is_element()) { + if !n.has_tag_name(("urn:xmpp:blocking", "item")) { + return Ok(err(iq, "bad-request")); + } + let Some(j) = n.attribute("jid") else { + return Ok(err(iq, "bad-request")); + }; + let j = match normalize_jid(j) { + Ok(j) => j, + Err(_) => return Ok(err(iq, "jid-malformed")), + }; + contacts.push(j); + } + if name == "block" && contacts.is_empty() { + return Ok(err(iq, "bad-request")); + } + let _ = load_blocklist(root, domain, user, true)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + storage::update(root, domain, Some(user), "blocklist", |v| { + let mut meta = v.get_key(&V::Bool(false)).cloned().unwrap_or_default(); + if meta.get("created").is_none() { + meta.set("created", V::Integer(now)); + } + meta.set("modified", V::Integer(now)); + if name == "unblock" && contacts.is_empty() { + *v = V::table() + } else { + for j in &contacts { + v.set( + j, + if name == "block" { + V::Integer(now) + } else { + V::Nil + }, + ); + } + } + v.set_key(V::Bool(false), meta); + Ok(()) + })?; + let mut r = ModuleResult::reply(iq_result(iq, "")); + r.deliveries.push(( + own.into(), + format!( + "{}", + uuid::Uuid::new_v4(), + format_args!( + "<{name} xmlns='urn:xmpp:blocking'>{}", + contacts + .iter() + .map(|j| format!("", escape(j))) + .collect::() + ) + ), + )); + Ok(Some(r)) + } + _ => Ok(None), + } +} +/// Required by stanza routing for both inbound and outbound filtering. +pub fn is_blocked(root: &Path, domain: &str, user: &str, contact: &str) -> Result { + let v = load_blocklist(root, domain, user, true)?; + let b = bare(contact); + let h = b.split_once('@').map(|(_, h)| h).unwrap_or(b); + Ok([contact, b, h].iter().any(|k| { + v.get(k) + .is_some_and(|value| !matches!(value, V::Nil | V::Bool(false))) + })) +} + +pub fn normalize_bare_jid(jid: &str) -> Result { + ensure!(!jid.contains('/'), "bare JID required"); + let (u, h) = match jid.split_once('@') { + Some((u, h)) => (Some(u), h), + None => (None, jid), + }; + let h = stringprep::nameprep(h).map_err(|e| anyhow::anyhow!(e.to_string()))?; + ensure!( + !h.is_empty() + && h.len() <= 1023 + && !h + .chars() + .any(|c| c.is_whitespace() || c.is_control() || "@<>\"'&".contains(c)), + "invalid domain" + ); + if let Some(u) = u { + let u = stringprep::nodeprep(u).map_err(|e| anyhow::anyhow!(e.to_string()))?; + ensure!(!u.is_empty() && u.len() <= 1023, "invalid localpart"); + Ok(format!("{u}@{h}")) + } else { + Ok(h.into_owned()) + } +} +fn normalize_jid(jid: &str) -> Result { + match jid.split_once('/') { + Some((b, r)) => { + let b = normalize_bare_jid(b)?; + let r = stringprep::resourceprep(r).map_err(|e| anyhow::anyhow!(e.to_string()))?; + ensure!(!r.is_empty() && r.len() <= 1023, "invalid resource"); + Ok(format!("{b}/{r}")) + } + None => normalize_bare_jid(jid), + } +} +fn contact_entry(data: &V, contact: &str) -> V { + data.get(contact).cloned().unwrap_or_else(|| { + let mut v = V::table(); + v.set("subscription", "none".into()); + v.set("groups", V::table()); + v + }) +} +fn set_subscription(v: &mut V, to: bool, from: bool) { + v.set( + "subscription", + match (to, from) { + (true, true) => "both", + (true, false) => "to", + (false, true) => "from", + _ => "none", + } + .into(), + ); +} +fn directions(v: &V) -> (bool, bool) { + match text(v, "subscription") { + Some("both") => (true, true), + Some("to") => (true, false), + Some("from") => (false, true), + _ => (false, false), + } +} +fn pending(data: &V, contact: &str) -> bool { + data.get_key(&V::Bool(false)) + .and_then(|m| m.get("pending")) + .and_then(|p| p.get(contact)) + .is_some() +} +fn set_pending(data: &mut V, contact: &str, value: V) { + let mut meta = data.get_key(&V::Bool(false)).cloned().unwrap_or_default(); + let mut p = meta.get("pending").cloned().unwrap_or_default(); + p.set(contact, value); + meta.set("pending", p); + data.set_key(V::Bool(false), meta); +} +fn roster_push(result: &mut ModuleResult, owner: &str, contact: &str, data: &V) { + let item = data + .get(contact) + .map(|v| roster_item(contact, v)) + .unwrap_or_else(|| format!("", escape(contact))); + result.deliveries.push((owner.into(),format!("{item}",uuid::Uuid::new_v4(),version(data)))); +} +/// Apply an authenticated client's subscription transition. Remote delivery is routed by caller. +pub fn subscription( + root: &Path, + domain: &str, + jid: &str, + p: Node<'_, '_>, +) -> Result> { + let kind = p.attribute("type").unwrap_or(""); + if !["subscribe", "subscribed", "unsubscribe", "unsubscribed"].contains(&kind) { + return Ok(None); + } + let Some(raw) = p.attribute("to") else { + return Ok(Some(ModuleResult::default())); + }; + let target = normalize_bare_jid(bare(raw))?; + let sender = bare(jid); + let (user, h) = sender + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("unauthenticated sender"))?; + ensure!(h == domain, "wrong domain"); + if sender == target { + return Ok(Some(ModuleResult::default())); + } + if is_blocked(root, domain, user, &target)? { + return Ok(Some(ModuleResult::default())); + } + let mut r = ModuleResult::default(); + let mut deliver = true; + let data = storage::update(root, domain, Some(user), "roster", |data| { + let mut entry = contact_entry(data, &target); + let (to, from) = directions(&entry); + match kind { + "subscribe" => { + if to { + deliver = false + } else { + entry.set("ask", "subscribe".into()) + } + } + "subscribed" => { + if pending(data, &target) { + set_pending(data, &target, V::Nil); + set_subscription(&mut entry, to, true) + } else if !from { + entry.set("approved", "true".into()); + deliver = false + } + } + "unsubscribe" => { + entry.set("ask", V::Nil); + set_subscription(&mut entry, false, from) + } + "unsubscribed" => { + set_pending(data, &target, V::Nil); + entry.set("approved", V::Nil); + set_subscription(&mut entry, to, false) + } + _ => {} + } + data.set(&target, entry); + bump(data)?; + Ok(()) + })?; + roster_push(&mut r, sender, &target, &data); + if !deliver { + return Ok(Some(r)); + } + let (to_user, to_host) = target.split_once('@').unwrap_or(("", &target)); + let stanza = crate::protocol::address(p, sender, &target); + if to_host == domain { + if storage::load(root, domain, Some(to_user), "accounts")?.is_none() { + return Ok(Some(r)); + } + if is_blocked(root, domain, to_user, sender)? { + return Ok(Some(r)); + } + let preauthenticated = match crate::preauth::token_from_presence(p) { + Some(token) => crate::preauth::consume( + root, + domain, + &target, + sender, + token, + chrono::Utc::now().timestamp(), + )?, + None => false, + }; + let mut autoapprove = false; + let recipient = storage::update(root, domain, Some(to_user), "roster", |data| { + let mut entry = contact_entry(data, sender); + let (to, from) = directions(&entry); + match kind { + "subscribe" => { + if preauthenticated || from || text(&entry, "approved") == Some("true") { + autoapprove = true; + set_pending(data, sender, V::Nil); + set_subscription(&mut entry, to, true); + } else { + let d = roxmltree::Document::parse(&stanza)?; + set_pending(data, sender, storage::stanza_value(d.root_element())); + } + } + "subscribed" => { + if text(&entry, "ask") == Some("subscribe") { + entry.set("ask", V::Nil); + set_subscription(&mut entry, true, from) + } else { + deliver = false + } + } + "unsubscribe" => { + set_pending(data, sender, V::Nil); + set_subscription(&mut entry, to, false) + } + "unsubscribed" => { + entry.set("ask", V::Nil); + set_subscription(&mut entry, false, from) + } + _ => {} + } + data.set(sender, entry); + bump(data)?; + Ok(()) + })?; + roster_push(&mut r, &target, sender, &recipient); + if autoapprove { + let own = storage::update(root, domain, Some(user), "roster", |data| { + let mut entry = contact_entry(data, &target); + let (_, from) = directions(&entry); + entry.set("ask", V::Nil); + set_subscription(&mut entry, true, from); + data.set(&target, entry); + bump(data)?; + Ok(()) + })?; + roster_push(&mut r, sender, &target, &own); + r.deliveries.push(( + sender.into(), + format!( + "", + escape(&target), + escape(sender) + ), + )); + deliver = false; + } + if preauthenticated { + let reciprocal = format!( + "", + escape(sender) + ); + let doc = roxmltree::Document::parse(&reciprocal)?; + if let Some(mut result) = subscription(root, domain, &target, doc.root_element())? { + r.deliveries.append(&mut result.deliveries); + r.replies.append(&mut result.replies); + } + } + } + if deliver { + r.deliveries.push((target, stanza)); + } + Ok(Some(r)) +} +pub fn subscribers(root: &Path, domain: &str, user: &str) -> Result> { + contacts(root, domain, user, false) +} +pub fn subscriptions(root: &Path, domain: &str, user: &str) -> Result> { + contacts(root, domain, user, true) +} +fn contacts(root: &Path, domain: &str, user: &str, outbound: bool) -> Result> { + let d = storage::load(root, domain, Some(user), "roster")?.unwrap_or_default(); + Ok(d.entries() + .iter() + .filter_map(|(k, v)| { + let (t, f) = directions(v); + if if outbound { t } else { f } { + k.as_str().map(str::to_owned) + } else { + None + } + }) + .collect()) +} +/// Pending subscription requests must be replayed on initial available presence. +pub fn pending_subscriptions(root: &Path, domain: &str, user: &str) -> Result> { + let data = storage::load(root, domain, Some(user), "roster")?.unwrap_or_default(); + let mut out = vec![]; + if let Some(p) = data.get_key(&V::Bool(false)).and_then(|m| m.get("pending")) { + for (k, v) in p.entries() { + if let Some(sender) = k.as_str() { + out.push(if *v == V::Bool(true) { + format!( + "", + escape(sender), + escape(user), + escape(domain) + ) + } else { + storage::stanza_xml(v)? + }); + } + } + } + Ok(out) +} +/// Persist eligible messages for delivery after the recipient becomes available. +pub fn offline_enqueue(root: &Path, domain: &str, user: &str, xml: &str) -> Result { + let d = roxmltree::Document::parse(xml)?; + let n = d.root_element(); + if n.tag_name().name() != "message" + || !["chat", "normal"].contains(&n.attribute("type").unwrap_or("normal")) + { + return Ok(false); + } + if n.children().any(|n| { + n.is_element() + && n.tag_name().namespace() == Some("urn:xmpp:hints") + && ["no-store", "no-storage"].contains(&n.tag_name().name()) + }) { + return Ok(false); + } + if storage::load(root, domain, Some(user), "accounts")?.is_none() { + return Ok(false); + } + crate::archive::append_new( + root, + domain, + Some(user), + "offline", + crate::archive::Entry { + id: uuid::Uuid::new_v4().to_string(), + when: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(), + peer: "".into(), + xml: xml.into(), + }, + )?; + Ok(true) +} +/// Returns IDs with stanzas; delete IDs only after queueing them successfully to recipient. +pub fn offline_pending(root: &Path, domain: &str, user: &str) -> Result> { + crate::archive::load_store(root, domain, Some(user), "offline")? + .into_iter() + .map(|e| { + let d = roxmltree::Document::parse(&e.xml)?; + let mut value = storage::stanza_value(d.root_element()); + let delay = format!( + "", + escape(domain), + crate::archive::timestamp(e.when)? + ); + let dd = roxmltree::Document::parse(&delay)?; + let mut i = 1; + while value.get_key(&V::Integer(i)).is_some() { + i += 1; + } + value.set_key(V::Integer(i), storage::stanza_value(dd.root_element())); + Ok((e.id, storage::stanza_xml(&value)?)) + }) + .collect() +} +const PUBSUB: &str = "http://jabber.org/protocol/pubsub"; +static LEGACY_PEP_LIMITS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); +/// Set once when constructing a runtime, matching the host's pep_max_items option. +pub fn set_legacy_pep_limit(root: &Path, domain: &str, max_items: usize) -> Result<()> { + ensure!(max_items > 0, "PEP item limit must be positive"); + LEGACY_PEP_LIMITS + .lock() + .map_err(|_| anyhow::anyhow!("PEP limit lock poisoned"))? + .insert((root.to_owned(), domain.into()), max_items); + Ok(()) +} + +fn pep_request( + root: &Path, + domain: &str, + actor: &str, + owner: &str, + kind: &str, + body: &str, + owner_ns: bool, +) -> Result { + let ns = if owner_ns { + "http://jabber.org/protocol/pubsub#owner" + } else { + PUBSUB + }; + let s = format!( + "{body}", + escape(owner) + ); + let d = roxmltree::Document::parse(&s)?; + let max_items = LEGACY_PEP_LIMITS + .lock() + .map_err(|_| anyhow::anyhow!("PEP limit lock poisoned"))? + .get(&(root.to_owned(), domain.into())) + .copied() + .unwrap_or(256); + let options = crate::pubsub::Options { + max_items, + ..Default::default() + }; + crate::pubsub::handle_with_options(root, domain, actor, d.root_element(), &options)? + .ok_or_else(|| anyhow::anyhow!("PEP target unsupported")) +} +fn pep_items( + root: &Path, + domain: &str, + actor: &str, + owner: &str, + node: &str, +) -> Result> { + let r = pep_request( + root, + domain, + actor, + owner, + "get", + &format!( + "", + escape(node), + usize::MAX + ), + false, + )?; + let s = r + .replies + .first() + .ok_or_else(|| anyhow::anyhow!("missing PEP response"))?; + let d = roxmltree::Document::parse(s)?; + if d.root_element().attribute("type") == Some("error") { + if d.descendants().any(|n| n.has_tag_name("item-not-found")) { + return Ok(vec![]); + } + anyhow::bail!("PEP retrieval denied"); + } + Ok(d.descendants() + .filter(|n| { + n.is_element() + && n.tag_name().name() == "item" + && n.tag_name().namespace() == Some(PUBSUB) + }) + .map(|n| { + ( + n.attribute("id").unwrap_or("").into(), + crate::protocol::xml(n), + ) + }) + .collect()) +} +// Explicit storage coordinates keep the compatibility helper independent of runtime state. +#[allow(clippy::too_many_arguments)] +fn pep_publish( + root: &Path, + domain: &str, + actor: &str, + node: &str, + id: &str, + payload: &str, + access: &str, + max: &str, +) -> Result> { + let owner = bare(actor); + let create = pep_request( + root, + domain, + actor, + owner, + "set", + &format!( + "http://jabber.org/protocol/pubsub#node_config{access}{max}", + escape(node) + ), + false, + )?; + if create + .replies + .iter() + .any(|x| x.contains("type='error'") && !x.contains("conflict")) + { + anyhow::bail!("PEP node creation rejected") + } + let r = pep_request( + root, + domain, + actor, + owner, + "set", + &format!( + "{payload}", + escape(node), + escape(id) + ), + false, + )?; + if r.replies.iter().any(|x| x.contains("type='error'")) { + anyhow::bail!("PEP publish rejected") + }; + Ok(r.deliveries) +} +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option> { + n.children() + .find(|n| n.is_element() && n.tag_name().name() == name) +} +fn child_text<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<&'a str> { + child(n, name).and_then(|n| n.text()) +} +fn tag(name: &str, text: Option<&str>) -> String { + format!("<{name}>{}", escape(text.unwrap_or(""))) +} +fn category(n: Node<'_, '_>, v4: bool) -> String { + if v4 { + let typ = if child(n, "HOME").is_some() { + "home" + } else if child(n, "WORK").is_some() { + "work" + } else { + "" + }; + format!( + "{}", + tag("text", Some(typ)) + ) + } else { + let typ = child(n, "parameters") + .and_then(|p| child(p, "type")) + .and_then(|t| child_text(t, "text")); + match typ { + Some("home") => "".into(), + Some("work") => "".into(), + _ => String::new(), + } + } +} +/// XEP-0398 conversion following the pinned Prosody module's field mappings. +pub type AvatarPayload = (String, String, String); +pub fn vcard_to_pep(n: Node<'_, '_>) -> Result<(String, Option)> { + use base64::Engine; + use sha1::{Digest, Sha1}; + let mut s = "".to_string(); + s.push_str(&format!("{}", tag("text", child_text(n, "FN")))); + s.push_str(""); + let name = child(n, "N"); + for (a, b) in [ + ("surname", "FAMILY"), + ("given", "GIVEN"), + ("additional", "MIDDLE"), + ("prefix", "PREFIX"), + ("suffix", "SUFFIX"), + ] { + s.push_str(&tag(a, name.and_then(|n| child_text(n, b)))) + } + s.push_str(""); + let mut avatar = None; + for c in n.children().filter(|n| n.is_element()) { + let name = c.tag_name().name(); + let lower = name.to_lowercase(); + let simple = match name { + "NICKNAME" | "TITLE" | "ROLE" | "CATEGORIES" | "NOTE" => Some("text"), + "URL" => Some("uri"), + "BDAY" => Some("date"), + _ => None, + }; + if let Some(typ) = simple { + s.push_str(&format!("<{lower}>{}", tag(typ, c.text()))); + continue; + } + match name { + "EMAIL" => { + if let Some(t) = child_text(c, "USERID") { + s.push_str(&format!( + "{}{}", + tag("text", Some(t)), + category(c, true) + )) + } + } + "TEL" => { + if let Some(t) = child_text(c, "NUMBER") { + s.push_str(&format!( + "{}{}", + tag("uri", Some(&format!("tel:{t}"))), + category(c, true) + )) + } + } + "ORG" => s.push_str(&format!( + "{}", + tag("text", child_text(c, "ORGNAME")) + )), + "DESC" => s.push_str(&format!("{}", tag("text", c.text()))), + "ADR" => { + s.push_str(""); + for (a, b) in [ + ("pobox", "POBOX"), + ("ext", "EXTADD"), + ("street", "STREET"), + ("locality", "LOCALITY"), + ("region", "REGION"), + ("code", "PCODE"), + ("country", "CTRY"), + ] { + s.push_str(&tag(a, child_text(c, b))) + } + s.push_str(&category(c, true)); + s.push_str("") + } + "JABBERID" => s.push_str(&format!( + "{}", + tag("uri", Some(&format!("xmpp:{}", c.text().unwrap_or("")))) + )), + "PHOTO" => { + if let Some(payload) = child_text(c, "BINVAL") { + let compact: String = payload.chars().filter(|c| !c.is_whitespace()).collect(); + let raw = base64::engine::general_purpose::STANDARD.decode(&compact)?; + ensure!(raw.len() <= 1024 * 1024, "avatar too large"); + let hash = hex::encode(Sha1::digest(&raw)); + let data = format!( + "{}", + escape(&compact) + ); + let mime = child_text(c, "TYPE") + .map(|t| format!(" type='{}'", escape(t))) + .unwrap_or_default(); + let metadata = format!( + "", + raw.len() + ); + avatar = Some((hash, data, metadata)); + } + } + _ => {} + } + } + s.push_str(""); + Ok((s, avatar)) +} +fn v4_to_temp(n: Node<'_, '_>) -> String { + let mut s = tag("FN", child(n, "fn").and_then(|n| child_text(n, "text"))); + s.push_str(""); + let name = child(n, "n"); + for (a, b) in [ + ("FAMILY", "surname"), + ("GIVEN", "given"), + ("MIDDLE", "additional"), + ("PREFIX", "prefix"), + ("SUFFIX", "suffix"), + ] { + s.push_str(&tag(a, name.and_then(|n| child_text(n, b)))) + } + s.push_str(""); + for c in n.children().filter(|n| n.is_element()) { + let name = c.tag_name().name(); + let simple = match name { + "nickname" | "title" | "role" | "categories" | "note" => Some("text"), + "url" => Some("uri"), + "bday" => Some("date"), + _ => None, + }; + if let Some(t) = simple { + s.push_str(&tag(&name.to_uppercase(), child_text(c, t))); + continue; + } + match name { + "email" => s.push_str(&format!( + "{}{}", + tag("USERID", child_text(c, "text")), + category(c, false) + )), + "tel" => s.push_str(&format!( + "{}{}", + tag( + "NUMBER", + child_text(c, "uri").map(|v| v.strip_prefix("tel:").unwrap_or(v)) + ), + category(c, false) + )), + "org" => s.push_str(&format!( + "{}", + tag("ORGNAME", child_text(c, "text")) + )), + "impp" => { + if let Some(v) = child_text(c, "uri").and_then(|v| v.strip_prefix("xmpp:")) { + s.push_str(&tag("JABBERID", Some(v))) + } + } + "adr" => { + s.push_str(""); + for (a, b) in [ + ("POBOX", "pobox"), + ("EXTADD", "ext"), + ("STREET", "street"), + ("LOCALITY", "locality"), + ("REGION", "region"), + ("PCODE", "code"), + ("CTRY", "country"), + ] { + s.push_str(&tag(a, child_text(c, b))) + } + s.push_str(&category(c, false)); + s.push_str("") + } + _ => {} + } + } + s +} +pub fn handle_legacy( + root: &Path, + domain: &str, + jid: &str, + iq: Node<'_, '_>, +) -> Result> { + let Some(q) = iq.children().find(|n| n.is_element()) else { + return Ok(None); + }; + let ns = q.tag_name().namespace().unwrap_or(""); + if !["vcard-temp", "urn:ietf:params:xml:ns:vcard-4.0"].contains(&ns) { + return handle_bookmarks(root, domain, jid, iq); + } + if iq.children().filter(|n| n.is_element()).count() != 1 || iq.attribute("id").is_none() { + return Ok(err(iq, "bad-request")); + } + if (ns == "vcard-temp" && q.tag_name().name() != "vCard") + || (ns == "urn:ietf:params:xml:ns:vcard-4.0" && q.tag_name().name() != "vcard") + { + return Ok(err(iq, "bad-request")); + } + let typ = iq.attribute("type").unwrap_or(""); + if !["get", "set"].contains(&typ) { + return Ok(None); + } + let owner = bare(iq.attribute("to").unwrap_or(jid)); + if !owner.ends_with(&format!("@{domain}")) { + return Ok(None); + } + if typ == "set" { + if owner != bare(jid) { + return Ok(err(iq, "forbidden")); + } + let mut r = ModuleResult::reply(iq_result(iq, "")); + if ns == "vcard-temp" { + let (v4, avatar) = match vcard_to_pep(q) { + Ok(v) => v, + Err(_) => return Ok(err(iq, "bad-request")), + }; + for node in ["urn:xmpp:avatar:metadata", "urn:xmpp:avatar:data"] { + let purge = pep_request( + root, + domain, + jid, + owner, + "set", + &format!(""), + true, + )?; + r.deliveries.extend(purge.deliveries); + } + if let Some((hash, data, meta)) = avatar { + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:avatar:data", + &hash, + &data, + "open", + "1", + )?); + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:avatar:metadata", + &hash, + &meta, + "open", + "1", + )?); + } + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:vcard4", + "current", + &v4, + "open", + "1", + )?); + } else { + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:vcard4", + "current", + &crate::protocol::xml(q), + "presence", + "1", + )?); + } + return Ok(Some(r)); + } + let items = match pep_items(root, domain, jid, owner, "urn:xmpp:vcard4") { + Ok(v) => v, + Err(_) => return Ok(err(iq, "forbidden")), + }; + let v4 = items + .last() + .and_then(|(_, s)| roxmltree::Document::parse(s).ok()) + .map(|d| { + d.root_element() + .children() + .find(|n| n.is_element()) + .map(crate::protocol::xml) + .unwrap_or_default() + }); + if ns != "vcard-temp" { + return Ok(match v4 { + Some(s) => reply(iq, &s), + None => err(iq, "item-not-found"), + }); + } + let mut s = "".to_string(); + if let Some(v4) = v4 { + let d = roxmltree::Document::parse(&v4)?; + s.push_str(&v4_to_temp(d.root_element())); + } else if let Ok(items) = pep_items(root, domain, jid, owner, "http://jabber.org/protocol/nick") + && let Some((_, x)) = items.last() + { + let d = roxmltree::Document::parse(x)?; + s.push_str(&tag("NICKNAME", child_text(d.root_element(), "nick"))); + } + if let Ok(items) = pep_items(root, domain, jid, owner, "urn:xmpp:avatar:metadata") + && let Some((_, x)) = items.last() + { + let d = roxmltree::Document::parse(x)?; + if let Some(info) = d.descendants().find(|n| n.has_tag_name("info")) { + s.push_str(""); + if let Some(t) = info.attribute("type") { + s.push_str(&tag("TYPE", Some(t))) + } + if let Some(url) = info.attribute("url") { + s.push_str(&tag("EXTVAL", Some(url))) + } else if let Some(hash) = info.attribute("id") + && let Ok(data) = pep_items(root, domain, jid, owner, "urn:xmpp:avatar:data") + && let Some((_, x)) = data.iter().find(|(id, _)| id == hash) + { + let d = roxmltree::Document::parse(x)?; + s.push_str(&tag("BINVAL", child_text(d.root_element(), "data"))) + } + s.push_str(""); + } + } + s.push_str(""); + Ok(reply(iq, &s)) +} +pub fn legacy_bookmarks(root: &Path, domain: &str, actor: &str) -> Result { + legacy_bookmark_storage(&pep_items( + root, + domain, + actor, + bare(actor), + "urn:xmpp:bookmarks:1", + )?) +} +pub fn legacy_bookmark_storage(items: &[(String, String)]) -> Result { + let mut s = "".to_string(); + for (id, x) in items { + let d = roxmltree::Document::parse(x)?; + if let Some(c) = child(d.root_element(), "conference") { + s.push_str(&format!("'); + for name in ["nick", "password"] { + if let Some(t) = child_text(c, name) { + s.push_str(&tag(name, Some(t))) + } + } + s.push_str(""); + } + } + s.push_str(""); + Ok(s) +} +fn save_bookmarks( + root: &Path, + domain: &str, + actor: &str, + bookmarks: Node<'_, '_>, + synchronize: bool, +) -> Result> { + let previous = pep_items(root, domain, actor, bare(actor), "urn:xmpp:bookmarks:1")?; + let mut keep = vec![]; + let mut out = vec![]; + // Validate the entire request before mutating any node. + for c in bookmarks + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "conference") + { + normalize_bare_jid(c.attribute("jid").unwrap_or(""))?; + } + for c in bookmarks + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "conference") + { + let id = normalize_bare_jid(c.attribute("jid").unwrap())?; + keep.push(id.clone()); + let mut payload = "'); + for name in ["nick", "password"] { + if let Some(t) = child_text(c, name) { + payload.push_str(&tag(name, Some(t))) + } + } + payload.push_str(""); + out.extend(pep_publish( + root, + domain, + actor, + "urn:xmpp:bookmarks:1", + &id, + &payload, + "whitelist", + "max", + )?); + } + if synchronize { + for (id, _) in previous { + if !keep.contains(&id) { + let r = pep_request( + root, + domain, + actor, + bare(actor), + "set", + &format!( + "", + escape(&id) + ), + false, + )?; + ensure!( + !r.replies.iter().any(|s| s.contains("type='error'")), + "bookmark retract failed" + ); + out.extend(r.deliveries); + } + } + } + Ok(out) +} +fn handle_bookmarks( + root: &Path, + domain: &str, + jid: &str, + iq: Node<'_, '_>, +) -> Result> { + let Some(q) = iq.children().find(|n| n.is_element()) else { + return Ok(None); + }; + let ns = q.tag_name().namespace().unwrap_or(""); + let typ = iq.attribute("type").unwrap_or(""); + if !["get", "set"].contains(&typ) { + return Ok(None); + } + let private = ns == "jabber:iq:private" + && child(q, "storage") + .is_some_and(|n| n.tag_name().namespace() == Some("storage:bookmarks")); + let pubsub = ns == PUBSUB + && q.children() + .any(|n| n.is_element() && n.attribute("node") == Some("storage:bookmarks")); + if !private && !pubsub { + return Ok(None); + } + if (private && q.tag_name().name() != "query") || (pubsub && q.tag_name().name() != "pubsub") { + return Ok(err(iq, "bad-request")); + } + if private && q.children().filter(|n| n.is_element()).count() != 1 { + return Ok(err(iq, "bad-request")); + } + if pubsub + && typ == "get" + && !q.children().any(|n| { + n.has_tag_name((PUBSUB, "items")) && n.attribute("node") == Some("storage:bookmarks") + }) + { + return Ok(err(iq, "bad-request")); + } + if iq.children().filter(|n| n.is_element()).count() != 1 || iq.attribute("id").is_none() { + return Ok(err(iq, "bad-request")); + } + let owner = bare(iq.attribute("to").unwrap_or(jid)); + if owner != bare(jid) { + return Ok(err(iq, "forbidden")); + } + if typ == "get" { + let storage = legacy_bookmarks(root, domain, jid)?; + let payload = if private { + format!("{storage}") + } else { + format!( + "{storage}" + ) + }; + return Ok(reply(iq, &payload)); + } + let bookmarks = if private { + child(q, "storage") + } else { + child(q, "publish") + .and_then(|n| child(n, "item")) + .and_then(|n| child(n, "storage")) + }; + let Some(bookmarks) = bookmarks else { + return Ok(err(iq, "bad-request")); + }; + let deliveries = match save_bookmarks(root, domain, jid, bookmarks, true) { + Ok(v) => v, + Err(_) => return Ok(err(iq, "bad-request")), + }; + let mut r = ModuleResult::reply(iq_result(iq, "")); + r.deliveries = deliveries; + Ok(Some(r)) +} +/// Migrate legacy stores without replacing existing PEP items; remove source only after success. +pub fn legacy_on_bind(root: &Path, domain: &str, jid: &str) -> Result { + let Some((user, h)) = bare(jid).split_once('@') else { + return Ok(ModuleResult::default()); + }; + ensure!(h == domain, "wrong domain"); + let mut r = ModuleResult::default(); + if let Some(v) = storage::load(root, domain, Some(user), "vcard")? { + let s = storage::stanza_xml(&v)?; + let d = roxmltree::Document::parse(&s)?; + let (v4, avatar) = vcard_to_pep(d.root_element())?; + if pep_items(root, domain, jid, bare(jid), "urn:xmpp:vcard4")?.is_empty() { + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:vcard4", + "current", + &v4, + "open", + "1", + )?); + } + if pep_items(root, domain, jid, bare(jid), "urn:xmpp:avatar:metadata")?.is_empty() + && pep_items(root, domain, jid, bare(jid), "urn:xmpp:avatar:data")?.is_empty() + && let Some((id, data, meta)) = avatar + { + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:avatar:data", + &id, + &data, + "open", + "1", + )?); + r.deliveries.extend(pep_publish( + root, + domain, + jid, + "urn:xmpp:avatar:metadata", + &id, + &meta, + "open", + "1", + )?); + } + storage::save(root, domain, Some(user), "vcard", &V::Nil)?; + } + let old = pep_items(root, domain, jid, bare(jid), "storage:bookmarks")?; + for (_, item) in &old { + let d = roxmltree::Document::parse(item)?; + if let Some(bookmarks) = child(d.root_element(), "storage") { + r.deliveries + .extend(save_bookmarks(root, domain, jid, bookmarks, false)?); + } + } + if !old.is_empty() { + let purge = pep_request( + root, + domain, + jid, + bare(jid), + "set", + "", + true, + )?; + ensure!( + !purge.replies.iter().any(|s| s.contains("type='error'")), + "legacy bookmarks purge failed" + ); + } + if let Some(data) = storage::load(root, domain, Some(user), "private")? + && let Some(b) = data.get("storage:storage:bookmarks") + { + let s = storage::stanza_xml(b)?; + let d = roxmltree::Document::parse(&s)?; + r.deliveries + .extend(save_bookmarks(root, domain, jid, d.root_element(), false)?); + storage::update(root, domain, Some(user), "private", |v| { + v.set("storage:storage:bookmarks", V::Nil); + Ok(()) + })?; + } + Ok(r) +} +pub fn avatar_presence( + root: &Path, + domain: &str, + jid: &str, + presence: Node<'_, '_>, +) -> Result { + if presence.attribute("type").is_some() { + return Ok(crate::protocol::xml(presence)); + } + let mut v = storage::stanza_value(presence); + if presence.children().any(|n| { + n.is_element() + && n.tag_name().namespace() == Some("vcard-temp:x:update") + && child(n, "photo").is_some() + }) { + return storage::stanza_xml(&v); + } + let items = pep_items(root, domain, jid, bare(jid), "urn:xmpp:avatar:metadata")?; + let hash = items.last().map(|(id, _)| id.as_str()).unwrap_or(""); + let s = format!( + "{}", + escape(hash) + ); + let d = roxmltree::Document::parse(&s)?; + let mut i = 1; + while v.get_key(&V::Integer(i)).is_some() { + i += 1 + } + v.set_key(V::Integer(i), storage::stanza_value(d.root_element())); + storage::stanza_xml(&v) +} +/// Caller must authenticate the remote domain before invoking this federation handler. +pub fn inbound_subscription( + root: &Path, + domain: &str, + from: &str, + p: Node<'_, '_>, +) -> Result> { + let kind = p.attribute("type").unwrap_or(""); + if !["subscribe", "subscribed", "unsubscribe", "unsubscribed"].contains(&kind) { + return Ok(None); + } + let sender = normalize_bare_jid(bare(from))?; + let target = normalize_bare_jid(bare(p.attribute("to").unwrap_or("")))?; + let (user, host) = target + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("recipient must be account"))?; + ensure!(host == domain, "not local recipient"); + if storage::load(root, domain, Some(user), "accounts")?.is_none() + || is_blocked(root, domain, user, &sender)? + { + return Ok(Some(ModuleResult::default())); + } + let preauthenticated = match crate::preauth::token_from_presence(p) { + Some(token) => crate::preauth::consume( + root, + domain, + &target, + &sender, + token, + chrono::Utc::now().timestamp(), + )?, + None => false, + }; + let mut deliver = true; + let mut autoapprove = false; + let stanza = crate::protocol::address(p, &sender, &target); + let data = storage::update(root, domain, Some(user), "roster", |data| { + let mut entry = contact_entry(data, &sender); + let (to, from) = directions(&entry); + match kind { + "subscribe" => { + if preauthenticated || from || text(&entry, "approved") == Some("true") { + autoapprove = true; + set_pending(data, &sender, V::Nil); + set_subscription(&mut entry, to, true) + } else { + let d = roxmltree::Document::parse(&stanza)?; + set_pending(data, &sender, storage::stanza_value(d.root_element())) + } + } + "subscribed" => { + if text(&entry, "ask") == Some("subscribe") { + entry.set("ask", V::Nil); + set_subscription(&mut entry, true, from) + } else { + deliver = false + } + } + "unsubscribe" => { + set_pending(data, &sender, V::Nil); + set_subscription(&mut entry, to, false) + } + "unsubscribed" => { + entry.set("ask", V::Nil); + set_subscription(&mut entry, false, from) + } + _ => {} + } + data.set(&sender, entry); + bump(data)?; + Ok(()) + })?; + let mut r = ModuleResult::default(); + roster_push(&mut r, &target, &sender, &data); + if autoapprove { + r.deliveries.push(( + sender.clone(), + format!( + "", + escape(&target), + escape(&sender) + ), + )); + deliver = false; + } + if preauthenticated { + let reciprocal = format!( + "", + escape(&sender) + ); + let doc = roxmltree::Document::parse(&reciprocal)?; + if let Some(mut result) = subscription(root, domain, &target, doc.root_element())? { + r.deliveries.append(&mut result.deliveries); + r.replies.append(&mut result.replies); + } + } + if deliver { + r.deliveries.push((target, stanza)); + } + Ok(Some(r)) +} +/// The caller must disconnect all user sessions before calling; does not send stanzas. +pub fn remove_account(root: &Path, domain: &str, user: &str) -> Result<()> { + ensure!(!user.is_empty(), "empty username"); + crate::archive::with_exclusive_access(|| storage::purge_user(root, domain, user)) +} +/// Migrate only the legacy default deny-JID privacy list, matching mod_blocklist. +pub fn load_blocklist(root: &Path, domain: &str, user: &str, migrate: bool) -> Result { + if let Some(data) = storage::load(root, domain, Some(user), "blocklist")? { + return Ok(data); + } + if !migrate || storage::load(root, domain, Some(user), "accounts")?.is_none() { + return Ok(V::table()); + } + let Some(privacy) = storage::load(root, domain, Some(user), "privacy")? else { + return Ok(V::table()); + }; + let Some(default) = privacy.get("default").and_then(V::as_str) else { + return Ok(V::table()); + }; + let Some(items) = privacy + .get("lists") + .and_then(|v| v.get(default)) + .and_then(|v| v.get("items")) + else { + return Ok(V::table()); + }; + let mut migrated = V::table(); + for (_, item) in items.entries() { + if text(item, "type") == Some("jid") + && text(item, "action") == Some("deny") + && let Some(j) = text(item, "value") + && let Ok(j) = normalize_jid(j) + { + migrated.set(&j, V::Bool(true)); + } + } + let mut metadata = V::table(); + metadata.set( + "created", + V::Integer( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64, + ), + ); + metadata.set("migrated", "privacy".into()); + migrated.set_key(V::Bool(false), metadata); + storage::update(root, domain, Some(user), "blocklist", |data| { + if data.entries().is_empty() { + *data = migrated; + } + Ok(()) + }) +} diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..b3d88c1 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,652 @@ +//! Prosody adminstream wire protocol with a deliberately explicit command surface. +//! Unsupported Lua expressions and commands return errors instead of pretending success. +use crate::{ + auth, + wire::{Framer, escape}, +}; +use anyhow::{Context, Result, bail, ensure}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt}, + net::{UnixListener, UnixStream}, + sync::Semaphore, + time::timeout, +}; +const NS: &str = "xmpp:prosody.im/admin"; +const STREAM: &str = "http://etherx.jabber.org/streams"; +const HEADER: &str = + ""; +#[derive(Clone, Debug, Default)] +pub struct AdminSnapshot { + pub uptime: Duration, + pub sessions: Vec, +} +#[derive(Clone, Debug, Default)] +pub struct AdminReply { + pub output: Vec, + pub message: String, + pub error: bool, +} +pub type UserMutation = Arc Result<()> + Send + Sync>; +#[derive(Clone)] +pub struct AdminBackend { + pub data: PathBuf, + /// Authentication domains and their password_hash setting. + pub hosts: BTreeMap, + pub modules: BTreeMap>, + pub snapshot: Arc AdminSnapshot + Send + Sync>, + pub delete_user: Option, + /// Invoked after disabling an account, to disconnect its sessions. + pub disconnect_user: Option, +} +#[derive(Clone, Debug)] +struct Command { + section: String, + name: String, + args: Vec>, +} +fn strings(expression: &str) -> Result>> { + let lua = crate::lua::bounded_vm().map_err(|e| anyhow::anyhow!(e.to_string()))?; + let env = lua + .create_table() + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let table: mlua::Table = lua + .load(format!("return {expression}")) + .set_environment(env) + .eval() + .map_err(|_| anyhow::anyhow!("expected string arguments"))?; + let mut max_index = 0usize; + for pair in table.clone().pairs::() { + let (key, value) = pair.map_err(|e| anyhow::anyhow!(e.to_string()))?; + let mlua::Value::Integer(index) = key else { + bail!("arguments must be an array") + }; + ensure!((1..=16).contains(&index), "too many arguments"); + ensure!( + matches!(value, mlua::Value::String(_) | mlua::Value::Nil), + "only string arguments are supported" + ); + max_index = max_index.max(index as usize); + } + let mut result = vec![]; + for index in 1..=max_index { + let value: mlua::Value = table + .raw_get(index) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + result.push(match value { + mlua::Value::String(s) => Some( + s.to_str() + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .to_string(), + ), + mlua::Value::Nil => None, + _ => bail!("only string arguments are supported"), + }); + } + Ok(result) +} +fn parse(line: &str) -> Result { + ensure!(line.len() <= 16384, "command too long"); + let line = line.trim().trim_end_matches(';').trim(); + if line == "help" { + return Ok(Command { + section: "help".into(), + name: "help".into(), + args: vec![], + }); + } + if line.starts_with('{') { + let mut args = strings(line)?; + ensure!(args.len() >= 2, "expected command section and name"); + let section = args.remove(0).context("missing section")?; + let name = args.remove(0).context("missing command")?; + return Ok(Command { + section, + name, + args, + }); + } + let (section, tail) = line + .split_once(':') + .context("expected section:command(arguments)")?; + let (name, tail) = tail.split_once('(').context("missing command arguments")?; + let args = tail + .strip_suffix(')') + .context("missing closing parenthesis")?; + ensure!( + [section, name] + .iter() + .all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')), + "invalid command name" + ); + Ok(Command { + section: section.into(), + name: name.into(), + args: strings(&format!("{{{args}}}"))?, + }) +} +impl AdminBackend { + fn account<'a>(&self, jid: &'a str) -> Result<(&'a str, &'a str, &str)> { + ensure!(!jid.contains('/'), "expected bare JID"); + let (user, host) = jid.split_once('@').context("expected user@host")?; + auth::user_name(user)?; + let hash = self + .hosts + .get(host) + .context("No such authentication host")?; + Ok((user, host, hash)) + } + fn execute(&self, c: &Command) -> Result { + let arg = |i: usize| c.args.get(i).and_then(|v| v.as_deref()); + let mut out = AdminReply::default(); + match (c.section.as_str(), c.name.as_str()) { + ("help", _) => { + out.output=vec!["server:version(), server:uptime(), server:status()".into(),"c2s:count(), c2s:show([jid]), host:list(), module:list([host])".into(),"user:create(jid[,password]), user:password(jid[,password]), user:delete(jid), user:disable(jid), user:enable(jid)".into()]; + out.message="Only listed commands are implemented; arbitrary Lua and module hot reload are unsupported".into(); + } + ("server", "version") => { + ensure!(c.args.is_empty(), "unexpected arguments"); + out.message = format!("rust-xmpp {}", env!("CARGO_PKG_VERSION")); + } + ("server", "status") => { + ensure!(c.args.is_empty(), "unexpected arguments"); + out.message = "running".into(); + } + ("server", "uptime") => { + ensure!(c.args.is_empty(), "unexpected arguments"); + out.message = format!( + "This server has been running for {} seconds", + (self.snapshot)().uptime.as_secs() + ); + } + ("c2s", "count") => { + ensure!(c.args.is_empty(), "unexpected arguments"); + out.message = format!("Total: {} clients", (self.snapshot)().sessions.len()); + } + ("c2s", "show") => { + ensure!( + c.args.len() <= 1, + "custom column specification is unsupported" + ); + out.output = (self.snapshot)() + .sessions + .into_iter() + .filter(|j| { + arg(0).is_none_or(|a| a == "*" || a == j || a == crate::protocol::bare(j)) + }) + .collect(); + out.output.sort(); + out.message = format!("{} c2s sessions shown", out.output.len()); + } + ("host", "list") => { + ensure!(c.args.is_empty(), "unexpected arguments"); + out.output = self.modules.keys().cloned().collect(); + out.message = format!("{} hosts", out.output.len()); + } + ("module", "list") => { + ensure!(c.args.len() <= 1, "unexpected arguments"); + if let Some(host) = arg(0) { + let modules = self.modules.get(host).context("No such host")?; + out.output = modules.clone(); + } else { + for (host, modules) in &self.modules { + out.output.push(format!("{host}: {}", modules.join(", "))); + } + } + out.message = "Modules listed".into(); + } + ("user", "create" | "password") => { + ensure!( + c.args.len() == 2, + "expected JID and password; custom roles are unsupported" + ); + let jid = arg(0).context("missing JID")?; + let password = arg(1).context("missing password")?; + let (user, host, hash) = self.account(jid)?; + if c.name == "create" { + auth::create(&self.data, host, user, password, hash)?; + out.message = format!("Created {jid}"); + } else { + auth::set_password(&self.data, host, user, password, hash)?; + out.message = "User password changed".into(); + } + } + ("user", "delete") => { + ensure!(c.args.len() == 1, "expected JID"); + let jid = arg(0).context("missing JID")?; + self.account(jid)?; + let delete = self.delete_user.as_ref().context( + "user:delete is unsupported: no complete deletion backend is configured", + )?; + delete(jid)?; + out.message = "User deleted".into(); + } + ("user", "disable" | "enable") => { + ensure!(c.args.len() == 1, "expected JID"); + let jid = arg(0).context("missing JID")?; + let (user, host, _) = self.account(jid)?; + ensure!( + crate::storage::load(&self.data, host, Some(user), "accounts")?.is_some(), + "No such user" + ); + crate::storage::update(&self.data, host, Some(user), "accounts", |v| { + v.set( + "disabled", + crate::storage::LuaValue::Bool(c.name == "disable"), + ); + Ok(()) + })?; + if c.name == "disable" + && let Some(disconnect) = &self.disconnect_user + { + disconnect(jid) + .context("Account disabled, but disconnecting sessions failed")?; + } + out.message = if c.name == "disable" { + "User disabled" + } else { + "User enabled" + } + .into(); + } + _ => bail!( + "Unsupported command {}:{}; type help for implemented commands", + c.section, + c.name + ), + } + Ok(out) + } + pub fn execute_line(&self, line: &str) -> Result { + self.execute(&parse(line)?) + } +} +pub struct AdminServer { + listener: UnixListener, + path: PathBuf, + inode: u64, + backend: AdminBackend, + uid: u32, +} +impl AdminServer { + pub fn bind(path: &Path, backend: AdminBackend) -> Result { + use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; + if let Ok(meta) = fs::symlink_metadata(path) { + ensure!( + meta.file_type().is_socket(), + "admin path exists and is not a socket" + ); + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => bail!("admin socket is already in use"), + Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => { + fs::remove_file(path)? + } + Err(e) => return Err(e.into()), + } + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let socket = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None) + .context("create admin Unix socket")?; + socket + .bind(&socket2::SockAddr::unix(path)?) + .context("bind admin Unix socket")?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + socket.listen(32).context("listen admin Unix socket")?; + socket.set_nonblocking(true)?; + let listener = UnixListener::from_std(socket.into())?; + let meta = fs::metadata(path)?; + Ok(Self { + listener, + path: path.into(), + inode: meta.ino(), + uid: meta.uid(), + backend, + }) + } + pub async fn serve(self) -> Result<()> { + let semaphore = Arc::new(Semaphore::new(32)); + loop { + let (stream, _) = self.listener.accept().await?; + let creds = stream.peer_cred()?; + if creds.uid() != self.uid && creds.uid() != 0 { + continue; + } + let Ok(permit) = semaphore.clone().try_acquire_owned() else { + continue; + }; + let backend = self.backend.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = connection(stream, backend).await; + }); + } + } +} +impl Drop for AdminServer { + fn drop(&mut self) { + use std::os::unix::fs::MetadataExt; + if fs::symlink_metadata(&self.path).is_ok_and(|m| m.ino() == self.inode) { + let _ = fs::remove_file(&self.path); + } + } +} +async fn send(s: &mut S, xml: &str) -> Result<()> { + timeout(Duration::from_secs(15), s.write_all(xml.as_bytes())).await??; + Ok(()) +} +async fn read(s: &mut S, f: &mut Framer) -> Result { + let text = timeout(Duration::from_secs(300), f.next(s)).await??; + if text.starts_with(" String { + format!("{xml}") +} +fn header_valid(text: &str) -> Result<()> { + let header = crate::wire::StreamHeader::parse(text)?; + ensure!( + header.default_namespace() == Some(NS), + "invalid admin stream namespace" + ); + Ok(()) +} +async fn connection( + mut stream: S, + backend: AdminBackend, +) -> Result<()> { + let mut f = Framer::default(); + let mut opening = read(&mut stream, &mut f).await?; + if opening.starts_with("").await?; + return Ok(()); + } + let command = (|| -> Result { + let w = wrapped(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("empty request")?; + ensure!( + n.tag_name().namespace() == Some(NS) && n.tag_name().name() == "repl-input", + "expected repl-input" + ); + ensure!( + !n.children().any(|n| n.is_element()), + "unexpected command child" + ); + parse(n.text().unwrap_or("")) + })(); + let result = if let Ok(mut command) = command { + if command.section == "user" + && matches!(command.name.as_str(), "create" | "password") + && command.args.len() == 1 + { + let id = uuid::Uuid::new_v4().simple().to_string(); + send( + &mut stream, + &format!(""), + ) + .await?; + let reply = read(&mut stream, &mut f).await?; + let password = (|| -> Result { + let w = wrapped(&reply); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("missing requested input")?; + ensure!( + n.tag_name().namespace() == Some(NS) + && n.tag_name().name() == "repl-requested-input" + && n.attribute("id") == Some(id.as_str()), + "unexpected requested input" + ); + ensure!( + n.attribute("status").unwrap_or("submit") == "submit", + "password input cancelled" + ); + Ok(n.text().unwrap_or("").into()) + })(); + match password { + Ok(password) => { + command.args.push(Some(password)); + let b = backend.clone(); + tokio::task::spawn_blocking(move || b.execute(&command)).await? + } + Err(e) => Err(e), + } + } else { + let b = backend.clone(); + tokio::task::spawn_blocking(move || b.execute(&command)).await? + } + } else { + Err(command.err().unwrap()) + }; + match result { + Ok(reply) => { + for line in reply.output { + send( + &mut stream, + &format!("{}", escape(&line)), + ) + .await?; + } + send( + &mut stream, + &format!("OK: {}", escape(&reply.message)), + ) + .await?; + } + Err(e) => { + send( + &mut stream, + &format!( + "Error: {}", + escape(&e.to_string()) + ), + ) + .await? + } + } + } +} +/// One command over the real admin socket. Used by CLI and health checks. +pub async fn query(path: &Path, line: &str, password: Option<&str>) -> Result { + let mut s = timeout(Duration::from_secs(5), UnixStream::connect(path)).await??; + query_stream(&mut s, line, password).await +} +async fn query_stream( + mut s: &mut S, + line: &str, + password: Option<&str>, +) -> Result { + let mut f = Framer::default(); + send(&mut s, HEADER).await?; + header_valid(&read(&mut s, &mut f).await?)?; + send( + &mut s, + &format!("{}", escape(line)), + ) + .await?; + let mut reply = AdminReply::default(); + loop { + let text = read(&mut s, &mut f).await?; + let w = wrapped(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("empty admin response")?; + ensure!( + n.tag_name().namespace() == Some(NS), + "invalid admin response namespace" + ); + match n.tag_name().name() { + "repl-output" => reply.output.push(n.text().unwrap_or("").into()), + "repl-result" => { + reply.message = n.text().unwrap_or("").into(); + reply.error = n.attribute("type") == Some("error"); + let _ = send(&mut s, "").await; + return Ok(reply); + } + "repl-request-input" => { + let id = n.attribute("id").context("missing request ID")?; + ensure!( + n.attribute("type") == Some("password"), + "unsupported input request" + ); + let status = if password.is_some() { + "submit" + } else { + "cancel" + }; + send( + &mut s, + &format!( + "{}", + escape(id), + escape(password.unwrap_or("")) + ), + ) + .await?; + } + _ => bail!("unexpected admin response"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn backend(path: &Path) -> AdminBackend { + AdminBackend { + data: path.into(), + hosts: BTreeMap::from([("example.com".into(), "SHA-256".into())]), + modules: BTreeMap::from([( + "example.com".into(), + vec!["admin_shell".into(), "tls".into()], + )]), + snapshot: Arc::new(|| AdminSnapshot { + uptime: Duration::from_secs(123), + sessions: vec!["alice@example.com/r".into()], + }), + delete_user: None, + disconnect_user: None, + } + } + async fn run(backend: AdminBackend, line: &str, password: Option<&str>) -> AdminReply { + let (mut client, server) = tokio::io::duplex(65536); + let task = tokio::spawn(connection(server, backend)); + let result = query_stream(&mut client, line, password).await.unwrap(); + drop(client); + let _ = task.await; + result + } + #[tokio::test] + async fn real_admin_xml_protocol_over_duplex() { + let root = tempfile::tempdir().unwrap(); + let b = backend(root.path()); + assert_eq!( + run(b.clone(), "server:status()", None).await.message, + "OK: running" + ); + assert_eq!( + run(b.clone(), "{\"c2s\",\"count\"}", None).await.message, + "OK: Total: 1 clients" + ); + assert_eq!( + run(b.clone(), "c2s:show()", None).await.output, + vec!["alice@example.com/r"] + ); + assert!( + !run( + b.clone(), + "user:create('alice@example.com')", + Some("secret<&password") + ) + .await + .error + ); + assert!( + auth::verify( + root.path(), + "example.com", + "alice", + "secret<&password", + "SHA-256" + ) + .unwrap() + ); + assert!( + !run( + b.clone(), + "user:password('alice@example.com','changed')", + None + ) + .await + .error + ); + assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + assert!( + !run(b.clone(), "user:disable('alice@example.com')", None) + .await + .error + ); + assert!(!auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + assert!( + !run(b.clone(), "user:enable('alice@example.com')", None) + .await + .error + ); + assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + for line in [ + "module:reload('tls')", + "os.execute('echo forbidden')", + "user:delete('alice@example.com')", + "user:create('bob@example.com',nil,'prosody:admin')", + ] { + assert!(run(b.clone(), line, None).await.error, "{line}"); + } + assert!( + run(b.clone(), "user:password('alice@example.com')", None) + .await + .error + ); + assert!(auth::verify(root.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + let (mut client, server) = tokio::io::duplex(65536); + let task = tokio::spawn(connection(server, b)); + client.write_all(b"server:version()").await.unwrap(); + let mut f = Framer::default(); + f.next(&mut client).await.unwrap(); + assert!(f.next(&mut client).await.unwrap().contains("rust-xmpp")); + drop(client); + let _ = task.await; + } +} diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..e1dbbcd --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,542 @@ +//! Prosody internal archives: Lua `.list` records; binary `.lidx` is a rebuildable cache. +use crate::{ + storage::{self, LuaValue as V}, + wire::escape, +}; +use anyhow::{Result, anyhow, bail}; +use std::{ + io::{BufRead, BufReader, Read, Write}, + path::Path, + sync::{Arc, Mutex, OnceLock, Weak}, +}; +type LockMap = std::collections::HashMap>>; +static LIFECYCLE: std::sync::RwLock<()> = std::sync::RwLock::new(()); +/// Account purge barrier. Callback must not invoke archive methods. +pub fn with_exclusive_access(f: impl FnOnce() -> Result) -> Result { + let _guard = LIFECYCLE + .write() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + f() +} +static LOCKS: OnceLock> = OnceLock::new(); +fn archive_lock( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, +) -> Result>> { + let path = storage::path(root, host, user, store); + let key = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + let mut locks = LOCKS + .get_or_init(|| Mutex::new(LockMap::new())) + .lock() + .map_err(|_| anyhow!("archive lock map poisoned"))?; + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return Ok(lock); + } + if locks.len() > 1024 { + locks.retain(|_, v| v.strong_count() > 0); + } + let lock = Arc::new(Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + Ok(lock) +} +#[derive(Clone, Debug)] +pub struct Entry { + pub id: String, + pub when: f64, + pub peer: String, + pub xml: String, +} +fn string(v: &V) -> Result { + if let V::String(s) = v { + Ok(String::from_utf8(s.clone())?) + } else { + bail!("expected archive string") + } +} +fn s(x: &str) -> V { + V::String(x.as_bytes().to_vec()) +} +fn table() -> V { + V::Table(vec![]) +} + +pub fn timestamp(value: f64) -> Result { + let seconds = value.floor() as i64; + let nanos = ((value - seconds as f64) * 1e9).round() as u32; + Ok( + chrono::DateTime::from_timestamp(seconds, nanos.min(999999999)) + .ok_or_else(|| anyhow!("invalid timestamp"))? + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + ) +} +pub fn parse_time(value: &str) -> Result { + let t = chrono::DateTime::parse_from_rfc3339(value)?; + Ok(t.timestamp() as f64 + t.timestamp_subsec_nanos() as f64 / 1e9) +} +fn from_xml(n: roxmltree::Node<'_, '_>) -> Result { + let mut v = table(); + v.set("name", s(n.tag_name().name())); + let mut attr = table(); + if let Some(ns) = n.tag_name().namespace() { + attr.set("xmlns", s(ns)); + } + for a in n.attributes() { + let key = if let Some(ns) = a.namespace() { + if ns == "http://www.w3.org/XML/1998/namespace" { + format!("xml:{}", a.name()) + } else { + format!("{}\u{1}{}", ns, a.name()) + } + } else { + a.name().into() + }; + attr.set(&key, s(a.value())); + } + v.set("attr", attr); + let mut idx = 1; + for c in n.children() { + let child = if c.is_element() { + from_xml(c)? + } else if c.is_text() { + s(c.text().unwrap_or("")) + } else { + continue; + }; + v.set_key(V::Integer(idx), child); + idx += 1; + } + Ok(v) +} +fn to_xml(v: &V, root: bool) -> Result { + if matches!(v, V::String(_)) { + return Ok(escape(&string(v)?)); + } + let name = string( + v.get("name") + .ok_or_else(|| anyhow!("archive stanza missing name"))?, + )?; + // Parse constructed XML below, preventing malformed names or injected markup. + let mut out = format!("<{name}"); + let mut ns_idx = 0; + if let Some(V::Table(attrs)) = v.get("attr") { + for (k, val) in attrs { + let k = string(k)?; + if root && (k == "stamp" || k == "stamp_legacy") { + continue; + } + let value = escape(&string(val)?); + if let Some((ns, key)) = k.split_once('\u{1}').or_else(|| k.split_once('|')) { + if ns == "http://www.w3.org/XML/1998/namespace" { + out.push_str(&format!(" xml:{key}='{value}'")); + } else { + ns_idx += 1; + out.push_str(&format!( + " xmlns:a{ns_idx}='{}' a{ns_idx}:{key}='{value}'", + escape(ns) + )); + } + } else { + out.push_str(&format!(" {k}='{value}'")); + } + } + } + out.push('>'); + let mut i = 1; + while let Some(child) = v.get_key(&V::Integer(i)) { + out.push_str(&to_xml(child, false)?); + i += 1; + } + out.push_str(&format!("")); + Ok(out) +} +pub fn load(root: &Path, host: &str, user: &str) -> Result> { + load_store(root, host, Some(user), "archive") +} +/// Scan one consistent archive snapshot with memory bounded by one record. +/// Prosody itself indexes record boundaries by lines beginning with `item`. +pub fn scan_store Result<()>>( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + visit: F, +) -> Result<()> { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + scan_unlocked(root, host, user, store, visit) +} +fn recover_append(p: &Path) -> Result<()> { + let journal = p.with_extension("rappend"); + match std::fs::read(&journal) { + Ok(data) => { + anyhow::ensure!(data.len() == 8, "invalid archive append journal"); + let length = u64::from_le_bytes(data.try_into().unwrap()); + let f = std::fs::OpenOptions::new().write(true).open(p)?; + anyhow::ensure!( + f.metadata()?.len() >= length, + "archive shorter than recovery offset" + ); + f.set_len(length)?; + f.sync_all()?; + std::fs::remove_file(journal)?; + std::fs::File::open(p.parent().unwrap())?.sync_all()?; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + Ok(()) +} +fn scan_unlocked Result<()>>( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + mut visit: F, +) -> Result<()> { + let path = storage::path(root, host, user, store).with_extension("list"); + recover_append(&path)?; + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + let mut record = Vec::new(); + let lua = crate::lua::bounded_vm().map_err(|e| anyhow!(e.to_string()))?; + let env = lua.create_table().map_err(|e| anyhow!(e.to_string()))?; + let mut decode = |data: &[u8]| -> Result<()> { + if data.iter().all(u8::is_ascii_whitespace) { + return Ok(()); + } + let count = std::rc::Rc::new(std::cell::Cell::new(0)); + lua.set_hook( + mlua::HookTriggers::new().every_nth_instruction(1000), + move |_, _| { + count.set(count.get() + 1); + if count.get() > 2000 { + return Err(mlua::Error::runtime("archive record instruction limit")); + } + Ok(mlua::VmState::Continue) + }, + ) + .map_err(|e| anyhow!(e.to_string()))?; + let mut chunk = b"local function item(v) return v end; return ".to_vec(); + chunk.extend(data); + let value = lua + .load(&chunk) + .set_mode(mlua::chunk::ChunkMode::Text) + .set_environment(env.clone()) + .eval::() + .map_err(|e| anyhow!(e.to_string()))?; + visit(decode_entry(&storage::from_lua(value)?)?)?; + lua.gc_collect().map_err(|e| anyhow!(e.to_string()))?; + Ok(()) + }; + loop { + line.clear(); + let n = (&mut reader) + .take(16 * 1024 * 1024 + 1) + .read_until(b'\n', &mut line)?; + if n == 0 { + break; + } + anyhow::ensure!( + line.len() <= 16 * 1024 * 1024, + "archive record line exceeds 16 MiB" + ); + if line.starts_with(b"item") && !record.is_empty() { + decode(&record)?; + record.clear(); + } + record.extend_from_slice(&line); + anyhow::ensure!( + record.len() <= 16 * 1024 * 1024, + "archive record exceeds 16 MiB" + ); + } + decode(&record) +} +fn load_unlocked(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result> { + let mut out = vec![]; + scan_unlocked(root, host, user, store, |e| { + out.push(e); + Ok(()) + })?; + Ok(out) +} +pub fn load_store(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result> { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + load_unlocked(root, host, user, store) +} +fn decode_entry(v: &V) -> Result { + let when = match v.get("when") { + Some(V::Number(x)) => *x, + Some(V::Integer(x)) => *x as f64, + _ => parse_time(&string( + v.get("attr") + .and_then(|a| a.get("stamp")) + .ok_or_else(|| anyhow!("archive timestamp missing"))?, + )?)?, + }; + let xml = to_xml(v, true)?; + roxmltree::Document::parse(&xml)?; + Ok(Entry { + id: string(v.get("key").ok_or_else(|| anyhow!("archive id missing"))?)?, + when, + peer: v.get("with").map(string).transpose()?.unwrap_or_default(), + xml, + }) +} +fn encode_entry(e: &Entry) -> Result { + let doc = roxmltree::Document::parse(&e.xml)?; + let mut value = from_xml(doc.root_element())?; + value.set("key", s(&e.id)); + value.set("when", V::Number(e.when)); + value.set("with", s(&e.peer)); + let mut attr = value.get("attr").cloned().unwrap_or_else(table); + attr.set("stamp", s(×tamp(e.when)?)); + value.set("attr", attr); + let serialized = storage::serialize(&value)?; + let expr = serialized + .strip_prefix("return ") + .ok_or_else(|| anyhow!("bad serializer"))? + .trim_end() + .trim_end_matches(';'); + Ok(format!("item({expr});\n")) +} +/// Replace a generic Prosody archive atomically, invalidating its binary cache. +pub fn save_store( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + entries: &[Entry], +) -> Result<()> { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + save_unlocked(root, host, user, store, entries) +} +fn save_unlocked( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + entries: &[Entry], +) -> Result<()> { + let mut data = String::new(); + for e in entries { + data.push_str(&encode_entry(e)?); + } + let p = storage::path(root, host, user, store).with_extension("list"); + recover_append(&p)?; + match std::fs::remove_file(p.with_extension("lidx")) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + storage::atomic_write(&p, data.as_bytes()) +} +pub fn append_store( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + entry: Entry, +) -> Result { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + let mut exists = false; + scan_unlocked(root, host, user, store, |e| { + exists |= e.id == entry.id; + Ok(()) + })?; + let id = entry.id.clone(); + if exists { + let mut entries = load_unlocked(root, host, user, store)?; + entries.retain(|v| v.id != entry.id); + entries.push(entry); + save_unlocked(root, host, user, store, &entries)?; + } else { + append_unlocked(root, host, user, store, &entry)?; + } + Ok(id) +} +/// Append an entry whose identifier is newly generated. O(record size), no archive scan. +/// Call append_store instead if replacing an existing identifier is required. +pub fn append_new( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + entry: Entry, +) -> Result { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + append_unlocked(root, host, user, store, &entry)?; + Ok(entry.id) +} +fn append_unlocked( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + entry: &Entry, +) -> Result<()> { + let data = encode_entry(entry)?; + let path = storage::path(root, host, user, store).with_extension("list"); + let parent = path.parent().unwrap(); + std::fs::create_dir_all(parent)?; + recover_append(&path)?; + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts.open(&path)?; + let length = file.metadata()?.len(); + let journal = path.with_extension("rappend"); + storage::atomic_write(&journal, &length.to_le_bytes())?; + match std::fs::remove_file(path.with_extension("lidx")) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + let result = (|| -> Result<()> { + file.write_all(data.as_bytes())?; + file.sync_all()?; + std::fs::remove_file(&journal)?; + std::fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + recover_append(&path)?; + } + result +} +/// Owner is localpart, peer is bare JID. Caller enforces archive preferences. +pub fn archive_message( + root: &Path, + domain: &str, + owner: &str, + peer: &str, + xml: &str, + time: f64, +) -> Result { + append_new( + root, + domain, + Some(owner), + "archive", + Entry { + id: uuid::Uuid::new_v4().to_string(), + when: time, + peer: peer.into(), + xml: xml.into(), + }, + ) +} + +/// Delete all entries older than cutoff, preserving archive format and invalidating index. +pub fn expire_before( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + cutoff: f64, +) -> Result { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + retain_unlocked(root, host, user, store, |e| e.when >= cutoff) +} +/// Remove acknowledged records atomically relative to concurrent appends. +pub fn delete_ids( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + ids: &[String], +) -> Result<()> { + let _life = LIFECYCLE + .read() + .map_err(|_| anyhow!("archive lifecycle lock poisoned"))?; + let lock = archive_lock(root, host, user, store)?; + let _guard = lock.lock().map_err(|_| anyhow!("archive lock poisoned"))?; + let wanted: std::collections::HashSet<&str> = ids.iter().map(String::as_str).collect(); + retain_unlocked(root, host, user, store, |e| !wanted.contains(e.id.as_str()))?; + Ok(()) +} +fn retain_unlocked bool>( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + mut retain: F, +) -> Result { + let path = storage::path(root, host, user, store).with_extension("list"); + if !path.exists() { + return Ok(0); + } + let parent = path.parent().unwrap(); + let temp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts.open(&temp)?; + let mut removed = 0; + scan_unlocked(root, host, user, store, |e| { + if retain(&e) { + file.write_all(encode_entry(&e)?.as_bytes())?; + } else { + removed += 1; + } + Ok(()) + })?; + if removed == 0 { + return Ok(0); + } + file.sync_all()?; + match std::fs::remove_file(path.with_extension("lidx")) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + std::fs::rename(&temp, &path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok(removed) + })(); + let _ = std::fs::remove_file(&temp); + result +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..31eb655 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,250 @@ +use crate::{lua::bounded_vm, storage::encode_name}; +use anyhow::{Context, Result, ensure}; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use subtle::ConstantTimeEq; + +/// Prepare a SASL authentication identity before looking up its XMPP account. +pub fn prepared_auth_username(name: &str) -> Result { + let sasl = + stringprep::saslprep(name).map_err(|_| anyhow::anyhow!("invalid authentication name"))?; + let prepared = + stringprep::nodeprep(&sasl).map_err(|_| anyhow::anyhow!("invalid authentication name"))?; + ensure!( + !prepared.is_empty() && prepared.len() <= 1023, + "invalid authentication name" + ); + Ok(prepared.into_owned()) +} + +pub fn user_name(name: &str) -> Result<()> { + let prepared = + stringprep::nodeprep(name).map_err(|_| anyhow::anyhow!("invalid account name"))?; + ensure!( + !name.is_empty() && name.len() <= 1023 && prepared == name, + "account name must be a normalized XMPP localpart" + ); + Ok(()) +} +fn account_path(root: &Path, domain: &str, user: &str) -> PathBuf { + root.join(encode_name(domain)) + .join("accounts") + .join(format!("{}.dat", encode_name(user))) +} +fn keys(password: &str, salt: &[u8], iterations: u32, hash: &str) -> Result<(Vec, Vec)> { + let password = + stringprep::saslprep(password).map_err(|_| anyhow::anyhow!("invalid password encoding"))?; + macro_rules! derive { + ($sha:ty,$n:expr) => {{ + let mut salted = [0u8; $n]; + pbkdf2::pbkdf2_hmac::<$sha>(password.as_bytes(), salt, iterations, &mut salted); + let mut client = Hmac::<$sha>::new_from_slice(&salted)?; + client.update(b"Client Key"); + let stored = <$sha>::digest(client.finalize().into_bytes()).to_vec(); + let mut server = Hmac::<$sha>::new_from_slice(&salted)?; + server.update(b"Server Key"); + (stored, server.finalize().into_bytes().to_vec()) + }}; + } + Ok(match hash { + "SHA-1" => derive!(Sha1, 20), + "SHA-256" => derive!(Sha256, 32), + _ => anyhow::bail!("unsupported password_hash"), + }) +} +/// Prosody `internal_hashed` stores binary salt and hex-encoded SCRAM keys. +#[derive(Clone, Debug)] +pub struct ScramCredentials { + pub salt: Vec, + pub iterations: u32, + pub stored_key: Vec, + pub server_key: Vec, +} +pub fn load_scram( + root: &Path, + domain: &str, + user: &str, + hash: &str, +) -> Result> { + if user_name(user).is_err() { + return Ok(None); + } + let file = match fs::File::open(account_path(root, domain, user)) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut data = Vec::new(); + file.take(65537).read_to_end(&mut data)?; + ensure!(data.len() <= 65536, "account record too large"); + ensure!( + !data.starts_with(b"\x1bLua"), + "binary Lua account record rejected" + ); + let lua = bounded_vm().map_err(|e| anyhow::anyhow!("{e}"))?; + let env = lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?; + let table: mlua::Table = lua + .load(data) + .set_environment(env) + .eval() + .map_err(|_| anyhow::anyhow!("invalid account record"))?; + let read = || -> mlua::Result> { + if table.get::>("disabled")?.unwrap_or(false) { + return Ok(None); + } + // Legacy plaintext accounts are derivable without changing the original record. + if let Some(password) = table.get::>("password")? { + let salt = uuid::Uuid::new_v4().to_string().into_bytes(); + let iterations = 10000; + let (stored_key, server_key) = + keys(&password, &salt, iterations, hash).map_err(mlua::Error::external)?; + return Ok(Some(ScramCredentials { + salt, + iterations, + stored_key, + server_key, + })); + } + let salt: mlua::LuaString = table.get("salt")?; + let iterations: u32 = table.get("iteration_count")?; + let stored: String = table.get("stored_key")?; + let server: String = table.get("server_key")?; + Ok(Some(ScramCredentials { + salt: salt.as_bytes().to_vec(), + iterations, + stored_key: hex::decode(stored).map_err(mlua::Error::external)?, + server_key: hex::decode(server).map_err(mlua::Error::external)?, + })) + }; + let result = read().map_err(|_| anyhow::anyhow!("unsupported/incomplete account record"))?; + if let Some(ref c) = result { + let len = match hash { + "SHA-1" => 20, + "SHA-256" => 32, + _ => anyhow::bail!("unsupported password_hash"), + }; + ensure!( + c.stored_key.len() == len + && c.server_key.len() == len + && !c.salt.is_empty() + && (1..=1_000_000).contains(&c.iterations), + "invalid SCRAM credentials" + ); + } + Ok(result) +} +pub fn verify(root: &Path, domain: &str, user: &str, password: &str, hash: &str) -> Result { + ensure!(password.len() <= 4096, "password too large"); + let Ok(user) = prepared_auth_username(user) else { + return Ok(false); + }; + let Some(c) = load_scram(root, domain, &user, hash)? else { + return Ok(false); + }; + let (stored, server) = keys(password, &c.salt, c.iterations, hash)?; + Ok(bool::from( + stored.ct_eq(&c.stored_key) & server.ct_eq(&c.server_key), + )) +} +/// Changes a password while preserving all unrelated Prosody account fields. +pub fn set_password( + root: &Path, + domain: &str, + user: &str, + password: &str, + hash: &str, +) -> Result<()> { + use crate::storage::{self, LuaValue}; + user_name(user)?; + ensure!( + !password.is_empty() && password.len() <= 4096, + "invalid password length" + ); + // `update` serializes mutations; verify existence inside its closure to avoid creating accounts. + storage::update(root, domain, Some(user), "accounts", |account| { + ensure!( + account_path(root, domain, user).is_file(), + "account does not exist" + ); + let iterations = account + .get("iteration_count") + .and_then(LuaValue::as_i64) + .unwrap_or(10000) + .max(10000); + ensure!(iterations <= 1_000_000, "invalid iteration count"); + let salt = uuid::Uuid::new_v4().to_string(); + let (stored, server) = keys(password, salt.as_bytes(), iterations as u32, hash)?; + account.set("stored_key", hex::encode(stored).into()); + account.set("server_key", hex::encode(server).into()); + account.set("salt", salt.into()); + account.set("iteration_count", LuaValue::Integer(iterations)); + account.set("password", LuaValue::Nil); + account.set( + "updated", + LuaValue::Integer( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64, + ), + ); + Ok(()) + })?; + Ok(()) +} +pub fn create(root: &Path, domain: &str, user: &str, password: &str, hash: &str) -> Result<()> { + user_name(user)?; + ensure!( + !password.is_empty() && password.len() <= 4096, + "invalid password length" + ); + let salt = uuid::Uuid::new_v4().to_string(); + let it = 10000; + let (stored, server) = keys(password, salt.as_bytes(), it, hash)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let content = format!( + "return {{\n stored_key = \"{}\";\n server_key = \"{}\";\n salt = \"{}\";\n iteration_count = {};\n created = {};\n updated = {};\n}}\n", + hex::encode(stored), + hex::encode(server), + salt, + it, + now, + now + ); + crate::storage::with_exclusive_access(|| { + let path = account_path(root, domain, user); + let parent = path.parent().context("no parent")?; + fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + } + // Publish a fully fsynced file without replacing an existing account. + let temp = parent.join(format!(".new-{}", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut f = options.open(&temp)?; + f.write_all(content.as_bytes())?; + f.sync_all()?; + fs::hard_link(&temp, &path).context("account already exists or cannot be published")?; + fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + let _ = fs::remove_file(temp); + result + }) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e2a7d71 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,372 @@ +use crate::lua::{bounded_vm, json_value}; +use anyhow::{Context, Result, bail, ensure}; +use mlua::{Lua, Table, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + rc::Rc, +}; + +const MAX_FILE: u64 = 1024 * 1024; +pub type Options = Map; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub global: Options, + #[serde(default)] + pub hosts: BTreeMap, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Host { + #[serde(default)] + pub component: Option, + #[serde(default)] + pub options: Options, +} +#[derive(Debug, Serialize)] +pub struct Diagnostic { + pub level: String, + pub scope: String, + pub message: String, +} + +pub fn load(path: &Path) -> Result { + let text = read_small(path)?; + let config: Config = match path.extension().and_then(|s| s.to_str()) { + Some("lua") => load_lua(path)?, + Some("json") => serde_json::from_str(&text).context("invalid JSON configuration")?, + Some("yaml" | "yml") => { + serde_yaml_ng::from_str(&text).context("invalid YAML configuration")? + } + Some("toml") => toml::from_str(&text).context("invalid TOML configuration")?, + _ => bail!("expected .lua, .json, .yaml, .yml or .toml"), + }; + config.validate()?; + Ok(config) +} +fn read_small(path: &Path) -> Result { + ensure!( + fs::metadata(path)?.len() <= MAX_FILE, + "configuration file exceeds 1 MiB" + ); + fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display())) +} +fn host_name(name: &str) -> Result<()> { + ensure!( + !name.is_empty() + && name.is_ascii() + && name != "*" + && !name.contains(['/', '\\', '@', ':']) + && !name.chars().any(char::is_whitespace), + "invalid/unsupported host name (use ASCII/punycode)" + ); + Ok(()) +} +impl Config { + pub fn option(&self, host: &str, key: &str) -> Option<&Json> { + self.hosts + .get(host) + .and_then(|h| h.options.get(key)) + .or_else(|| self.global.get(key)) + } + /// Explicit configured modules only; implicit dependencies are not claimed as resolved. + pub fn modules(&self, name: &str) -> Result> { + let host = self.hosts.get(name).context("unknown host")?; + let mut enabled = BTreeSet::new(); + let inherit = host + .options + .get("load_global_modules") + .and_then(Json::as_bool) + .unwrap_or(host.component.is_none()); + if inherit { + enabled.extend(string_list(self.global.get("modules_enabled"))?); + } + enabled.extend(string_list(host.options.get("modules_enabled"))?); + let mut disabled = string_list(self.global.get("modules_disabled"))?; + disabled.extend(string_list(host.options.get("modules_disabled"))?); + for item in disabled { + enabled.remove(&item); + } + if let Some(component) = &host.component { + enabled.insert(component.clone()); + } + Ok(enabled) + } + pub fn validate(&self) -> Result<()> { + ensure!( + !self.hosts.is_empty(), + "no virtual hosts or components configured" + ); + for (name, host) in &self.hosts { + host_name(name)?; + ensure!( + name == &name.to_ascii_lowercase(), + "host names must be lowercase" + ); + if let Some(c) = &host.component { + ensure!(!c.is_empty(), "empty component module"); + } + } + for (scope, options) in std::iter::once(("*", &self.global)) + .chain(self.hosts.iter().map(|(n, h)| (n.as_str(), &h.options))) + { + for key in ["modules_enabled", "modules_disabled"] { + string_list(options.get(key)).with_context(|| format!("{scope}: {key}"))?; + } + for key in ["c2s_ports", "s2s_ports", "http_ports", "https_ports"] { + if let Some(value) = options.get(key) { + if value.as_object().is_some_and(Map::is_empty) { + continue; + } + let list = value + .as_array() + .with_context(|| format!("{scope}: {key} must be a list"))?; + let mut seen = BTreeSet::new(); + for port in list { + let p = port + .as_u64() + .filter(|n| (1..=65535).contains(n)) + .context("port must be an integer in 1..65535")?; + ensure!(seen.insert(p), "duplicate port in {scope}: {key}"); + } + } + } + for key in [ + "allow_registration", + "load_global_modules", + "c2s_require_encryption", + "s2s_require_encryption", + "advertise_bosh", + "advertise_websocket", + ] { + if let Some(v) = options.get(key) { + ensure!(v.is_boolean(), "{scope}: {key} must be boolean"); + } + } + } + crate::http_config::validate(self)?; + crate::http_proxy::ProxyConfig::from_options(&self.global)?; + Ok(()) + } + pub fn diagnostics(&self) -> Vec { + let mut out = vec![]; + for (name, h) in &self.hosts { + let modules = self.modules(name).unwrap_or_default(); + if self.option(name, "allow_registration") == Some(&Json::Bool(true)) + && modules.contains("register") + { + out.push(Diagnostic { + level: "info".into(), + scope: name.clone(), + message: "Public registration is enabled by configuration".into(), + }); + } + if modules.contains("seclabels") + && self + .option(name, "security_labels") + .and_then(|v| v.get("policies")) + .is_some() + { + out.push(Diagnostic { level:"warning".into(), scope:name.clone(), message:"security_labels policies/classifications supplies catalog metadata only; no access-control enforcement. Invalid schemas are rejected.".into() }); + } + if h.component.is_none() && modules.contains("pep_simple") { + out.push(Diagnostic { + level: "info".into(), + scope: name.clone(), + message: "mod_pep_simple is empty in the supplied Prosody revision".into(), + }); + } + } + out + } +} +fn string_list(v: Option<&Json>) -> Result> { + match v { + None => Ok(vec![]), + Some(Json::Object(m)) if m.is_empty() => Ok(vec![]), + Some(Json::Array(a)) => a + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .context("expected string list entry") + }) + .collect(), + _ => bail!("expected a list of strings"), + } +} + +#[derive(Clone)] +struct State { + scopes: Table, + stack: Rc>>, + files: Rc>, +} +fn load_lua(path: &Path) -> Result { + let lua = bounded_vm().map_err(|e| anyhow::anyhow!("{e}"))?; + let scopes = lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?; + scopes + .set("*", lua.create_table().map_err(|e| anyhow::anyhow!("{e}"))?) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let state = State { + scopes, + stack: Default::default(), + files: Default::default(), + }; + evaluate(&lua, path, state.clone()).map_err(|e| anyhow::anyhow!("Lua configuration: {e}"))?; + let mut config = Config::default(); + for pair in state.scopes.pairs::() { + let (name, table) = pair.map_err(|e| anyhow::anyhow!("{e}"))?; + let Json::Object(mut options) = + json_value(Value::Table(table), 0).map_err(|e| anyhow::anyhow!("{e}"))? + else { + bail!("scope is not a table"); + }; + if name == "*" { + config.global = options; + } else { + let component = options + .remove("component_module") + .and_then(|v| v.as_str().map(str::to_owned)); + options.remove("defined"); + config.hosts.insert(name, Host { component, options }); + } + } + Ok(config) +} +fn evaluate(lua: &Lua, path: &Path, state: State) -> mlua::Result<()> { + let path = path.canonicalize().map_err(mlua::Error::external)?; + if state.stack.borrow().contains(&path) || state.stack.borrow().len() >= 16 { + return Err(mlua::Error::runtime("Include cycle/depth limit")); + } + *state.files.borrow_mut() += 1; + if *state.files.borrow() > 128 { + return Err(mlua::Error::runtime("Include file count limit")); + } + let source = read_small(&path).map_err(|e| mlua::Error::runtime(e.to_string()))?; + state.stack.borrow_mut().push(path.clone()); + let env = lua.create_table()?; + let current = Rc::new(RefCell::new("*".to_owned())); + for (alias, component) in [ + ("VirtualHost", false), + ("Host", false), + ("host", false), + ("Component", true), + ("component", true), + ] { + let scopes = state.scopes.clone(); + let current = current.clone(); + env.set( + alias, + lua.create_function(move |lua, name: String| { + host_name(&name).map_err(|e| mlua::Error::runtime(e.to_string()))?; + let name = name.to_ascii_lowercase(); + let existing: Option = scopes.get(name.as_str())?; + let t = existing.unwrap_or(lua.create_table()?); + let previous_component: Option = t.get("component_module")?; + let defined: Option = t.get("defined")?; + if (component && defined == Some(true) && previous_component.is_none()) + || (!component && previous_component.is_some()) + { + return Err(mlua::Error::runtime("VirtualHost/Component name clash")); + } + t.set("defined", true)?; + if component { + t.set("component_module", "component")?; + t.set("load_global_modules", false)?; + } + scopes.set(name.as_str(), t.clone())?; + *current.borrow_mut() = name; + let reset = current.clone(); + lua.create_function(move |lua, value: Value| { + let apply_table = { + let t = t.clone(); + let reset = reset.clone(); + move |_: &Lua, opts: Table| { + for p in opts.pairs::() { + let (k, v) = p?; + t.set(k, v)?; + } + *reset.borrow_mut() = "*".into(); + Ok(()) + } + }; + match value { + Value::String(s) if component => { + t.set("component_module", s)?; + Ok(Value::Function(lua.create_function(apply_table)?)) + } + Value::Table(opts) => { + apply_table(lua, opts)?; + Ok(Value::Nil) + } + _ => Err(mlua::Error::runtime( + "invalid VirtualHost/Component declaration", + )), + } + }) + })?, + )?; + } + let parent = path.parent().unwrap().to_path_buf(); + let include_state = state.clone(); + let include = lua.create_function(move |lua, pattern: String| { + let target = parent.join(pattern); + let pattern = target + .to_str() + .ok_or_else(|| mlua::Error::runtime("non-UTF8 Include path"))?; + let mut matches = glob::glob(pattern) + .map_err(mlua::Error::external)? + .collect::, _>>() + .map_err(mlua::Error::external)?; + matches.sort(); + if matches.is_empty() { + return Err(mlua::Error::runtime("Include matched no files")); + } + for file in matches { + evaluate(lua, &file, include_state.clone())?; + } + Ok(()) + })?; + env.set("Include", include.clone())?; + env.set("include", include)?; + let mt = lua.create_table()?; + let read_scopes = state.scopes.clone(); + let read_current = current.clone(); + mt.set( + "__index", + lua.create_function(move |_, (_, key): (Table, String)| { + let scope: Table = read_scopes.get(read_current.borrow().as_str())?; + let value: Value = scope.get(key.as_str())?; + if matches!(value, Value::Nil) { + return Err(mlua::Error::runtime(format!( + "undefined or unsupported config symbol: {key}" + ))); + } + Ok(value) + })?, + )?; + let scopes = state.scopes.clone(); + mt.set( + "__newindex", + lua.create_function(move |_, (_, key, value): (Table, String, Value)| { + let scope: Table = scopes.get(current.borrow().as_str())?; + scope.set(key, value) + })?, + )?; + env.set_metatable(Some(mt))?; + let result = lua + .load(&source) + .set_name(path.to_string_lossy().as_ref()) + .set_environment(env) + .exec(); + state.stack.borrow_mut().pop(); + result +} diff --git a/src/csi.rs b/src/csi.rs new file mode 100644 index 0000000..b598ccc --- /dev/null +++ b/src/csi.rs @@ -0,0 +1,172 @@ +//! Write buffering matching the supplied Prosody mod_csi_simple importance policy. +use crate::{protocol, wire::escape}; +use std::time::{Duration, Instant}; +#[derive(Clone, Debug, Default)] +pub struct Buffer { + inactive: bool, + flushing_until: Option, + queue: Vec, + bytes: usize, + pub limit: usize, + pub resume_delay: Duration, + pub important_payloads: Vec, +} +impl Buffer { + pub fn configure(&mut self, limit: usize, delay: Duration, payloads: Vec) { + self.limit = limit.max(1); + self.resume_delay = delay; + self.important_payloads = payloads; + } + pub fn set_inactive(&mut self, inactive: bool) -> Vec { + self.inactive = inactive; + self.flushing_until = None; + if inactive { Vec::new() } else { self.drain() } + } + pub fn reset(&mut self) { + self.inactive = false; + self.flushing_until = None; + self.queue.clear(); + self.bytes = 0; + } + fn drain(&mut self) -> Vec { + self.bytes = 0; + std::mem::take(&mut self.queue) + } + /// Client input flushes held output without declaring the client active. + pub fn activity(&mut self, now: Instant) -> Vec { + if self.inactive && !self.queue.is_empty() { + self.flushing_until = Some(now + self.resume_delay); + self.drain() + } else { + Vec::new() + } + } + pub fn output(&mut self, xml: String, owner: &str, now: Instant) -> Vec { + if !self.inactive || self.flushing_until.is_some_and(|until| now < until) { + return vec![xml]; + } + self.flushing_until = None; + if self.queue.len() >= self.limit.max(1) + || self.bytes.saturating_add(xml.len()) > 4 * 1024 * 1024 + || important(&xml, &self.important_payloads) + { + self.flushing_until = Some(now + self.resume_delay); + let mut output = self.drain(); + output.push(xml); + output + } else { + let xml = delayed(&xml, owner); + self.bytes += xml.len(); + self.queue.push(xml); + Vec::new() + } + } +} +fn has(n: roxmltree::Node<'_, '_>, ns: &str, name: &str) -> bool { + n.children().any(|c| c.has_tag_name((ns, name))) +} +fn matches_path(n: roxmltree::Node<'_, '_>, path: &str) -> bool { + let (namespace, rest) = if let Some(p) = path.strip_prefix('{') { + let Some((ns, rest)) = p.split_once('}') else { + return false; + }; + (ns, rest) + } else { + (n.tag_name().namespace().unwrap_or("jabber:client"), path) + }; + let (name, next) = rest + .split_once('/') + .map_or((rest, None), |(name, rest)| (name, Some(rest))); + n.children().filter(|c| c.is_element()).any(|c| { + (name == "*" || c.tag_name().name() == name) + && c.tag_name().namespace().unwrap_or("jabber:client") == namespace + && next.is_none_or(|p| matches_path(c, p)) + }) +} +pub fn important(xml: &str, payloads: &[String]) -> bool { + let wrapped = format!("{xml}"); + let Ok(d) = roxmltree::Document::parse(&wrapped) else { + return true; + }; + let Some(mut n) = d.root_element().first_element_child() else { + return true; + }; + if n.tag_name().namespace() != Some("jabber:client") { + return true; + } + match n.tag_name().name() { + "presence" => !matches!(n.attribute("type"), None | Some("unavailable" | "error")), + "iq" => true, + "message" => { + if n.attribute("type") == Some("headline") { + return false; + } + if n.attribute("type") == Some("error") || has(n, "urn:xmpp:carbons:2", "sent") { + return true; + } + if let Some(inner) = n + .children() + .find(|c| c.has_tag_name(("urn:xmpp:carbons:2", "received"))) + .and_then(|c| { + c.children() + .find(|c| c.has_tag_name(("urn:xmpp:forward:0", "forwarded"))) + }) + .and_then(|c| { + c.children() + .find(|c| c.has_tag_name(("jabber:client", "message"))) + }) + { + n = inner; + } + has(n, "jabber:client", "body") + || has(n, "jabber:client", "subject") + || has(n, "urn:xmpp:eme:0", "encryption") + || has(n, "jabber:x:conference", "x") + || n.children().any(|c| { + c.has_tag_name(("http://jabber.org/protocol/muc#user", "x")) + && c.children().any(|c| c.tag_name().name() == "invite") + }) + || n.children().any(|c| { + c.is_element() + && matches!( + c.tag_name().namespace(), + Some("urn:xmpp:jingle-message:0" | "urn:xmpp:jingle-message:1") + ) + }) + || payloads.iter().any(|p| matches_path(n, p)) + } + _ => false, + } +} +fn delayed(xml: &str, owner: &str) -> String { + let wrapped = format!("{xml}"); + let Ok(d) = roxmltree::Document::parse(&wrapped) else { + return xml.into(); + }; + let Some(n) = d.root_element().first_element_child() else { + return xml.into(); + }; + if n.tag_name().namespace() != Some("jabber:client") || n.tag_name().name() == "iq" { + return xml.into(); + } + let mut output = protocol::xml(n); + if let Some(at) = output.rfind("", + escape(owner), + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + ), + ); + } else if output.ends_with("/>") { + output.truncate(output.len() - 2); + output.push_str(&format!( + ">", + escape(owner), + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + n.tag_name().name() + )); + } + output +} diff --git a/src/data_lock.rs b/src/data_lock.rs new file mode 100644 index 0000000..513ef53 --- /dev/null +++ b/src/data_lock.rs @@ -0,0 +1,24 @@ +//! Process-wide ownership of the data directory, independent of listening ports. +use anyhow::{Context, Result}; +use std::{fs::File, path::Path}; + +/// Keep this guard alive while any runtime task can mutate the directory. +/// On Linux, flock locks the directory inode, so aliases and symlinks cannot +/// bypass it and no sentinel file needs to be deleted after a crash. +/// Prosody itself does not participate in this lock. +pub struct DataDirectoryGuard { + _directory: File, +} + +impl DataDirectoryGuard { + pub fn acquire(root: &Path) -> Result { + std::fs::create_dir_all(root).context("cannot create data directory")?; + let directory = File::open(root).context("cannot open data directory")?; + directory.try_lock().context( + "cannot exclusively lock data directory; another rust-xmpp process may be using it", + )?; + Ok(Self { + _directory: directory, + }) + } +} diff --git a/src/extras.rs b/src/extras.rs new file mode 100644 index 0000000..3e3f8b0 --- /dev/null +++ b/src/extras.rs @@ -0,0 +1,321 @@ +use crate::{ + config::Config, + protocol::{ModuleResult, iq_error, iq_result}, + wire::escape, +}; +use anyhow::{Context, Result}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use std::time::Instant; +pub fn handle( + config: &Config, + domain: &str, + iq: roxmltree::Node<'_, '_>, + started: Instant, +) -> Result> { + let Some(q) = iq.first_element_child() else { + return Ok(None); + }; + let ns = q.tag_name().namespace().unwrap_or(""); + let name = q.tag_name().name(); + let modules = config.modules(domain)?; + let payload = match (iq.attribute("type"), name, ns) { + (Some("get"), "time", "urn:xmpp:time") if modules.contains("time") => Some(format!( + "", + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + )), + (Some("get"), "query", "jabber:iq:last") if modules.contains("uptime") => Some(format!( + "", + started.elapsed().as_secs() + )), + ( + Some("get"), + "services" | "credentials", + "urn:xmpp:extdisco:1" | "urn:xmpp:extdisco:2", + ) if modules.contains("turn_external") => { + let host = config + .option(domain, "turn_external_host") + .and_then(|v| v.as_str()) + .unwrap_or(domain); + let secret = config + .option(domain, "turn_external_secret") + .and_then(|v| v.as_str()) + .context("TURN secret missing")?; + let port = config + .option(domain, "turn_external_port") + .and_then(|v| v.as_u64()) + .unwrap_or(3478); + let ttl = config + .option(domain, "turn_external_ttl") + .and_then(|v| v.as_i64()) + .unwrap_or(86400); + let expires = chrono::Utc::now() + chrono::Duration::seconds(ttl); + let username = match config + .option(domain, "turn_external_user") + .and_then(|v| v.as_str()) + { + Some(u) => format!("{}:{u}", expires.timestamp()), + None => expires.timestamp().to_string(), + }; + let mut mac = Hmac::::new_from_slice(secret.as_bytes())?; + mac.update(username.as_bytes()); + let password = STANDARD.encode(mac.finalize().into_bytes()); + let tcp = config + .option(domain, "turn_external_tcp") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut services = vec![("stun", "udp", port), ("turn", "udp", port)]; + if tcp { + services.extend([("stun", "tcp", port), ("turn", "tcp", port)]); + } + if let Some(p) = config + .option(domain, "turn_external_tls_port") + .and_then(|v| v.as_u64()) + { + services.push(("turns", "tcp", p)); + } + let mut xml = format!("<{name} xmlns='{ns}'>"); + for (kind, transport, port) in services { + if q.attribute("type").is_some_and(|t| t != kind) { + continue; + } + if name == "credentials" + && (kind == "stun" + || !q.children().any(|s| { + s.is_element() + && s.tag_name().name() == "service" + && s.attribute("host") == Some(host) + && s.attribute("type") == Some(kind) + && s.attribute("port").is_none_or(|p| p == port.to_string()) + })) + { + continue; + } + xml.push_str(&format!( + ""); + } + xml.push_str(&format!("")); + Some(xml) + } + ( + Some("get"), + "catalog", + "urn:xmpp:sec-label:catalog:2" | "urn:xmpp:sec-label:catalog:0", + ) if modules.contains("seclabels") => { + let configured_labels = config.option(domain, "security_labels"); + let compatibility_labels; + let labels = if let Some(value) = configured_labels.filter(|value| { + value.get("policies").is_some() || value.get("default_policy").is_some() + }) { + compatibility_labels = match policy_catalog_labels(value) { + Ok(labels) => labels, + Err(_) => { + return Ok(Some(ModuleResult::reply(iq_error(iq, "not-acceptable")))); + } + }; + Some(&compatibility_labels) + } else { + configured_labels + }; + let title = config + .option(domain, "security_catalog_name") + .and_then(|v| v.as_str()) + .unwrap_or("Default"); + let desc = config + .option(domain, "security_catalog_desc") + .and_then(|v| v.as_str()) + .unwrap_or("My labels"); + let target = q + .attribute("to") + .map(|to| format!(" to='{}'", escape(to))) + .unwrap_or_default(); + let mut xml = format!( + "", + escape(title), + escape(desc) + ); + if let Some(value) = labels { + labels_xml(value, "", None, ns, &mut xml)?; + } else { + labels_xml( + &serde_json::json!([{"name":"Unclassified","label":true,"default":true}]), + "", + None, + ns, + &mut xml, + )?; + } + xml.push_str(""); + Some(xml) + } + _ => None, + }; + Ok(payload.map(|p| ModuleResult::reply(iq_result(iq, &p)))) +} +/// Catalog-only compatibility for the example configuration's policy schema. +/// Selects `default_policy` and exposes its classifications as ordinary labels. +/// Policy names confer no authorization, membership, ACL, or message enforcement. +/// Unknown fields are rejected rather than silently implying policy support. +fn policy_catalog_labels(value: &serde_json::Value) -> Result { + use anyhow::ensure; + use serde_json::Value; + + let root = value + .as_object() + .context("security labels must be an object")?; + ensure!( + root.keys() + .all(|key| matches!(key.as_str(), "default_policy" | "policies")), + "unknown policy catalog option" + ); + let selected = root + .get("default_policy") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .context("default policy missing")?; + let policies = root + .get("policies") + .and_then(Value::as_object) + .context("policies must be an object")?; + ensure!(policies.contains_key(selected), "default policy not found"); + let mut selected_labels = None; + for (name, policy) in policies { + ensure!(!name.trim().is_empty(), "empty policy name"); + let policy = policy.as_object().context("policy must be an object")?; + ensure!( + policy.keys().all(|key| key == "classifications"), + "unknown policy option" + ); + let classifications = policy + .get("classifications") + .and_then(Value::as_array) + .context("classifications must be an array")?; + let mut labels = Vec::with_capacity(classifications.len()); + for classification in classifications { + let mut label = classification + .as_object() + .context("classification must be an object")? + .clone(); + ensure!( + label.keys().all(|key| matches!( + key.as_str(), + "name" | "label" | "display" | "color" | "bgcolor" | "default" + )), + "unknown classification option" + ); + ensure!( + label + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| !name.trim().is_empty()), + "classification name missing" + ); + for key in ["display", "color", "bgcolor"] { + ensure!( + label.get(key).is_none_or(Value::is_string), + "classification display option must be a string" + ); + } + ensure!( + label.get("default").is_none_or(Value::is_boolean), + "classification default must be a boolean" + ); + match label.entry("label").or_insert(Value::Bool(true)) { + Value::Bool(true) => {} + Value::String(text) => { + if text.starts_with('<') { + roxmltree::Document::parse(text)?; + } + } + _ => anyhow::bail!("classification label must be true or a string"), + } + labels.push(Value::Object(label)); + } + if name == selected { + selected_labels = Some(Value::Array(labels)); + } + } + selected_labels.context("default policy not found") +} + +fn labels_xml( + v: &serde_json::Value, + prefix: &str, + key: Option<&str>, + ns: &str, + out: &mut String, +) -> Result<()> { + if let Some(label) = v + .get("label") + .filter(|v| !v.is_null() && v != &&serde_json::Value::Bool(false)) + { + let name = key + .or_else(|| v.get("name").and_then(|v| v.as_str())) + .context("security label name missing")?; + let selector = escape(&format!("{prefix}{name}")); + let default = if v.get("default") == Some(&serde_json::Value::Bool(true)) { + " default='true'" + } else { + "" + }; + if ns.ends_with(":2") { + out.push_str(&format!( + "" + )); + } else { + out.push_str(&format!( + "" + )); + } + if v.get("display").is_some() || v.get("color").is_some() || v.get("bgcolor").is_some() { + out.push_str("{}", + escape(v.get("display").and_then(|v| v.as_str()).unwrap_or(name)) + )); + } + let text = if label == &serde_json::Value::Bool(true) { + escape(name) + } else if let Some(s) = label.as_str() { + if s.starts_with('<') { + roxmltree::Document::parse(s)?; + s.to_owned() + } else { + escape(s) + } + } else { + anyhow::bail!("unsupported security label") + }; + out.push_str(&format!("")); + if ns.ends_with(":2") { + out.push_str(""); + } + } else if let Some(a) = v.as_array() { + for child in a { + labels_xml(child, prefix, None, ns, out)?; + } + } else if let Some(m) = v.as_object() { + let next = key.map(|k| format!("{prefix}{k}|")); + for (k, child) in m { + labels_xml(child, next.as_deref().unwrap_or(prefix), Some(k), ns, out)?; + } + } + Ok(()) +} diff --git a/src/federation.rs b/src/federation.rs new file mode 100644 index 0000000..bdacb23 --- /dev/null +++ b/src/federation.rs @@ -0,0 +1,797 @@ +//! Authenticated XMPP server-to-server transport: STARTTLS + SASL EXTERNAL. +//! No dialback or plaintext fallback: a peer must prove ownership of its source domain +//! through a trusted certificate and the TLS CertificateVerify signature. +use crate::wire::{Framer, escape}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use hickory_resolver::{TokioAsyncResolver, error::ResolveErrorKind}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt}, + net::TcpStream, + sync::{Mutex, mpsc, oneshot}, + time::{Instant, timeout}, +}; +use tokio_rustls::{ + TlsAcceptor, TlsConnector, + rustls::{ + self, ClientConfig, DigitallySignedStruct, DistinguishedName, RootCertStore, ServerConfig, + SignatureScheme, + client::{ + WebPkiServerVerifier, + danger::{HandshakeSignatureValid, ServerCertVerifier}, + }, + pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}, + server::danger::{ClientCertVerified, ClientCertVerifier}, + }, +}; +const STREAM: &str = "http://etherx.jabber.org/streams"; +const SERVER: &str = "jabber:server"; +const TLS: &str = "urn:ietf:params:xml:ns:xmpp-tls"; +const SASL: &str = "urn:ietf:params:xml:ns:xmpp-sasl"; +const HANDSHAKE: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub struct FederatedStanza { + pub from_domain: String, + pub xml: String, +} +#[derive(Clone)] +pub struct Federation { + inner: Arc, + local_domains: BTreeSet, + /// Explicit operator routing overrides; TLS identity is always checked against the JID domain. + targets: BTreeMap, + outbound_idle: Duration, +} +type PoolKey = (String, String, Option<(String, u16)>); +type PeerConnection = Arc>>; +const MAX_OUTBOUND: usize = 128; +struct Delivery { + stanza: String, + complete: oneshot::Sender>, +} +struct Outbound { + sender: mpsc::Sender, + last_used: Instant, +} +struct Authenticated { + tls: tokio_rustls::client::TlsStream, + framer: Framer, +} +struct Inner { + certs: Vec>, + key: PrivateKeyDer<'static>, + roots: Arc, + client: Arc, + resolver: TokioAsyncResolver, + outbound: Mutex>, +} +impl Federation { + pub fn new( + domain: String, + certs: Vec>, + key: PrivateKeyDer<'static>, + roots: RootCertStore, + ) -> Result { + let domain = canonical_domain(&domain)?; + ensure!(!roots.is_empty(), "empty federation trust store"); + let roots = Arc::new(roots); + let client = ClientConfig::builder() + .with_root_certificates(roots.clone()) + .with_client_auth_cert(certs.clone(), key.clone_key())?; + Ok(Self { + inner: Arc::new(Inner { + certs, + key, + roots, + client: Arc::new(client), + resolver: TokioAsyncResolver::tokio_from_system_conf()?, + outbound: Mutex::new(BTreeMap::new()), + }), + local_domains: BTreeSet::from([domain]), + targets: BTreeMap::new(), + outbound_idle: Duration::from_secs(60), + }) + } + pub fn add_local_domain(&mut self, domain: String) -> Result<()> { + self.local_domains.insert(canonical_domain(&domain)?); + Ok(()) + } + pub fn set_target(&mut self, domain: String, host: String, port: u16) -> Result<()> { + let domain = canonical_domain(&domain)?; + ensure!(port != 0 && !host.is_empty(), "invalid federation target"); + self.targets.insert(domain, (host, port)); + Ok(()) + } + pub fn native_roots() -> Result { + let native = rustls_native_certs::load_native_certs(); + let mut roots = RootCertStore::empty(); + for cert in native.certs { + roots.add(cert)?; + } + ensure!(!roots.is_empty(), "no native trust anchors available"); + Ok(roots) + } + async fn targets(&self, domain: &str) -> Result> { + if let Some(target) = self.targets.get(domain) { + return Ok(vec![target.clone()]); + } + let lookup = self + .inner + .resolver + .srv_lookup(format!("_xmpp-server._tcp.{domain}.")) + .await; + let mut records = match lookup { + Ok(v) => v.iter().cloned().collect::>(), + Err(e) if matches!(e.kind(), ResolveErrorKind::NoRecordsFound { .. }) => { + return Ok(vec![(domain.into(), 5269)]); + } + Err(e) => return Err(e.into()), + }; + ensure!( + !records.iter().any(|r| r.target().is_root()), + "domain explicitly disables XMPP federation" + ); + records.sort_by_key(|r| r.priority()); + let mut targets = Vec::new(); + while !records.is_empty() { + let priority = records[0].priority(); + let end = records + .iter() + .take_while(|r| r.priority() == priority) + .count(); + let sum: u64 = records[..end].iter().map(|r| r.weight() as u64).sum(); + let random = + u64::from_be_bytes(uuid::Uuid::new_v4().as_bytes()[..8].try_into().unwrap()); + let chosen = if sum == 0 { + random as usize % end + } else { + let draw = random % sum; + let mut acc = 0; + records[..end] + .iter() + .position(|r| { + acc += r.weight() as u64; + draw < acc + }) + .unwrap_or(0) + }; + let r = records.remove(chosen); + targets.push((r.target().to_utf8().trim_end_matches('.').into(), r.port())); + } + if targets.is_empty() { + targets.push((domain.into(), 5269)); + } + Ok(targets) + } + pub async fn route(&self, from_domain: &str, to_jid: &str, xml: &str) -> Result<()> { + let from_domain = canonical_domain(from_domain)?; + ensure!( + self.local_domains.contains(&from_domain), + "unowned federation sender domain" + ); + let to_domain = jid_domain(to_jid)?; + validate_stanza(xml, &from_domain, &to_domain)?; + timeout(HANDSHAKE, self.route_inner(&from_domain, &to_domain, xml)) + .await + .context("federation route timeout")? + } + /// Configure before routing. Idle sockets close independently of subsequent traffic. + pub fn with_outbound_idle_timeout(mut self, duration: Duration) -> Result { + ensure!( + duration >= Duration::from_millis(10) && duration <= Duration::from_secs(3600), + "invalid federation idle timeout" + ); + self.outbound_idle = duration; + Ok(self) + } + async fn route_inner(&self, from: &str, to: &str, xml: &str) -> Result<()> { + let key = ( + from.to_owned(), + to.to_owned(), + self.targets.get(to).cloned(), + ); + let peer = { + let mut pool = self.inner.outbound.lock().await; + // Closed actors cannot retain TLS sockets. Never await peer locks under the map lock. + pool.retain(|_, peer| { + if Arc::strong_count(peer) > 1 { + return true; + } + peer.try_lock().map_or(true, |connection| { + connection.as_ref().is_some_and(|c| !c.sender.is_closed()) + }) + }); + if let Some(peer) = pool.get(&key) { + peer.clone() + } else { + ensure!( + pool.len() < MAX_OUTBOUND, + "federation connection pool is full" + ); + let peer = Arc::new(Mutex::new(None)); + pool.insert(key, peer.clone()); + peer + } + }; + let mut connection = peer.lock().await; + for attempt in 0..2 { + if connection.as_ref().is_some_and(|c| { + c.sender.is_closed() || c.last_used.elapsed() >= self.outbound_idle + }) { + *connection = None; + } + if connection.is_none() { + let targets = self.targets(to).await?; + let mut last = None; + let mut authenticated = None; + for (host, port) in targets { + match self.connect(&host, port, from, to).await { + Ok(c) => { + authenticated = Some(c); + break; + } + Err(e) => last = Some(e), + } + } + let authenticated = authenticated.ok_or_else(|| { + last.unwrap_or_else(|| anyhow::anyhow!("no federation endpoints")) + })?; + let (sender, receiver) = mpsc::channel(1); + tokio::spawn(outbound_actor(authenticated, receiver, self.outbound_idle)); + *connection = Some(Outbound { + sender, + last_used: Instant::now(), + }); + } + let (complete, result) = oneshot::channel(); + let delivery = Delivery { + stanza: server_stanza(xml)?, + complete, + }; + let ready = connection.as_mut().unwrap(); + if ready.sender.send(delivery).await.is_err() { + *connection = None; + // send() returns the owned delivery only when the actor never took it. + // Reconnecting is safe in this case; writes with ambiguous outcomes + // below are deliberately never replayed. + if attempt == 0 { + continue; + } + anyhow::bail!("federation connection closed before delivery"); + } + // A canceled route or failed write has an ambiguous delivery outcome. Never replay it. + let outcome = result.await.unwrap_or_else(|_| { + Err(anyhow::anyhow!( + "federation connection closed during delivery" + )) + }); + if outcome.is_err() { + *connection = None; + } else { + ready.last_used = Instant::now(); + } + return outcome; + } + unreachable!("bounded delivery attempts return or fail") + } + + async fn connect(&self, host: &str, port: u16, from: &str, to: &str) -> Result { + let mut tcp = timeout(Duration::from_secs(8), TcpStream::connect((host, port))).await??; + tcp.set_nodelay(true)?; + let mut f = Framer::default(); + send(&mut tcp, &header(from, to)).await?; + read_header(&mut tcp, &mut f, Some(to), Some(from)).await?; + let features = frame(&mut tcp, &mut f).await?; + ensure!( + feature(&features, TLS, "starttls")?, + "peer does not offer STARTTLS" + ); + send(&mut tcp, &format!("")).await?; + let proceed = frame(&mut tcp, &mut f).await?; + ensure!(element(&proceed, TLS, "proceed")?, "STARTTLS rejected"); + ensure!(f.buffer.is_empty(), "unexpected cleartext after proceed"); + let name = ServerName::try_from(to.to_owned())?; + let mut tls = TlsConnector::from(self.inner.client.clone()) + .connect(name, tcp) + .await?; + let mut f = Framer::default(); + send(&mut tls, &header(from, to)).await?; + read_header(&mut tls, &mut f, Some(to), Some(from)).await?; + let features = frame(&mut tls, &mut f).await?; + ensure!( + external_feature(&features)?, + "peer does not offer SASL EXTERNAL" + ); + send( + &mut tls, + &format!( + "{}", + B64.encode(from) + ), + ) + .await?; + let success = frame(&mut tls, &mut f).await?; + ensure!( + element(&success, SASL, "success")?, + "peer rejected EXTERNAL" + ); + send(&mut tls, &header(from, to)).await?; + read_header(&mut tls, &mut f, Some(to), Some(from)).await?; + let features = frame(&mut tls, &mut f).await?; + ensure!( + element(&features, STREAM, "features")?, + "missing postauth features" + ); + Ok(Authenticated { tls, framer: f }) + } + pub async fn accept( + &self, + tcp: TcpStream, + inbound: mpsc::Sender, + ) -> Result<()> { + // One deadline covers the entire unauthenticated protocol, including SASL + // and stream restarts; per-frame deadlines otherwise let peers pin sockets. + let (mut tls, mut f, from) = timeout(HANDSHAKE, self.authenticate(tcp)) + .await + .context("federation authentication timeout")??; + loop { + let text = frame(&mut tls, &mut f).await?; + if f.is_stream_end(&text) { + send(&mut tls, "").await?; + return Ok(()); + } + let destination = { + let w = wrap(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("missing stanza")?; + ensure!( + n.tag_name().namespace() == Some(SERVER), + "invalid S2S stanza namespace" + ); + jid_domain(n.attribute("to").context("missing destination")?)? + }; + ensure!( + self.local_domains.contains(&destination), + "unserved destination domain" + ); + validate_stanza(&text, &from, &destination)?; + timeout( + Duration::from_secs(15), + inbound.send(FederatedStanza { + from_domain: from.clone(), + xml: text, + }), + ) + .await??; + } + } + async fn authenticate( + &self, + mut tcp: TcpStream, + ) -> Result<(tokio_rustls::server::TlsStream, Framer, String)> { + tcp.set_nodelay(true)?; + let mut f = Framer::default(); + let (from, to) = read_header(&mut tcp, &mut f, None, None).await?; + ensure!( + self.local_domains.contains(&to), + "unknown destination domain" + ); + send(&mut tcp,&format!("{}",header(&to,&from))).await?; + let start = frame(&mut tcp, &mut f).await?; + ensure!(element(&start, TLS, "starttls")?, "TLS required"); + ensure!(f.buffer.is_empty(), "TLS pipelining rejected"); + let verifier = Arc::new(DomainVerifier { + inner: WebPkiServerVerifier::builder(self.inner.roots.clone()).build()?, + name: ServerName::try_from(from.clone())?, + }); + let config = ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(self.inner.certs.clone(), self.inner.key.clone_key())?; + send(&mut tcp, &format!("")).await?; + let mut tls = timeout(HANDSHAKE, TlsAcceptor::from(Arc::new(config)).accept(tcp)).await??; + let mut f = Framer::default(); + read_header(&mut tls, &mut f, Some(&from), Some(&to)).await?; + send(&mut tls,&format!("{}EXTERNAL",header(&to,&from))).await?; + let auth = frame(&mut tls, &mut f).await?; + { + let w = wrap(&auth); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("missing auth")?; + ensure!( + n.tag_name().namespace() == Some(SASL) + && n.tag_name().name() == "auth" + && n.attribute("mechanism") == Some("EXTERNAL"), + "EXTERNAL required" + ); + let payload = n.text().unwrap_or(""); + let authz = if payload == "=" { + Vec::new() + } else { + B64.decode(payload)? + }; + ensure!( + authz.is_empty() + || std::str::from_utf8(&authz) + .ok() + .and_then(|value| canonical_domain(value).ok()) + .is_some_and(|value| value == from), + "EXTERNAL identity mismatch" + ); + } + send(&mut tls, &format!("")).await?; + read_header(&mut tls, &mut f, Some(&from), Some(&to)).await?; + send( + &mut tls, + &format!("{}", header(&to, &from)), + ) + .await?; + Ok((tls, f, from)) + } +} +/// One task owns each TLS stream so peer closure is observed even when no writes occur. +async fn outbound_actor( + mut connection: Authenticated, + mut queue: mpsc::Receiver, + idle: Duration, +) { + let mut deadline = Instant::now() + idle; + loop { + tokio::select! { + biased; + // Outbound S2S is unidirectional; bidi was never negotiated. Any peer stanza, + // stream error, closing tag or EOF ends this connection without replaying data. + _ = connection.framer.next(&mut connection.tls) => break, + _ = tokio::time::sleep_until(deadline) => break, + delivery = queue.recv() => { + let Some(delivery)=delivery else{break}; + if delivery.complete.is_closed(){continue} + let outcome=send(&mut connection.tls,&delivery.stanza).await; + let failed=outcome.is_err();let _=delivery.complete.send(outcome); + if failed{break}deadline=Instant::now()+idle; + } + } + } + queue.close(); + while let Ok(delivery) = queue.try_recv() { + let _ = delivery.complete.send(Err(anyhow::anyhow!( + "federation connection retired before write" + ))); + } + let _ = timeout( + Duration::from_secs(1), + send(&mut connection.tls, ""), + ) + .await; + let _ = timeout(Duration::from_secs(1), connection.tls.shutdown()).await; +} +#[derive(Debug)] +struct DomainVerifier { + inner: Arc, + name: ServerName<'static>, +} +impl ClientCertVerifier for DomainVerifier { + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + fn verify_client_cert( + &self, + end: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> std::result::Result { + self.inner + .verify_server_cert(end, intermediates, &self.name, &[], now)?; + Ok(ClientCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} +fn canonical_domain(name: &str) -> Result { + // Domain identities use canonical DNS label syntax. IPv4 literals remain + // supported; rustls requires an IP SAN for them rather than a DNS SAN. + ensure!( + !name.is_empty() + && name.len() <= 253 + && name.is_ascii() + && name.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }), + "invalid federation domain" + ); + ServerName::try_from(name.to_owned())?; + Ok(name.to_ascii_lowercase()) +} +fn jid_domain(jid: &str) -> Result { + let bare = jid.split('/').next().context("missing JID")?; + let domain = match bare.split_once('@') { + Some((local, domain)) => { + ensure!(!local.is_empty() && !domain.contains('@'), "invalid JID"); + domain + } + None => bare, + }; + canonical_domain(domain) +} +fn header(from: &str, to: &str) -> String { + format!( + "", + escape(from), + escape(to), + uuid::Uuid::new_v4() + ) +} +fn wrap(xml: &str) -> String { + format!("{xml}") +} +async fn send(s: &mut S, xml: &str) -> Result<()> { + timeout(Duration::from_secs(15), s.write_all(xml.as_bytes())).await??; + Ok(()) +} +async fn frame(s: &mut S, f: &mut Framer) -> Result { + let text = timeout(Duration::from_secs(120), f.next(s)).await??; + if text.starts_with("( + s: &mut S, + f: &mut Framer, + expected_from: Option<&str>, + expected_to: Option<&str>, +) -> Result<(String, String)> { + let mut text = frame(s, f).await?; + if text.starts_with(" Result { + let w = wrap(xml); + let d = roxmltree::Document::parse(&w)?; + Ok(d.root_element() + .first_element_child() + .is_some_and(|n| n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name)) +} +fn feature(xml: &str, ns: &str, name: &str) -> Result { + let w = wrap(xml); + let d = roxmltree::Document::parse(&w)?; + let features = d + .root_element() + .first_element_child() + .context("missing features")?; + ensure!( + features.has_tag_name((STREAM, "features")), + "missing stream features" + ); + Ok(features.children().any(|n| n.has_tag_name((ns, name)))) +} +fn external_feature(xml: &str) -> Result { + let w = wrap(xml); + let d = roxmltree::Document::parse(&w)?; + let features = d + .root_element() + .first_element_child() + .context("missing features")?; + ensure!( + features.has_tag_name((STREAM, "features")), + "missing SASL features" + ); + Ok(features + .children() + .filter(|n| n.has_tag_name((SASL, "mechanisms"))) + .flat_map(|n| n.children()) + .any(|n| n.has_tag_name((SASL, "mechanism")) && n.text() == Some("EXTERNAL"))) +} +fn validate_stanza(xml: &str, from_domain: &str, to_domain: &str) -> Result<()> { + let w = wrap(xml); + let d = roxmltree::Document::parse(&w)?; + let root = d.root_element(); + ensure!( + root.children().filter(|n| n.is_element()).count() == 1, + "multiple stanzas" + ); + let n = root.first_element_child().context("missing stanza")?; + ensure!( + matches!(n.tag_name().name(), "iq" | "message" | "presence") + && matches!( + n.tag_name().namespace(), + Some(SERVER) | Some("jabber:client") + ), + "invalid federation stanza" + ); + ensure!( + jid_domain(n.attribute("from").context("missing stanza from")?)? == from_domain, + "forged source domain" + ); + ensure!( + jid_domain(n.attribute("to").context("missing stanza to")?)? == to_domain, + "wrong destination domain" + ); + Ok(()) +} +fn server_stanza(xml: &str) -> Result { + let w = wrap(xml); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("missing stanza")?; + Ok(crate::protocol::xml_namespace( + n, + Some(("jabber:client", SERVER)), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn federation_domains_are_dns_names_and_case_insensitive() { + assert_eq!(canonical_domain("Alpha.TEST").unwrap(), "alpha.test"); + for invalid in [ + "", + "::1", + "[::1]", + "alpha.test.", + ".test", + "a..test", + "-alpha.test", + "alpha-.test", + "_xmpp.test", + "alpha@beta.test", + "alpha/test", + ] { + assert!(canonical_domain(invalid).is_err(), "{invalid}"); + } + assert!(canonical_domain(&format!("{}.test", "a".repeat(64))).is_err()); + assert_eq!(jid_domain("alice@ALPHA.TEST/r").unwrap(), "alpha.test"); + for invalid in [ + "@alpha.test", + "alice@@alpha.test", + "alice@", + "alice@bad_domain.test", + ] { + assert!(jid_domain(invalid).is_err(), "{invalid}"); + } + validate_stanza( + "", + "alpha.test", + "beta.test", + ) + .unwrap(); + } + + #[test] + fn starttls_must_be_an_immediate_stream_feature() { + assert!( + feature( + &format!(""), + TLS, + "starttls", + ) + .unwrap() + ); + assert!( + feature( + &format!(""), + TLS, + "starttls" + ) + .is_err() + ); + assert!( + !feature( + &format!( + "" + ), + TLS, + "starttls", + ) + .unwrap() + ); + } + + #[test] + fn external_must_be_in_the_advertised_mechanisms() { + let mechanisms = + format!("EXTERNAL"); + assert!( + external_feature(&format!("{mechanisms}")).unwrap() + ); + assert!( + !external_feature(&format!( + "{mechanisms}" + )) + .unwrap() + ); + assert!(!external_feature(&format!("{mechanisms}")).unwrap()); + assert!(external_feature(&format!("{mechanisms}")).is_err()); + } + + #[tokio::test] + async fn stream_restarts_compare_canonical_domains() { + let text = header("Alpha.TEST", "BETA.TEST"); + let mut input = text.as_bytes(); + let domains = read_header( + &mut input, + &mut Framer::default(), + Some("alpha.test"), + Some("beta.test"), + ) + .await + .unwrap(); + assert_eq!(domains, ("alpha.test".into(), "beta.test".into())); + let text = header("gamma.test", "beta.test"); + let mut input = text.as_bytes(); + assert!( + read_header( + &mut input, + &mut Framer::default(), + Some("alpha.test"), + Some("beta.test") + ) + .await + .is_err() + ); + } +} diff --git a/src/http_altconnect.rs b/src/http_altconnect.rs new file mode 100644 index 0000000..c8d6e78 --- /dev/null +++ b/src/http_altconnect.rs @@ -0,0 +1,143 @@ +//! XEP-0156 host-meta discovery, matching the supplied mod_http_altconnect. +use crate::{config::Config, http_config::HttpConfig, http_transport::HttpRoute, wire::escape}; +use anyhow::{Context, Result}; +use axum::{ + Router, + extract::{Request, State}, + http::{Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Link { + pub rel: &'static str, + pub href: String, +} +#[derive(Clone)] +struct Discovery { + config: HttpConfig, + xml_path: String, + json_path: String, + xml: String, + json: String, +} +/// Configured values, never request Host or forwarded headers, define public URLs. +pub fn links(config: &Config, domain: &str, secure: bool, port: u16) -> Result> { + let http = HttpConfig::for_host(config, domain)?; + let modules = config.modules(domain)?; + let mut links = Vec::new(); + for (module, option, rel) in [ + ("bosh", "advertise_bosh", "urn:xmpp:alt-connections:xbosh"), + ( + "websocket", + "advertise_websocket", + "urn:xmpp:alt-connections:websocket", + ), + ] { + let advertise = config + .option(domain, option) + .map(|v| { + v.as_bool() + .with_context(|| format!("{option} must be boolean")) + }) + .transpose()? + .unwrap_or(true); + if modules.contains(module) && advertise { + let url = http.external_url(module, secure, port)?; + let href = if module == "websocket" { + url.replacen("http", "ws", 1) + } else { + url + }; + links.push(Link { rel, href }); + } + } + Ok(links) +} +pub fn with_altconnect( + router: Router, + config: &Config, + domain: &str, + secure: bool, + port: u16, +) -> Result { + let http = HttpConfig::for_host(config, domain)?; + let links = links(config, domain, secure, port)?; + let mut xml = "".to_owned(); + for link in &links { + xml.push_str(&format!( + "", + link.rel, + escape(&link.href) + )); + } + xml.push_str(""); + let state = Discovery { + xml_path: format!("{}/host-meta", http.altconnect_path.trim_end_matches('/')), + json_path: format!( + "{}/host-meta.json", + http.altconnect_path.trim_end_matches('/') + ), + config: http, + xml, + json: serde_json::to_string(&serde_json::json!({"links": links}))?, + }; + Ok(router.layer(axum::middleware::from_fn_with_state(state, discovery))) +} +async fn discovery(State(state): State, req: Request, next: Next) -> Response { + let path = req.uri().path(); + if path != state.xml_path && path != state.json_path { + return next.run(req).await; + } + let mut response = if let Some(host) = req + .headers() + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + { + if !state.config.accepts_host(host) { + StatusCode::NOT_FOUND.into_response() + } else if req.method() == Method::OPTIONS { + ( + [ + ("access-control-allow-origin", "*"), + ("access-control-allow-methods", "GET, HEAD, OPTIONS"), + ("access-control-allow-headers", "Content-Type"), + ], + "", + ) + .into_response() + } else if req.method() == Method::GET || req.method() == Method::HEAD { + let (mime, body) = if path == state.xml_path { + ("application/xrd+xml", state.xml) + } else { + ("application/json", state.json) + }; + let len = body.len().to_string(); + ( + [ + ("content-type", mime), + ("access-control-allow-origin", "*"), + ("content-length", len.as_str()), + ], + if req.method() == Method::HEAD { + String::new() + } else { + body + }, + ) + .into_response() + } else { + ( + StatusCode::METHOD_NOT_ALLOWED, + [("allow", "GET, HEAD, OPTIONS")], + ) + .into_response() + } + } else { + StatusCode::BAD_REQUEST.into_response() + }; + response.extensions_mut().insert(HttpRoute::Altconnect); + response +} diff --git a/src/http_config.rs b/src/http_config.rs new file mode 100644 index 0000000..d2c371b --- /dev/null +++ b/src/http_config.rs @@ -0,0 +1,395 @@ +//! Prosody-compatible HTTP app mount and advertised URL configuration. +use crate::config::{Config, Options}; +use anyhow::{Context, Result, ensure}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug)] +pub struct HttpConfig { + pub host: String, + pub default_host: Option, + pub websocket_path: String, + pub bosh_path: String, + pub file_share_path: String, + pub altconnect_path: String, + pub consider_websocket_secure: bool, + pub consider_bosh_secure: bool, + pub websocket_origins: Vec, + pub bosh_origins: Vec, + known_hosts: BTreeSet, + external: Option, +} +fn string(options: &Options, key: &str) -> Result> { + options + .get(key) + .map(|v| { + v.as_str() + .map(str::to_owned) + .with_context(|| format!("{key} must be a string")) + }) + .transpose() +} +fn valid_authority(authority: &axum::http::uri::Authority) -> bool { + let raw = authority.as_str(); + if raw.contains('@') { + return false; + } + let suffix = if raw.starts_with('[') { + let Some(end) = raw.find(']') else { + return false; + }; + &raw[end + 1..] + } else { + raw.find(':').map(|i| &raw[i..]).unwrap_or("") + }; + if suffix.is_empty() { + return true; + } + suffix.strip_prefix(':').is_some_and(|port| { + !port.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && port.parse::().is_ok() + }) +} +fn host(value: &str) -> Result { + ensure!( + !value.is_empty() + && value.is_ascii() + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-')), + "invalid HTTP host: {value}" + ); + Ok(value.to_ascii_lowercase()) +} +/// Mount paths are literals, never axum route patterns. Reject ambiguous or unsafe forms. +pub fn normalize_path(value: &str) -> Result { + ensure!( + value.is_ascii(), + "HTTP paths must use percent encoding for non-ASCII characters" + ); + // Validate pchars directly. No value is passed to a route-pattern parser. + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut offset = 0; + while offset < bytes.len() { + let byte = bytes[offset]; + if byte == b'%' { + ensure!( + offset + 2 < bytes.len() + && bytes[offset + 1].is_ascii_hexdigit() + && bytes[offset + 2].is_ascii_hexdigit(), + "malformed percent escape in HTTP path" + ); + let byte = u8::from_str_radix(&value[offset + 1..offset + 3], 16)?; + ensure!( + byte != b'/' && byte != b'\\' && !byte.is_ascii_control(), + "ambiguous percent escape in HTTP path" + ); + decoded.push(byte); + offset += 3; + } else { + ensure!( + byte.is_ascii_alphanumeric() || b"/-._~!&'()*+,;=:@".contains(&byte), + "HTTP path must contain only literal URL path characters" + ); + decoded.push(byte); + offset += 1; + } + } + ensure!( + !value.contains("//") + && !decoded + .split(|b| *b == b'/') + .any(|s| s == b"." || s == b".."), + "invalid HTTP path" + ); + let value = if value.starts_with('/') { + value.to_owned() + } else { + format!("/{value}") + }; + let value = value.trim_end_matches('/'); + Ok(if value.is_empty() { + "/".into() + } else { + value.to_owned() + }) +} + +fn paths(options: &Options) -> Result> { + let Some(value) = options.get("http_paths") else { + return Ok(BTreeMap::new()); + }; + let table = value.as_object().context("http_paths must be a table")?; + table + .iter() + .map(|(k, v)| { + Ok(( + k.clone(), + v.as_str() + .context("http_paths entries must be strings")? + .to_owned(), + )) + }) + .collect() +} +impl HttpConfig { + pub fn for_host(config: &Config, domain: &str) -> Result { + let local = &config + .hosts + .get(domain) + .context("unknown HTTP virtual host")? + .options; + let global_paths = paths(&config.global)?; + let local_paths = paths(local)?; + let mount = |name: &str, default: &str| -> Result { + normalize_path( + &local_paths + .get(name) + .or_else(|| global_paths.get(name)) + .map(String::as_str) + .unwrap_or(default) + .replace("$host", domain), + ) + }; + let effective_host = |name: &str| -> Result { + let h = &config.hosts.get(name).context("unknown HTTP host")?.options; + host( + &string(h, "http_host")? + .or(string(&config.global, "http_host")?) + .unwrap_or_else(|| name.into()), + ) + }; + let mut known_hosts = BTreeSet::new(); + for name in config.hosts.keys() { + known_hosts.insert(effective_host(name)?); + } + let default_host = string(&config.global, "http_default_host")? + .map(|s| host(&s)) + .transpose()?; + let origins = |module: &str| -> Result> { + let value = config + .option(domain, "http_cors_override") + .and_then(|v| v.get(module)) + .and_then(|v| v.get("origins")) + .or_else(|| config.option(domain, "access_control_allow_origins")); + match value { + None => Ok(vec![]), + Some(v) if v.as_object().is_some_and(|m| m.is_empty()) => Ok(vec![]), + Some(v) if v.as_str() == Some("*") => Ok(vec!["*".into()]), + Some(v) => v + .as_array() + .context("HTTP origins must be a list")? + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .context("HTTP origins entries must be strings") + }) + .collect(), + } + }; + let boolean = |key: &str| -> Result { + config + .option(domain, key) + .map(|v| { + v.as_bool() + .with_context(|| format!("{key} must be boolean")) + }) + .unwrap_or(Ok(false)) + }; + let external = + string(local, "http_external_url")?.or(string(&config.global, "http_external_url")?); + if let Some(url) = &external { + parse_external(url)?; + } + Ok(Self { + host: effective_host(domain)?, + default_host, + websocket_path: mount("websocket", "/xmpp-websocket")?, + bosh_path: mount("bosh", "/http-bind")?, + file_share_path: mount("file_share", "/file_share")?, + altconnect_path: mount("altconnect", "/.well-known")?, + consider_websocket_secure: boolean("consider_websocket_secure")?, + consider_bosh_secure: boolean("consider_bosh_secure")?, + websocket_origins: origins("websocket")?, + bosh_origins: origins("bosh")?, + known_hosts, + external, + }) + } + pub fn accepts_host(&self, authority: &str) -> bool { + let Ok(authority) = authority.parse::() else { + return false; + }; + if !valid_authority(&authority) { + return false; + } + let requested = authority.host().to_ascii_lowercase(); + requested == self.host + || (!self.known_hosts.contains(&requested) + && self.default_host.as_deref() == Some(self.host.as_str())) + } + /// External reverse-proxy prefixes affect discovery only, never local mounts. + pub fn external_url(&self, app: &str, secure: bool, port: u16) -> Result { + let path = match app { + "websocket" => &self.websocket_path, + "bosh" => &self.bosh_path, + "file_share" => &self.file_share_path, + "altconnect" => &self.altconnect_path, + _ => anyhow::bail!("unknown HTTP app"), + }; + if let Some(external) = &self.external { + let uri = parse_external(external)?; + let scheme = uri.scheme_str().unwrap(); + let authority = uri.authority().unwrap(); + let authority = + if authority.port_u16() == Some(if scheme == "https" { 443 } else { 80 }) { + authority.host().to_owned() + } else { + authority.to_string() + }; + return Ok(format!( + "{scheme}://{authority}{}{path}", + uri.path().trim_end_matches('/') + )); + } + let scheme = if secure { "https" } else { "http" }; + let port = if port == if secure { 443 } else { 80 } { + String::new() + } else { + format!(":{port}") + }; + Ok(format!("{scheme}://{}{port}{path}", self.host)) + } +} +fn parse_external(value: &str) -> Result { + let uri: axum::http::Uri = value.parse().context("invalid http_external_url")?; + ensure!( + matches!(uri.scheme_str(), Some("http" | "https")) + && uri.authority().is_some() + && !uri.authority().unwrap().as_str().contains('@') + && uri.query().is_none() + && !value.contains('#'), + "http_external_url must be an absolute HTTP(S) URL without credentials, query or fragment" + ); + ensure!( + valid_authority(uri.authority().unwrap()), + "http_external_url port must be in 0..65535" + ); + Ok(uri) +} +pub fn validate(config: &Config) -> Result<()> { + for options in std::iter::once(&config.global).chain(config.hosts.values().map(|h| &h.options)) + { + for value in paths(options)?.values() { + normalize_path(&value.replace("$host", "example.test"))?; + } + } + for domain in config.hosts.keys() { + let http = HttpConfig::for_host(config, domain)?; + let modules = config.modules(domain)?; + ensure!( + !(modules.contains("websocket") + && modules.contains("bosh") + && http.websocket_path == http.bosh_path), + "websocket and bosh HTTP paths conflict for {domain}" + ); + if modules.contains("http_altconnect") { + for suffix in ["host-meta", "host-meta.json"] { + let path = format!("{}/{suffix}", http.altconnect_path.trim_end_matches('/')); + ensure!( + !(modules.contains("websocket") && http.websocket_path == path + || modules.contains("bosh") && http.bosh_path == path), + "http_altconnect path conflicts with XMPP transport for {domain}" + ); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn config(value: serde_json::Value) -> Config { + serde_json::from_value(value).unwrap() + } + #[test] + fn mounts_inherit_per_app_and_expand_virtual_host() { + let c = config( + serde_json::json!({"global":{"http_paths":{"websocket":"global/$host/","bosh":"bind/"}},"hosts":{"a.test":{"options":{"http_paths":{"websocket":"custom/$host"}}}}}), + ); + let h = HttpConfig::for_host(&c, "a.test").unwrap(); + assert_eq!(h.websocket_path, "/custom/a.test"); + assert_eq!(h.bosh_path, "/bind"); + } + #[test] + fn routes_hosts_and_default_without_overriding_known_hosts() { + let c = config( + serde_json::json!({"global":{"http_default_host":"web.test"},"hosts":{"a.test":{"options":{"http_host":"web.test"}},"b.test":{"options":{}}}}), + ); + let h = HttpConfig::for_host(&c, "a.test").unwrap(); + assert!(h.accepts_host("WEB.TEST:5280")); + assert!(h.accepts_host("unknown.test")); + assert!(!h.accepts_host("b.test")); + assert!(!h.accepts_host("bad host")); + } + #[test] + fn external_prefix_and_standard_port() { + let c = config( + serde_json::json!({"global":{"http_external_url":"https://proxy.test:443/prefix/"},"hosts":{"a.test":{"options":{}}}}), + ); + assert_eq!( + HttpConfig::for_host(&c, "a.test") + .unwrap() + .external_url("bosh", false, 5280) + .unwrap(), + "https://proxy.test/prefix/http-bind" + ); + } + #[test] + fn literal_pchars_and_percent_encoding_are_supported() { + for path in [ + "/chat/v1:ws", + "/bind%20endpoint", + "/a/*", + "/a@b!&'()+,;=:_~-.%41", + ] { + assert_eq!(normalize_path(path).unwrap(), path); + } + } + #[test] + fn invalid_paths_types_urls_and_conflicts_are_errors() { + for path in [ + "/a/{capture}", + "/../b", + "/a?x", + "/a#b", + "/a//b", + "/$unknown", + "/a%2fb", + "/a%5cb", + "/a%00b", + "/%2e%2E/b", + "/a%", + "/a%xx", + "/a b", + "/a\\b", + ] { + assert!(normalize_path(path).is_err(), "{path}"); + } + for global in [ + serde_json::json!({"http_paths":false}), + serde_json::json!({"http_paths":{"bosh":false}}), + serde_json::json!({"http_external_url":"https://u:p@a.test"}), + serde_json::json!({"http_external_url":"relative/path"}), + serde_json::json!({"modules_enabled":["websocket","bosh"],"http_paths":{"websocket":"same/","bosh":"/same"}}), + ] { + assert!( + validate(&config( + serde_json::json!({"global":global,"hosts":{"a.test":{"options":{}}}}) + )) + .is_err() + ); + } + } +} diff --git a/src/http_proxy.rs b/src/http_proxy.rs new file mode 100644 index 0000000..48b059e --- /dev/null +++ b/src/http_proxy.rs @@ -0,0 +1,403 @@ +//! Trusted reverse-proxy connection metadata. Headers never authenticate a socket peer. +use crate::config::Options; +use anyhow::{Result, bail, ensure}; +use axum::http::HeaderMap; +use std::net::{IpAddr, SocketAddr}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RequestContext { + pub socket_peer: SocketAddr, + pub peer_ip: IpAddr, + pub secure: bool, +} + +#[derive(Clone, Debug)] +pub struct ProxyConfig { + trusted: Vec, + legacy: bool, +} +#[derive(Clone, Debug)] +struct Network { + ip: IpAddr, + prefix: u8, +} +impl Network { + fn parse(value: &str) -> Result { + let (ip, prefix) = match value.split_once('/') { + Some((ip, prefix)) => (ip.parse::()?, Some(prefix.parse::()?)), + None => (value.parse::()?, None), + }; + let bits = if ip.is_ipv4() { 32 } else { 128 }; + let prefix = prefix.unwrap_or(bits); + ensure!(prefix <= bits, "invalid trusted_proxies network prefix"); + Ok(Self { ip, prefix }) + } + fn contains(&self, ip: IpAddr) -> bool { + match (self.ip, ip) { + (IpAddr::V4(network), IpAddr::V4(ip)) => { + let mask = u32::MAX + .checked_shl(32 - u32::from(self.prefix)) + .unwrap_or(0); + u32::from(network) & mask == u32::from(ip) & mask + } + (IpAddr::V6(network), IpAddr::V6(ip)) => { + let mask = u128::MAX + .checked_shl(128 - u32::from(self.prefix)) + .unwrap_or(0); + u128::from(network) & mask == u128::from(ip) & mask + } + _ => false, + } + } +} +impl Default for ProxyConfig { + fn default() -> Self { + Self { + trusted: vec![ + Network::parse("127.0.0.1").unwrap(), + Network::parse("::1").unwrap(), + ], + legacy: true, + } + } +} +impl ProxyConfig { + pub fn from_options(options: &Options) -> Result { + let mut config = Self::default(); + if let Some(value) = options.get("trusted_proxies") { + // Empty native Lua tables are represented as empty JSON objects. + let values: &[serde_json::Value] = if let Some(values) = value.as_array() { + values + } else if value.as_object().is_some_and(|table| table.is_empty()) { + &[] + } else { + bail!("trusted_proxies must be an array of IP addresses or CIDR networks") + }; + config.trusted = values + .iter() + .map(|value| { + let Some(value) = value.as_str() else { + bail!("trusted_proxies entries must be strings") + }; + Network::parse(value).map_err(|_| { + anyhow::anyhow!("invalid trusted_proxies IP address or CIDR network") + }) + }) + .collect::>()?; + } + if let Some(value) = options.get("http_legacy_x_forwarded") { + config.legacy = value + .as_bool() + .ok_or_else(|| anyhow::anyhow!("http_legacy_x_forwarded must be a boolean"))?; + } + Ok(config) + } + fn trusted(&self, ip: IpAddr) -> bool { + self.trusted.iter().any(|network| network.contains(ip)) + } + pub fn resolve( + &self, + socket_peer: SocketAddr, + secure: bool, + headers: &HeaderMap, + ) -> RequestContext { + let mut context = RequestContext { + socket_peer, + peer_ip: socket_peer.ip(), + secure, + }; + if !self.trusted(context.peer_ip) { + return context; + } + if self.legacy { + // Match Prosody: the rightmost non-proxy wins; all-proxy lists keep the socket IP. + if let Some(value) = combined(headers, "x-forwarded-for") { + for ip in value.split(',').filter_map(|v| parse_ip(v.trim())) { + if !self.trusted(ip) { + context.peer_ip = ip; + } + } + } + context.secure |= combined(headers, "x-forwarded-proto").as_deref() == Some("https"); + } else if let Some(value) = combined(headers, "forwarded") + && let Some(elements) = forwarded(&value) + { + let mut first_hop = true; + for (ip, proto) in elements.into_iter().rev() { + if !self.trusted(context.peer_ip) { + break; + } + context.peer_ip = ip; + // The immediate trusted proxy may terminate TLS. Once that + // assertion is accepted, every earlier consumed hop must + // also report HTTPS; plaintext cannot be upgraded again. + let hop_secure = proto.as_deref() == Some("https"); + context.secure = if first_hop { + hop_secure + } else { + context.secure && hop_secure + }; + first_hop = false; + } + } + context + } +} +fn combined(headers: &HeaderMap, name: &str) -> Option { + let values = headers + .get_all(name) + .iter() + .map(|v| v.to_str()) + .collect::, _>>() + .ok()?; + (!values.is_empty()).then(|| values.join(",")) +} +fn parse_ip(value: &str) -> Option { + value + .parse() + .ok() + .or_else(|| value.parse::().ok().map(|v| v.ip())) + .or_else(|| value.strip_prefix('[')?.strip_suffix(']')?.parse().ok()) +} +// Quoted delimiters and escapes are handled before splitting Forwarded elements. +fn split_quoted(value: &str, delimiter: char) -> Option> { + let (mut quote, mut escape, mut start) = (false, false, 0); + let mut result = Vec::new(); + for (i, c) in value.char_indices() { + if escape { + escape = false; + continue; + } + if quote && c == '\\' { + escape = true; + continue; + } + if c == '"' { + quote = !quote; + } else if c == delimiter && !quote { + result.push(&value[start..i]); + start = i + c.len_utf8(); + } + } + if quote || escape { + return None; + } + result.push(&value[start..]); + Some(result) +} +fn unquote(value: &str) -> Option { + if !value.starts_with('"') { + return (!value.contains('"')).then(|| value.to_owned()); + } + let value = value.strip_prefix('"')?.strip_suffix('"')?; + let mut chars = value.chars(); + let mut result = String::new(); + while let Some(c) = chars.next() { + result.push(if c == '\\' { chars.next()? } else { c }); + } + Some(result) +} +fn forwarded(value: &str) -> Option)>> { + split_quoted(value, ',')? + .into_iter() + .map(|element| { + let (mut ip, mut proto) = (None, None); + for pair in split_quoted(element, ';')? { + let (name, value) = pair.trim().split_once('=')?; + let value = unquote(value.trim())?; + if name.eq_ignore_ascii_case("for") { + if ip.is_some() { + return None; + } + ip = Some(parse_ip(&value)?); + } else if name.eq_ignore_ascii_case("proto") { + if proto.is_some() { + return None; + } + proto = Some(value); + } + } + Some((ip?, proto)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + fn context( + config: &ProxyConfig, + peer: &str, + secure: bool, + values: &[(&str, &str)], + ) -> RequestContext { + let mut headers = HeaderMap::new(); + for (name, value) in values { + headers.append( + axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + } + config.resolve(peer.parse().unwrap(), secure, &headers) + } + #[test] + fn untrusted_socket_ignores_spoofed_headers() { + let c = context( + &ProxyConfig::default(), + "192.0.2.1:80", + false, + &[ + ("x-forwarded-for", "198.51.100.1"), + ("x-forwarded-proto", "https"), + ("forwarded", "for=198.51.100.2;proto=https"), + ], + ); + assert_eq!(c.peer_ip.to_string(), "192.0.2.1"); + assert!(!c.secure); + } + #[test] + fn legacy_chain_uses_rightmost_untrusted_and_tls_is_preserved() { + let config = ProxyConfig::from_options( + &serde_json::from_value( + serde_json::json!({"trusted_proxies":["127.0.0.0/8","2001:db8::/32"]}), + ) + .unwrap(), + ) + .unwrap(); + let c = context( + &config, + "127.0.0.1:80", + false, + &[ + ( + "x-forwarded-for", + "198.51.100.1, 192.0.2.2, [2001:db8::1]:443", + ), + ("x-forwarded-proto", "https"), + ], + ); + assert_eq!(c.peer_ip.to_string(), "192.0.2.2"); + assert!(c.secure); + let c = context( + &config, + "127.0.0.1:80", + true, + &[ + ("x-forwarded-for", "127.0.0.2"), + ("x-forwarded-proto", "http"), + ], + ); + assert_eq!(c.peer_ip.to_string(), "127.0.0.1"); + assert!(c.secure); + } + #[test] + fn forwarded_mode_stops_at_first_untrusted_hop() { + let config = ProxyConfig::from_options( + &serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false})).unwrap(), + ) + .unwrap(); + let c = context( + &config, + "127.0.0.1:80", + true, + &[ + ( + "forwarded", + "for=198.51.100.1;proto=http, for=\"[2001:db8::2]:443\";proto=https", + ), + ("x-forwarded-proto", "http"), + ], + ); + assert_eq!(c.peer_ip.to_string(), "2001:db8::2"); + assert!(c.secure); + assert!( + context( + &config, + "127.0.0.1:80", + false, + &[("forwarded", "for=192.0.2.1;proto=https")] + ) + .secure + ); + } + #[test] + fn forwarded_tls_termination_cannot_upgrade_a_plaintext_chain_hop() { + let config = ProxyConfig::from_options( + &serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false,"trusted_proxies":["127.0.0.0/8"]})).unwrap(), + ) + .unwrap(); + for value in [ + "for=192.0.2.1;proto=http,for=127.0.0.2;proto=https", + "for=192.0.2.1;proto=https,for=127.0.0.2;proto=http", + ] { + let c = context(&config, "127.0.0.1:80", false, &[("forwarded", value)]); + assert_eq!(c.peer_ip.to_string(), "192.0.2.1"); + assert!(!c.secure); + } + } + #[test] + fn malformed_forwarded_and_unknown_identifiers_are_ignored() { + let config = ProxyConfig::from_options( + &serde_json::from_value(serde_json::json!({"http_legacy_x_forwarded":false})).unwrap(), + ) + .unwrap(); + for value in [ + "for=unknown", + "for=_hidden", + "for=\"unfinished", + "for=192.0.2.1;for=192.0.2.2", + ] { + assert_eq!( + context(&config, "127.0.0.1:80", true, &[("forwarded", value)]) + .peer_ip + .to_string(), + "127.0.0.1" + ); + } + } + #[test] + fn empty_native_lua_table_disables_proxy_trust() { + for value in [serde_json::json!({}), serde_json::json!([])] { + let config = ProxyConfig::from_options( + &serde_json::from_value(serde_json::json!({"trusted_proxies":value})).unwrap(), + ) + .unwrap(); + let c = context( + &config, + "127.0.0.1:80", + false, + &[ + ("x-forwarded-for", "192.0.2.1"), + ("x-forwarded-proto", "https"), + ], + ); + assert_eq!(c.peer_ip.to_string(), "127.0.0.1"); + assert!(!c.secure); + } + } + #[test] + fn config_validation_and_network_boundaries() { + for value in [ + serde_json::json!({"trusted_proxies":["10.0.0.1/33"]}), + serde_json::json!({"trusted_proxies":"*"}), + serde_json::json!({"http_legacy_x_forwarded":"yes"}), + ] { + assert!(ProxyConfig::from_options(&serde_json::from_value(value).unwrap()).is_err()); + } + assert!( + Network::parse("0.0.0.0/0") + .unwrap() + .contains("192.0.2.1".parse().unwrap()) + ); + assert!( + !Network::parse("127.0.0.0/8") + .unwrap() + .contains("128.0.0.1".parse().unwrap()) + ); + assert!( + !Network::parse("::/0") + .unwrap() + .contains("192.0.2.1".parse().unwrap()) + ); + } +} diff --git a/src/http_service.rs b/src/http_service.rs new file mode 100644 index 0000000..ab13b3e --- /dev/null +++ b/src/http_service.rs @@ -0,0 +1,1037 @@ +//! Streaming XEP-0363 upload endpoint. Slots are single-use and bounded before streaming. +use crate::{ + logging::{Level, Logger}, + storage, + wire::escape, +}; +use anyhow::{Result, ensure}; +use axum::{ + Router, + body::Body, + extract::{Request, State}, + http::{HeaderValue, StatusCode}, + response::Response, + routing::any, +}; +use http_body_util::BodyExt; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + io::{AsyncSeekExt, AsyncWriteExt}, + net::TcpListener, +}; +use tokio_rustls::TlsAcceptor; +use tokio_util::io::ReaderStream; +const NS: &str = "urn:xmpp:http:upload:0"; +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +#[derive(Clone, Debug)] +pub struct UploadConfig { + pub root: PathBuf, + pub host: String, + pub base_url: String, + pub size_limit: u64, + pub daily_quota: u64, + pub global_quota: u64, + pub expires_after: Option, + pub allowed_types: Vec, + pub secret: Option, +} +#[derive(Clone, Serialize, Deserialize)] +struct Record { + owner: String, + filename: String, + size: u64, + content_type: String, + created: u64, + token: String, + complete: bool, + #[serde(default)] + xml: Option, +} +#[derive(Clone)] +pub struct UploadService { + config: Arc, + records: Arc>>, + active: Arc>>, +} +pub struct Slot { + pub url: String, + pub authorization: String, +} +impl Slot { + pub fn xml(&self) -> String { + format!( + "
{}
", + escape(&self.url), + escape(&self.url), + escape(&self.authorization) + ) + } +} +fn url_escape(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { + out.push(b as char) + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} +fn url_decode(value: &str) -> Option> { + let mut out = Vec::with_capacity(value.len()); + let mut bytes = value.bytes(); + while let Some(byte) = bytes.next() { + if byte == b'%' { + let h = (bytes.next()? as char).to_digit(16)?; + let l = (bytes.next()? as char).to_digit(16)?; + out.push((h * 16 + l) as u8); + } else { + out.push(byte); + } + } + Some(out) +} +impl UploadService { + pub fn new(config: UploadConfig) -> Result { + ensure!( + config.base_url.starts_with("https://") || config.base_url.starts_with("http://"), + "invalid upload base URL" + ); + let path = config + .root + .join(storage::encode_name(&config.host)) + .join("rust_uploads.json"); + let mut records: BTreeMap = if path.exists() { + serde_json::from_slice(&std::fs::read(path)?)? + } else { + BTreeMap::new() + }; + let entries = crate::archive::load_store(&config.root, &config.host, None, "uploads")?; + let mut authoritative = BTreeMap::new(); + for e in entries { + let doc = roxmltree::Document::parse(&e.xml)?; + let node = doc.root_element(); + let filename = node + .attribute("filename") + .ok_or_else(|| anyhow::anyhow!("upload filename missing"))? + .to_string(); + let size = node + .attribute("size") + .ok_or_else(|| anyhow::anyhow!("upload size missing"))? + .parse()?; + let token = records.remove(&e.id).map(|r| r.token).unwrap_or_default(); + authoritative.insert( + e.id, + Record { + owner: e.peer, + filename, + size, + content_type: node + .attribute("content-type") + .unwrap_or("application/octet-stream") + .into(), + created: e.when as u64, + token, + complete: false, + xml: Some(e.xml), + }, + ); + } + records = authoritative; + let s = Self { + config: Arc::new(config), + records: Arc::new(Mutex::new(records)), + active: Arc::new(Mutex::new(BTreeSet::new())), + }; + s.prune()?; + Ok(s) + } + fn persist(&self, r: &BTreeMap) -> Result<()> { + let mut entries: Vec<_> = r + .iter() + .map(|(id, v)| crate::archive::Entry { + id: id.clone(), + when: v.created as f64, + peer: v.owner.clone(), + xml: v.xml.clone().unwrap_or_else(|| { + format!( + "", + escape(&v.filename), + v.size, + escape(&v.content_type) + ) + }), + }) + .collect(); + entries.sort_by(|a, b| a.when.total_cmp(&b.when).then_with(|| a.id.cmp(&b.id))); + crate::archive::save_store( + &self.config.root, + &self.config.host, + None, + "uploads", + &entries, + )?; + let total: u64 = r + .values() + .fold(0u64, |total, r| total.saturating_add(r.size)); + storage::update( + &self.config.root, + &self.config.host, + None, + "upload_stats", + |v| { + v.set("total", storage::LuaValue::Number(total as f64)); + Ok(()) + }, + )?; + storage::atomic_write( + &self + .config + .root + .join(storage::encode_name(&self.config.host)) + .join("rust_uploads.json"), + &serde_json::to_vec(r)?, + ) + } + fn file(&self, id: &str) -> PathBuf { + self.config + .root + .join(storage::encode_name(&self.config.host)) + .join("http_file_share") + .join(format!("{}.bin", storage::encode_name(id))) + } + pub fn prune(&self) -> Result<()> { + let mut r = self.records.lock().unwrap(); + let active = self.active.lock().unwrap(); + let obsolete: Vec = r + .iter() + .filter(|(id, v)| { + !active.contains(*id) + && (self + .config + .expires_after + .is_some_and(|age| v.created.saturating_add(age) <= now()) + || (!v.complete + && v.created.saturating_add(600) <= now() + && !self.file(id).exists())) + }) + .map(|(id, _)| id.clone()) + .collect(); + for id in obsolete { + match std::fs::remove_file(self.file(&id)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + r.remove(&id); + } + self.persist(&r) + } + /// Caller must authenticate and authorize the bare JID before issuing a slot. + pub fn request_slot( + &self, + owner: &str, + filename: &str, + size: u64, + content_type: &str, + ) -> Result { + self.prune()?; + ensure!(!owner.is_empty(), "forbidden"); + ensure!( + !filename.is_empty() + && filename.len() <= 1024 + && !filename + .chars() + .any(|c| c == '/' || c == '\\' || c.is_control()) + && filename != "." + && filename != "..", + "bad-request: invalid filename" + ); + ensure!( + size <= self.config.size_limit, + "not-acceptable: file too large" + ); + ensure!( + HeaderValue::from_str(content_type).is_ok(), + "bad-request: content type" + ); + ensure!( + self.config.allowed_types.is_empty() + || self.config.allowed_types.iter().any(|t| t == content_type + || t.strip_suffix("/*") + .is_some_and(|prefix| content_type.starts_with(&format!("{prefix}/")))), + "not-acceptable: file type" + ); + let mut r = self.records.lock().unwrap(); + let total: u128 = r.values().map(|r| r.size as u128).sum(); + let daily: u128 = r + .values() + .filter(|r| r.owner == owner && r.created.saturating_add(86400) > now()) + .map(|r| r.size as u128) + .sum(); + ensure!( + total + size as u128 <= self.config.global_quota as u128, + "resource-constraint: global quota" + ); + ensure!( + daily + size as u128 <= self.config.daily_quota as u128, + "resource-constraint: daily quota" + ); + let id = uuid::Uuid::new_v4().to_string(); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let token = if let Some(secret) = &self.config.secret { + sign_jwt( + secret, + &serde_json::json!({"sub":owner,"slot":id,"filename":filename,"filesize":size,"filetype":content_type,"iat":now(),"exp":now()+600,"expires":self.config.expires_after.map(|age|now()+age)}), + ) + } else { + token + }; + let auth = format!("Bearer {token}"); + r.insert( + id.clone(), + Record { + owner: owner.into(), + filename: filename.into(), + size, + content_type: content_type.into(), + created: now(), + token, + complete: false, + xml: None, + }, + ); + if let Err(e) = self.persist(&r) { + r.remove(&id); + return Err(e); + }; + Ok(Slot { + url: format!( + "{}/{}/{}", + self.config.base_url.trim_end_matches('/'), + id, + url_escape(filename) + ), + authorization: auth, + }) + } + pub fn disco_info(&self) -> String { + format!( + "{NS}{}", + self.config.size_limit + ) + } + pub fn router(&self) -> Router { + let prefix = self + .config + .base_url + .split_once("://") + .and_then(|(_, a)| a.find('/').map(|i| &a[i..])) + .unwrap_or("") + .trim_end_matches('/') + .to_owned(); + self.router_with_prefix(prefix) + } + /// Use the listener's mount path independently of the advertised public URL. + pub fn router_at(&self, internal_path: &str) -> Result { + let prefix = crate::http_config::normalize_path(internal_path)?; + Ok(self.router_with_prefix(prefix.trim_end_matches('/').to_owned())) + } + fn router_with_prefix(&self, prefix: String) -> Router { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let config = Arc::downgrade(&self.config); + let records = Arc::downgrade(&self.records); + let active = Arc::downgrade(&self.active); + handle.spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(3600)).await; + let (Some(config), Some(records), Some(active)) = + (config.upgrade(), records.upgrade(), active.upgrade()) + else { + break; + }; + let service = UploadService { + config, + records, + active, + }; + let _ = tokio::task::spawn_blocking(move || service.prune()).await; + } + }); + } + Router::new() + .fallback(any(endpoint)) + .with_state(self.clone()) + .layer(axum::Extension(UploadPath(prefix))) + } + pub async fn serve(&self, listener: TcpListener, tls: Option) -> Result<()> { + serve_router(listener, tls, self.router()).await + } +} +pub async fn serve_router( + listener: TcpListener, + tls: Option, + app: Router, +) -> Result<()> { + serve_router_inner( + listener, + tls, + app, + None, + crate::http_proxy::ProxyConfig::default(), + ) + .await +} + +/// Serve HTTP with instance-owned diagnostics. Request bodies, headers and raw +/// URLs are deliberately excluded; paths are reduced to fixed route classes. +pub async fn serve_router_logged( + listener: TcpListener, + tls: Option, + app: Router, + logger: Logger, +) -> Result<()> { + serve_router_inner( + listener, + tls, + app, + Some(logger), + crate::http_proxy::ProxyConfig::default(), + ) + .await +} + +/// Serve with configured proxy trust and attach effective context to every request. +pub async fn serve_router_configured( + listener: TcpListener, + tls: Option, + app: Router, + logger: Option, + proxy: crate::http_proxy::ProxyConfig, +) -> Result<()> { + serve_router_inner(listener, tls, app, logger, proxy).await +} + +fn http_event( + logger: &Option, + id: &str, + level: Level, + event: &str, + fields: &[(&str, &str)], +) { + if let Some(logger) = logger { + logger.event(level, "http", Some(id), event, fields); + } +} + +async fn serve_router_inner( + listener: TcpListener, + tls: Option, + app: Router, + logger: Option, + proxy: crate::http_proxy::ProxyConfig, +) -> Result<()> { + let limit = Arc::new(tokio::sync::Semaphore::new(256)); + loop { + let (stream, peer) = listener.accept().await?; + let Ok(permit) = limit.clone().try_acquire_owned() else { + continue; + }; + let mut app = app.clone(); + let tls = tls.clone(); + let logger = logger.clone(); + let id = uuid::Uuid::new_v4().to_string(); + http_event( + &logger, + &id, + Level::Info, + "connection-open", + &[ + ("peer", &peer.to_string()), + ("transport", if tls.is_some() { "https" } else { "http" }), + ], + ); + let proxy = proxy.clone(); + let transport_secure = tls.is_some(); + app = app.layer(axum::middleware::from_fn( + move |mut req: Request, next: axum::middleware::Next| { + let context = proxy.resolve(peer, transport_secure, req.headers()); + req.extensions_mut().insert(context); + async move { next.run(req).await } + }, + )); + if let Some(request_logger) = logger.clone() { + let request_id = id.clone(); + app = app.layer(axum::middleware::from_fn( + move |req: Request, next: axum::middleware::Next| { + let logger = request_logger.clone(); + let id = request_id.clone(); + async move { + let default_route = match req.uri().path() { + "/xmpp-websocket" | "/xmpp-websocket/" => "websocket", + "/http-bind" | "/http-bind/" => "bosh", + _ => "other", + }; + let response = next.run(req).await; + let route = match response + .extensions() + .get::() + { + Some(crate::http_transport::HttpRoute::Websocket) => "websocket", + Some(crate::http_transport::HttpRoute::Bosh) => "bosh", + Some(crate::http_transport::HttpRoute::Altconnect) => "altconnect", + None => default_route, + }; + let status = response.status(); + logger.event( + if status.is_client_error() || status.is_server_error() { + Level::Warn + } else { + Level::Info + }, + "http", + Some(&id), + "request-complete", + &[("route", route), ("status", status.as_str())], + ); + response + } + }, + )); + } + tokio::spawn(async move { + let _permit = permit; + let service = hyper_util::service::TowerToHyperService::new(app); + let mut builder = + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()); + builder + .http1() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(std::time::Duration::from_secs(15)); + let result = if let Some(tls) = tls { + let stream = match tokio::time::timeout( + std::time::Duration::from_secs(15), + tls.accept(stream), + ) + .await + { + Ok(Ok(stream)) => stream, + result => { + http_event( + &logger, + &id, + Level::Warn, + "connection-closed", + &[ + ("phase", "tls-handshake"), + ( + "reason", + if result.is_err() { + "timeout" + } else { + "tls-handshake-failed" + }, + ), + ], + ); + return; + } + }; + http_event(&logger, &id, Level::Info, "tls-established", &[]); + builder + .serve_connection_with_upgrades(hyper_util::rt::TokioIo::new(stream), service) + .await + } else { + builder + .serve_connection_with_upgrades(hyper_util::rt::TokioIo::new(stream), service) + .await + }; + http_event( + &logger, + &id, + if result.is_ok() { + Level::Info + } else { + Level::Warn + }, + "connection-closed", + &[ + ("phase", "http"), + ( + "reason", + if result.is_ok() { + "http-complete-or-upgraded" + } else { + "http-transport-error" + }, + ), + ], + ); + }); + } +} + +fn response(status: StatusCode) -> Response { + Response::builder() + .status(status) + .body(Body::empty()) + .unwrap() +} +fn bearer_token(authorization: &[u8]) -> Option<&str> { + let value = std::str::from_utf8(authorization).ok()?; + let (scheme, token) = value.split_once(' ')?; + scheme + .eq_ignore_ascii_case("Bearer") + .then(|| token.trim_start_matches(' ')) +} +struct UploadGuard { + active: Arc>>, + id: String, + temp: PathBuf, +} +impl Drop for UploadGuard { + fn drop(&mut self) { + self.active.lock().unwrap().remove(&self.id); + let _ = std::fs::remove_file(&self.temp); + } +} +#[derive(Clone)] +struct UploadPath(String); +async fn endpoint( + State(s): State, + axum::Extension(prefix): axum::Extension, + req: Request, +) -> Response { + let mut reply = handle_at(s, req, &prefix.0).await; + reply + .headers_mut() + .insert("access-control-allow-origin", HeaderValue::from_static("*")); + reply +} +async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response { + let request_path = req.uri().path().to_string(); + let Some(path) = request_path.strip_prefix(&format!("{prefix}/")) else { + return response(StatusCode::NOT_FOUND); + }; + let Some((id, name)) = path.split_once('/') else { + return response(StatusCode::NOT_FOUND); + }; + let Some(decoded_id) = url_decode(id).and_then(|v| String::from_utf8(v).ok()) else { + return response(StatusCode::BAD_REQUEST); + }; + let id = decoded_id.as_str(); + if id.is_empty() + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return response(StatusCode::BAD_REQUEST); + } + let record = s.records.lock().unwrap().get(id).cloned(); + let Some(record) = record else { + return response(StatusCode::NOT_FOUND); + }; + if url_decode(name).as_deref() != Some(record.filename.as_bytes()) { + return response(StatusCode::NOT_FOUND); + }; + if s.config + .expires_after + .is_some_and(|age| record.created.saturating_add(age) <= now()) + { + return response(StatusCode::GONE); + } + if req.method() == "OPTIONS" { + return Response::builder() + .status(204) + .header("access-control-allow-origin", "*") + .header("access-control-allow-methods", "GET, HEAD, PUT, OPTIONS") + .header( + "access-control-allow-headers", + "Authorization, Content-Type", + ) + .body(Body::empty()) + .unwrap(); + } + if req.method() == "PUT" { + use subtle::ConstantTimeEq; + let auth = req + .headers() + .get("authorization") + .map(|v| v.as_bytes()) + .unwrap_or_default(); + let valid_saved = s.config.secret.is_none() + && !record.token.is_empty() + && bearer_token(auth) + .is_some_and(|token| bool::from(token.as_bytes().ct_eq(record.token.as_bytes()))) + && record.created.saturating_add(600) > now(); + let valid_jwt = s + .config + .secret + .as_ref() + .is_some_and(|secret| verify_jwt(secret, auth, id, &record)); + if !valid_saved && !valid_jwt { + return response(StatusCode::FORBIDDEN); + } + if let Some(len) = req.headers().get("content-length") + && len.to_str().ok().and_then(|v| v.parse::().ok()) != Some(record.size) + { + return response(StatusCode::PAYLOAD_TOO_LARGE); + } + if record.complete || s.file(id).exists() { + return response(StatusCode::CONFLICT); + }; + { + // Use the same lock order as prune: a reservation must still exist + // when it becomes active, so pruning cannot orphan an upload. + let records = s.records.lock().unwrap(); + if !records.contains_key(id) { + return response(StatusCode::GONE); + } + if !s.active.lock().unwrap().insert(id.into()) { + return response(StatusCode::CONFLICT); + } + } + let dest = s.file(id); + let temp = dest.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); + let _guard = UploadGuard { + active: s.active.clone(), + id: id.into(), + temp: temp.clone(), + }; + if tokio::fs::create_dir_all(dest.parent().unwrap()) + .await + .is_err() + { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let Ok(mut file) = options.open(&temp).await else { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + let mut body = req.into_body(); + let mut count = 0u64; + loop { + let frame = + match tokio::time::timeout(std::time::Duration::from_secs(120), body.frame()).await + { + Ok(Some(frame)) => frame, + Ok(None) => break, + Err(_) => return response(StatusCode::REQUEST_TIMEOUT), + }; + let Ok(frame) = frame else { + return response(StatusCode::BAD_REQUEST); + }; + if let Ok(bytes) = frame.into_data() { + count = count.saturating_add(bytes.len() as u64); + if count > record.size { + return response(StatusCode::PAYLOAD_TOO_LARGE); + }; + if file.write_all(&bytes).await.is_err() { + return response(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + if count != record.size { + return response(StatusCode::BAD_REQUEST); + }; + if file.sync_all().await.is_err() { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + drop(file); + if tokio::fs::hard_link(&temp, &dest).await.is_err() { + return response(StatusCode::CONFLICT); + }; + #[cfg(unix)] + { + let Ok(directory) = tokio::fs::File::open(dest.parent().unwrap()).await else { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + if directory.sync_all().await.is_err() { + return response(StatusCode::INTERNAL_SERVER_ERROR); + } + } + let mut records = s.records.lock().unwrap(); + if let Some(r) = records.get_mut(id) { + r.complete = true; + } + if s.persist(&records).is_err() { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + return response(StatusCode::CREATED); + } + if req.method() != "GET" && req.method() != "HEAD" { + return response(StatusCode::METHOD_NOT_ALLOWED); + } + let Ok(mut file) = tokio::fs::File::open(s.file(id)).await else { + return response(StatusCode::GONE); + }; + let modified = chrono::DateTime::from_timestamp(record.created as i64, 0) + .map(|d| d.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) + .unwrap_or_default(); + if req + .headers() + .get("if-modified-since") + .and_then(|v| v.to_str().ok()) + == Some(modified.as_str()) + { + return response(StatusCode::NOT_MODIFIED); + } + let mut status = StatusCode::OK; + let mut start = 0; + let mut end = record.size.saturating_sub(1); + if let Some(range) = req.headers().get("range") { + let parsed = range + .to_str() + .ok() + .and_then(|r| r.strip_prefix("bytes=")) + .and_then(|r| r.split_once('-')) + .and_then(|(a, b)| { + if a.is_empty() { + let suffix = b.parse::().ok()?; + if suffix == 0 || record.size == 0 { + return None; + } + return Some((record.size.saturating_sub(suffix), record.size - 1)); + } + let start = a.parse::().ok()?; + let end = if b.is_empty() { + record.size.checked_sub(1)? + } else { + b.parse().ok()? + }; + (start <= end && start < record.size) + .then_some((start, end.min(record.size.saturating_sub(1)))) + }); + let Some((a, b)) = parsed else { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header("content-range", format!("bytes */{}", record.size)) + .body(Body::empty()) + .unwrap(); + }; + start = a; + end = b; + status = StatusCode::PARTIAL_CONTENT; + } + let length = if record.size == 0 { 0 } else { end - start + 1 }; + if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return response(StatusCode::INTERNAL_SERVER_ERROR); + }; + use tokio::io::AsyncReadExt; + let body = if req.method() == "HEAD" { + Body::empty() + } else { + Body::from_stream(ReaderStream::new(file.take(length))) + }; + let disposition = if record.content_type == "text/plain" + || ["image/", "video/", "audio/"] + .iter() + .any(|p| record.content_type.starts_with(p)) + { + "inline" + } else { + "attachment" + }; + let mut builder = Response::builder() + .status(status) + .header("content-type", &record.content_type) + .header("content-length", length) + .header( + "content-disposition", + format!( + "{disposition}; filename*=UTF-8''{}", + url_escape(&record.filename) + ), + ) + .header("x-content-type-options", "nosniff") + .header( + "content-security-policy", + "default-src 'none'; media-src 'self'; frame-ancestors 'none'", + ) + .header("access-control-allow-origin", "*") + .header("accept-ranges", "bytes") + .header("last-modified", modified) + .header("cache-control", "max-age=31556952, immutable") + .header("x-frame-options", "DENY") + .header("strict-transport-security", "max-age=31556952"); + if status == StatusCode::PARTIAL_CONTENT { + builder = builder.header( + "content-range", + format!("bytes {start}-{end}/{}", record.size), + ); + } + builder.body(body).unwrap() +} + +fn sign_jwt(secret: &str, payload: &serde_json::Value) -> String { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B}; + use hmac::{Hmac, Mac}; + let signed = format!( + "{}.{}", + B.encode(br#"{"alg":"HS256","typ":"JWT"}"#), + B.encode(serde_json::to_vec(payload).unwrap()) + ); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(signed.as_bytes()); + format!("{signed}.{}", B.encode(mac.finalize().into_bytes())) +} +fn verify_jwt(secret: &str, authorization: &[u8], id: &str, r: &Record) -> bool { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B}; + use hmac::{Hmac, Mac}; + let check = || -> Option<()> { + let token = bearer_token(authorization)?; + if token.len() > 8192 { + return None; + } + let (signed, sig) = token.rsplit_once('.')?; + let (head, body) = signed.split_once('.')?; + let header: serde_json::Value = serde_json::from_slice(&B.decode(head).ok()?).ok()?; + if header.get("alg")?.as_str()? != "HS256" { + return None; + } + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).ok()?; + mac.update(signed.as_bytes()); + mac.verify_slice(&B.decode(sig).ok()?).ok()?; + let p: serde_json::Value = serde_json::from_slice(&B.decode(body).ok()?).ok()?; + if p.get("exp")?.as_u64()? <= now() + || p.get("nbf") + .and_then(|v| v.as_u64()) + .is_some_and(|t| t > now()) + || p.get("slot")?.as_str()? != id + || p.get("sub")?.as_str()? != r.owner + || p.get("filename")?.as_str()? != r.filename + || p.get("filesize")?.as_u64()? != r.size + || p.get("filetype")?.as_str()? != r.content_type + { + return None; + } + Some(()) + }; + check().is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn upload_internal_mount_is_independent_of_advertised_proxy_prefix() { + let temp = tempfile::tempdir().unwrap(); + let service = UploadService::new(UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: "https://public.example.test/proxy/files".into(), + size_limit: 8, + daily_quota: 8, + global_quota: 8, + expires_after: None, + allowed_types: vec![], + secret: None, + }) + .unwrap(); + let slot = service + .request_slot("a@example.test", "hello.txt", 2, "text/plain") + .unwrap(); + assert!( + slot.url + .starts_with("https://public.example.test/proxy/files/") + ); + let suffix = slot + .url + .strip_prefix("https://public.example.test/proxy/files") + .unwrap(); + assert!(service.router_at("/internal/files").is_ok()); + let request = Request::builder() + .method("PUT") + .uri(format!("/internal/files{suffix}")) + .header("authorization", &slot.authorization) + .body(Body::from("hi")) + .unwrap(); + assert_eq!( + handle_at(service.clone(), request, "/internal/files") + .await + .status(), + StatusCode::CREATED + ); + let request = Request::builder() + .uri(format!("/internal/files{suffix}")) + .body(Body::empty()) + .unwrap(); + let reply = handle_at(service.clone(), request, "/internal/files").await; + assert_eq!(reply.status(), StatusCode::OK); + assert_eq!(reply.into_body().collect().await.unwrap().to_bytes(), "hi"); + let request = Request::builder() + .uri(format!("/proxy/files{suffix}")) + .body(Body::empty()) + .unwrap(); + assert_eq!( + handle_at(service, request, "/internal/files") + .await + .status(), + StatusCode::NOT_FOUND + ); + } + + #[test] + fn abandoned_slots_release_quota_without_pruning_existing_or_active_files() { + let temp = tempfile::tempdir().unwrap(); + let service = UploadService::new(UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: "https://upload.example.test/file_share".into(), + size_limit: 1, + daily_quota: 3, + global_quota: 3, + expires_after: None, + allowed_types: vec![], + secret: None, + }) + .unwrap(); + for name in ["abandoned", "existing", "active"] { + service + .request_slot("a@example.test", name, 1, "text/plain") + .unwrap(); + } + { + let mut records = service.records.lock().unwrap(); + for (id, record) in records.iter_mut() { + record.created = now() - 601; + if record.filename == "existing" { + // Imported archives do not carry a completion flag. + let path = service.file(id); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"x").unwrap(); + } else if record.filename == "active" { + service.active.lock().unwrap().insert(id.clone()); + } + } + } + service + .request_slot("a@example.test", "replacement", 1, "text/plain") + .unwrap(); + let records = service.records.lock().unwrap(); + assert_eq!(records.len(), 3); + assert!(!records.values().any(|r| r.filename == "abandoned")); + assert!(records.values().any(|r| r.filename == "existing")); + assert!(records.values().any(|r| r.filename == "active")); + } +} diff --git a/src/http_transport.rs b/src/http_transport.rs new file mode 100644 index 0000000..5b61e87 --- /dev/null +++ b/src/http_transport.rs @@ -0,0 +1,942 @@ +//! RFC 7395 WebSocket adapter to the shared XMPP stream engine. +use crate::wire::{Framer, escape}; +use axum::{ + Router, + extract::{ + State, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use futures_util::{SinkExt, StreamExt}; +use std::{future::Future, pin::Pin, sync::Arc}; +use tokio::io::{AsyncWriteExt, DuplexStream}; +pub type ConnectionHandler = + Arc Pin + Send>> + Send + Sync>; +/// The effective HTTP connection context is supplied by the HTTP listener. +pub type ContextConnectionHandler = Arc< + dyn Fn( + DuplexStream, + Option, + ) -> Pin + Send>> + + Send + + Sync, +>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HttpRoute { + Websocket, + Bosh, + Altconnect, +} + +#[derive(Clone)] +struct ConfiguredState { + handler: ContextConnectionHandler, + bosh: BoshState, + config: crate::http_config::HttpConfig, + websocket: bool, + bosh_enabled: bool, + secure: bool, +} +/// A fallback dispatcher uses literal mount paths, avoiding route-pattern conflicts and panics. +pub fn configured_router( + handler: ContextConnectionHandler, + config: crate::http_config::HttpConfig, + websocket: bool, + bosh: bool, + secure: bool, +) -> anyhow::Result { + configured_router_with_fallback(handler, config, websocket, bosh, secure, Router::new()) +} +/// Compose transport mounts with an existing HTTP app without merging fallbacks. +pub fn configured_router_with_fallback( + handler: ContextConnectionHandler, + config: crate::http_config::HttpConfig, + websocket: bool, + bosh: bool, + secure: bool, + fallback: Router, +) -> anyhow::Result { + anyhow::ensure!( + !(websocket && bosh && config.websocket_path == config.bosh_path), + "websocket and bosh HTTP paths conflict" + ); + let legacy: ConnectionHandler = { + let handler = handler.clone(); + Arc::new(move |stream| handler(stream, None)) + }; + let mut bosh_state = new_bosh_state(legacy); + bosh_state.context_handler = Some(handler.clone()); + let state = ConfiguredState { + handler, + bosh: bosh_state, + config, + websocket, + bosh_enabled: bosh, + secure, + }; + Ok(fallback + .layer(axum::middleware::from_fn_with_state( + state, + configured_middleware, + )) + .layer(axum::extract::DefaultBodyLimit::max(256 * 1024))) +} +async fn configured_middleware( + State(state): State, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let path = request.uri().path(); + let matches = + |mount: &str| path == mount || (mount != "/" && path.strip_suffix('/') == Some(mount)); + if (state.websocket && matches(&state.config.websocket_path)) + || (state.bosh_enabled && matches(&state.config.bosh_path)) + { + let route = if state.websocket && matches(&state.config.websocket_path) { + HttpRoute::Websocket + } else { + HttpRoute::Bosh + }; + let mut response = configured_request(State(state), request).await; + response.extensions_mut().insert(route); + response + } else { + next.run(request).await + } +} +async fn configured_request( + State(state): State, + request: axum::extract::Request, +) -> Response { + use axum::extract::{FromRequest, FromRequestParts}; + let host = request + .headers() + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()); + let Some(host) = host else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if !state.config.accepts_host(host) { + return StatusCode::NOT_FOUND.into_response(); + } + let path = request.uri().path(); + let matches = + |mount: &str| path == mount || (mount != "/" && path.strip_suffix('/') == Some(mount)); + let websocket = state.websocket && matches(&state.config.websocket_path); + let bosh = state.bosh_enabled && matches(&state.config.bosh_path); + if !websocket && !bosh { + return StatusCode::NOT_FOUND.into_response(); + } + let context = request + .extensions() + .get::() + .cloned(); + let secure = context.as_ref().map(|c| c.secure).unwrap_or(state.secure); + let considered = if websocket { + state.config.consider_websocket_secure + } else { + state.config.consider_bosh_secure + }; + if !secure && !considered { + return StatusCode::FORBIDDEN.into_response(); + } + let origins = if websocket { + &state.config.websocket_origins + } else { + &state.config.bosh_origins + }; + let origin = request.headers().get(axum::http::header::ORIGIN).cloned(); + if let Some(origin) = &origin + && !origin + .to_str() + .ok() + .is_some_and(|o| origins.iter().any(|a| a == o || a == "*")) + { + return StatusCode::FORBIDDEN.into_response(); + } + let method = request.method().clone(); + let mut response = if method == axum::http::Method::OPTIONS { + preflight(if websocket { + "GET, OPTIONS" + } else { + "POST, OPTIONS" + }) + } else if websocket && method == axum::http::Method::GET { + let (mut parts, _) = request.into_parts(); + match WebSocketUpgrade::from_request_parts(&mut parts, &()).await { + Ok(ws) => { + let handler: ConnectionHandler = + Arc::new(move |stream| (state.handler)(stream, context)); + upgrade(State(handler), parts.headers, ws).await + } + Err(e) => e.into_response(), + } + } else if bosh && method == axum::http::Method::POST { + match Bytes::from_request(request, &()).await { + Ok(bytes) => bosh_with_context(state.bosh, bytes, context).await, + Err(e) => e.into_response(), + } + } else { + ( + StatusCode::METHOD_NOT_ALLOWED, + [( + axum::http::header::ALLOW, + if websocket { + "GET, OPTIONS" + } else { + "POST, OPTIONS" + }, + )], + ) + .into_response() + }; + response + .headers_mut() + .remove(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN); + if let Some(origin) = origin { + response + .headers_mut() + .insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.headers_mut().append( + axum::http::header::VARY, + axum::http::HeaderValue::from_static("Origin"), + ); + } + response +} +pub fn websocket_router(handler: ConnectionHandler) -> Router { + let route = get(upgrade).options(|| async { preflight("GET, OPTIONS") }); + Router::new() + .route("/xmpp-websocket", route.clone()) + .route("/xmpp-websocket/", route) + .with_state(handler) +} +fn preflight(methods: &'static str) -> Response { + ( + [ + ("access-control-allow-origin", "*"), + ("access-control-allow-methods", methods), + ("access-control-allow-headers", "Content-Type"), + ], + "", + ) + .into_response() +} +async fn upgrade( + State(handler): State, + headers: axum::http::HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + if !headers + .get("sec-websocket-protocol") + .and_then(|v| v.to_str().ok()) + .is_some_and(|p| p.split(',').any(|p| p.trim() == "xmpp")) + { + return StatusCode::BAD_REQUEST.into_response(); + } + ws.protocols(["xmpp"]) + .max_message_size(256 * 1024) + .max_frame_size(256 * 1024) + .on_upgrade(move |socket| bridge(socket, handler)) +} +fn from_websocket(input: &str) -> anyhow::Result { + let d = roxmltree::Document::parse(input)?; + let n = d.root_element(); + if n.tag_name().namespace() == Some("urn:ietf:params:xml:ns:xmpp-framing") { + match n.tag_name().name() { + "open" => { + let mut out="'); + Ok(out) + } + "close" => Ok("".into()), + _ => anyhow::bail!("unknown framing element"), + } + } else { + anyhow::ensure!( + matches!( + n.tag_name().namespace(), + Some( + "jabber:client" + | "urn:ietf:params:xml:ns:xmpp-sasl" + | "urn:xmpp:sasl:2" + | "urn:xmpp:sm:3" + | "urn:xmpp:csi:0" + ) + ), + "unexpected websocket namespace" + ); + Ok(input.into()) + } +} +fn to_websocket(input: &str) -> anyhow::Result> { + if input.starts_with("" { + return Ok(Some( + "".into(), + )); + } + if input.starts_with(""); + let d = roxmltree::Document::parse(&xml)?; + let n = d.root_element(); + let mut out = ""); + return Ok(Some(out)); + } + let wrapped = format!( + "{input}" + ); + let d = roxmltree::Document::parse(&wrapped)?; + let n = d + .root_element() + .first_element_child() + .ok_or_else(|| anyhow::anyhow!("no XML"))?; + // Every message is an independent XML document. Serialize the parsed + // subtree so inherited namespaces also survive on children and attributes. + Ok(Some(crate::protocol::xml(n))) +} +async fn bridge(socket: WebSocket, handler: ConnectionHandler) { + let (internal, transport) = tokio::io::duplex(256 * 1024); + let mut worker = tokio::spawn(handler(internal)); + let (mut read, mut write) = tokio::io::split(transport); + let (mut tx, mut rx) = socket.split(); + let incoming = async { + while let Some(Ok(message)) = rx.next().await { + match message { + Message::Text(text) => { + let text = from_websocket(&text)?; + write.write_all(text.as_bytes()).await?; + } + Message::Close(_) => break, + Message::Binary(_) => anyhow::bail!("binary XMPP frames forbidden"), + _ => {} + } + } + anyhow::Ok(()) + }; + let outgoing = async { + let mut framer = Framer::default(); + loop { + let frame = framer.next(&mut read).await?; + if let Some(text) = to_websocket(&frame)? { + tx.send(Message::Text(text.into())).await?; + } + if frame == "" { + break; + } + } + anyhow::Ok(()) + }; + tokio::select! {_=incoming=>{},_=outgoing=>{}} + let _ = tokio::time::timeout(Duration::from_secs(2), tx.close()).await; + // Deliver buffered XMPP close frames before retiring the engine. EOF alone + // remains an ungraceful disconnect, allowing stream management to hibernate. + let _ = write.shutdown().await; + if tokio::time::timeout(Duration::from_secs(5), &mut worker) + .await + .is_err() + { + worker.abort(); + } +} +#[cfg(test)] +mod tests { + use super::*; + fn configured_state(secure: bool) -> ConfiguredState { + let config = serde_json::from_value(serde_json::json!({"global":{"http_paths":{"websocket":"/custom/ws","bosh":"/custom/bind"},"http_cors_override":{"websocket":{"origins":["https://allowed.test"]},"bosh":{"origins":["https://allowed.test"]}}},"hosts":{"a.test":{"options":{}}}})).unwrap(); + let handler: ContextConnectionHandler = + Arc::new(|_, _| Box::pin(async { panic!("no stream expected") })); + let legacy: ConnectionHandler = + Arc::new(|_| Box::pin(async { panic!("no stream expected") })); + ConfiguredState { + handler, + bosh: new_bosh_state(legacy), + config: crate::http_config::HttpConfig::for_host(&config, "a.test").unwrap(), + websocket: true, + bosh_enabled: true, + secure, + } + } + async fn configured_status( + state: ConfiguredState, + method: &str, + path: &str, + host: &str, + origin: Option<&str>, + context: Option, + ) -> Response { + let mut request = axum::http::Request::builder() + .method(method) + .uri(path) + .header("host", host); + if let Some(origin) = origin { + request = request.header("origin", origin); + } + let mut request = request.body(axum::body::Body::empty()).unwrap(); + if let Some(context) = context { + request.extensions_mut().insert(context); + } + configured_request(State(state), request).await + } + #[tokio::test] + async fn configured_mount_host_origin_and_secure_dispatch() { + assert_eq!( + configured_status( + configured_state(true), + "OPTIONS", + "/custom/ws/", + "a.test", + Some("https://allowed.test"), + None + ) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + configured_status( + configured_state(true), + "OPTIONS", + "/xmpp-websocket", + "a.test", + None, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + configured_status( + configured_state(true), + "OPTIONS", + "/custom/bind", + "unknown.test", + None, + None + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + configured_status( + configured_state(true), + "OPTIONS", + "/custom/bind", + "a.test", + Some("https://bad.test"), + None + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + configured_status( + configured_state(false), + "OPTIONS", + "/custom/bind", + "a.test", + None, + None + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + let context = crate::http_proxy::RequestContext { + socket_peer: "127.0.0.1:1234".parse().unwrap(), + peer_ip: "198.51.100.1".parse().unwrap(), + secure: true, + }; + let response = configured_status( + configured_state(false), + "OPTIONS", + "/custom/bind", + "a.test", + Some("https://allowed.test"), + Some(context), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN], + "https://allowed.test" + ); + assert_eq!( + configured_status( + configured_state(true), + "GET", + "/custom/bind", + "a.test", + None, + None + ) + .await + .status(), + StatusCode::METHOD_NOT_ALLOWED + ); + } + #[test] + fn independent_framing() { + assert!(from_websocket("").unwrap().starts_with("").is_err()); + assert!(from_websocket("").is_ok()); + assert!(from_websocket("").is_ok()); + assert!(to_websocket("").unwrap().unwrap().contains("xmlns:stream=")); + let xml = to_websocket("x") + .unwrap() + .unwrap(); + assert_eq!( + roxmltree::Document::parse(&xml) + .unwrap() + .root_element() + .tag_name() + .namespace(), + Some("jabber:client") + ); + } + #[test] + fn websocket_preserves_inherited_namespaces_and_spaced_declarations() { + for input in [ + "hello", + "hello", + "hello", + ] { + let xml = to_websocket(input).unwrap().unwrap(); + let doc = roxmltree::Document::parse(&xml).unwrap(); + let body = doc.descendants().find(|n| n.has_tag_name("body")).unwrap(); + assert_eq!(body.tag_name().namespace(), Some("jabber:client")); + if input.contains("stream:test") { + assert_eq!( + body.attribute(("http://etherx.jabber.org/streams", "test")), + Some("value") + ); + } + } + } + #[tokio::test] + async fn bosh_processing_capacity_is_bounded() { + let requests = Arc::new(tokio::sync::Semaphore::new(256)); + let _occupied = requests.clone().acquire_many_owned(256).await.unwrap(); + let state = BoshState { + handler: Arc::new(|_| Box::pin(async { panic!("must not create a session") })), + sessions: Arc::new(Mutex::new(BTreeMap::new())), + requests, + context_handler: None, + }; + let response = bosh(State(state), Bytes::from_static(b"")).await; + let bytes = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert!( + std::str::from_utf8(&bytes) + .unwrap() + .contains("resource-constraint") + ); + } +} + +use axum::{body::Bytes, routing::post}; +use std::{ + collections::{BTreeMap, HashSet}, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, Notify, mpsc}; +const BOSH: &str = "http://jabber.org/protocol/httpbind"; +struct Sequence { + next: u64, + pending: HashSet, + responses: BTreeMap, +} +struct BoshSession { + domain: String, + wait: u64, + sequence: Mutex, + changed: Notify, + poll_wakeup: Notify, + writer: Mutex>, + output: Mutex>, + last: std::sync::Mutex, + tasks: Vec>, + engine_done: Arc, +} +impl Drop for BoshSession { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } +} +#[derive(Clone)] +struct BoshState { + handler: ConnectionHandler, + context_handler: Option, + sessions: Arc>>>, + requests: Arc, +} +fn new_bosh_state(handler: ConnectionHandler) -> BoshState { + let state = BoshState { + handler, + context_handler: None, + sessions: Arc::new(Mutex::new(BTreeMap::new())), + requests: Arc::new(tokio::sync::Semaphore::new(256)), + }; + let weak = Arc::downgrade(&state.sessions); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let Some(map) = weak.upgrade() else { break }; + map.lock() + .await + .retain(|_, s| s.last.lock().unwrap().elapsed() < Duration::from_secs(120)); + } + }); + state +} +pub fn bosh_router(handler: ConnectionHandler) -> Router { + let state = new_bosh_state(handler); + let route = post(bosh).options(|| async { preflight("POST, OPTIONS") }); + Router::new() + .route("/http-bind", route.clone()) + .route("/http-bind/", route) + .layer(axum::extract::DefaultBodyLimit::max(256 * 1024)) + .with_state(state) +} +fn bosh_reply(xml: String) -> Response { + ( + [ + (axum::http::header::CONTENT_TYPE, "text/xml; charset=utf-8"), + ( + axum::http::HeaderName::from_static("access-control-allow-origin"), + "*", + ), + ], + xml, + ) + .into_response() +} +fn terminate(condition: &str) -> Response { + bosh_reply(format!( + "" + )) +} +async fn bosh(State(state): State, bytes: Bytes) -> Response { + bosh_with_context(state, bytes, None).await +} +async fn bosh_with_context( + state: BoshState, + bytes: Bytes, + context: Option, +) -> Response { + // A disconnected HTTP client must not cancel stanza processing or leave a + // pending RID without its cached response. A retry uses that same response. + let Ok(permit) = state.requests.clone().try_acquire_owned() else { + return terminate("resource-constraint"); + }; + match tokio::spawn(async move { + let _permit = permit; + bosh_request(state, bytes, context).await + }) + .await + { + Ok(response) => response, + Err(_) => terminate("remote-connection-failed"), + } +} +async fn bosh_request( + state: BoshState, + bytes: Bytes, + context: Option, +) -> Response { + let Ok(text) = std::str::from_utf8(&bytes) else { + return terminate("bad-request"); + }; + let Ok(doc) = roxmltree::Document::parse(text) else { + return terminate("bad-request"); + }; + let body = doc.root_element(); + if body.tag_name().name() != "body" || body.tag_name().namespace() != Some(BOSH) { + return terminate("bad-request"); + }; + let Some(rid) = body + .attribute("rid") + .and_then(|r| r.parse::().ok()) + .filter(|r| *r < 9_007_199_254_740_991) + else { + return terminate("bad-request"); + }; + let initial = body.attribute("sid").is_none(); + let sid = body + .attribute("sid") + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); + let session = if initial { + let Some(domain) = body.attribute("to").filter(|v| !v.is_empty()) else { + return terminate("improper-addressing"); + }; + if body + .attribute("hold") + .and_then(|h| h.parse::().ok()) + .unwrap_or(1) + > 1 + { + return terminate("policy-violation"); + } + let wait = body + .attribute("wait") + .and_then(|v| v.parse::().ok()) + .unwrap_or(30) + .clamp(1, 60); + let (internal, external) = tokio::io::duplex(256 * 1024); + let engine_done = Arc::new(Notify::new()); + let done = engine_done.clone(); + let handler = if let Some(handler) = &state.context_handler { + handler(internal, context) + } else { + (state.handler)(internal) + }; + let engine = tokio::spawn(async move { + handler.await; + done.notify_one(); + }); + let (mut read, write) = tokio::io::split(external); + let (tx, rx) = mpsc::channel(128); + let pump = tokio::spawn(async move { + let mut framer = Framer::default(); + while let Ok(frame) = framer.next(&mut read).await { + if frame.starts_with("" { + break; + } + let Ok(Some(xml)) = to_websocket(&frame) else { + break; + }; + if tx.send(xml).await.is_err() { + break; + } + } + }); + let session = Arc::new(BoshSession { + domain: domain.into(), + wait, + sequence: Mutex::new(Sequence { + next: rid, + pending: HashSet::new(), + responses: BTreeMap::new(), + }), + changed: Notify::new(), + poll_wakeup: Notify::new(), + writer: Mutex::new(write), + output: Mutex::new(rx), + last: std::sync::Mutex::new(Instant::now()), + tasks: vec![engine, pump], + engine_done, + }); + let mut map = state.sessions.lock().await; + if map.len() >= 4096 { + return terminate("resource-constraint"); + } + map.insert(sid.clone(), session.clone()); + session + } else { + let map = state.sessions.lock().await; + let Some(session) = map.get(&sid) else { + return terminate("item-not-found"); + }; + session.clone() + }; + *session.last.lock().unwrap() = Instant::now(); + match tokio::time::timeout( + Duration::from_secs(90), + process_bosh_request(&state, body, rid, initial, &sid, &session), + ) + .await + { + Ok(response) => response, + Err(_) => { + state.sessions.lock().await.remove(&sid); + for task in &session.tasks { + task.abort(); + } + terminate("remote-connection-failed") + } + } +} +async fn process_bosh_request( + state: &BoshState, + body: roxmltree::Node<'_, '_>, + rid: u64, + initial: bool, + sid: &str, + session: &BoshSession, +) -> Response { + // A cached RID must never replay its stanzas. Two outstanding requests are allowed. + let reorder_deadline = Instant::now() + Duration::from_secs(5); + let mut seq = loop { + let seq = session.sequence.lock().await; + if rid <= seq.next { + break seq; + } + if rid > seq.next.saturating_add(1) || Instant::now() >= reorder_deadline { + return terminate("item-not-found"); + } + drop(seq); + tokio::time::sleep(Duration::from_millis(10)).await; + }; + if let Some(cached) = seq.responses.get(&rid) { + return bosh_reply(cached.clone()); + } + if seq.pending.contains(&rid) { + drop(seq); + let deadline = Instant::now() + Duration::from_secs(session.wait + 5); + loop { + let notified = session.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(cached) = session.sequence.lock().await.responses.get(&rid).cloned() { + return bosh_reply(cached); + } + if tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), notified) + .await + .is_err() + { + return terminate("remote-connection-failed"); + } + } + } + if rid != seq.next || seq.pending.len() >= 2 { + return terminate("item-not-found"); + }; + seq.pending.insert(rid); + if seq.pending.len() > 1 { + session.poll_wakeup.notify_one(); + } + seq.next += 1; + let mut writer = session.writer.lock().await; + drop(seq); + let mut input = String::new(); + if initial || body.attribute(("urn:xmpp:xbosh", "restart")) == Some("true") { + input.push_str(&format!("",escape(&session.domain))); + } + for node in body.children().filter(|n| n.is_element()) { + input.push_str(&crate::protocol::xml(node)); + } + let closing = body.attribute("type") == Some("terminate"); + if closing { + input.push_str(""); + } + if writer.write_all(input.as_bytes()).await.is_err() { + state.sessions.lock().await.remove(sid); + return terminate("remote-connection-failed"); + } + drop(writer); + let mut payload = String::new(); + if closing { + // Let the engine consume the stream close and run graceful cleanup before + // removing the session: dropping it aborts the engine task. In particular, + // a reconnect must not race with the old resource remaining registered. + let mut rx = session.output.lock().await; + let _ = tokio::time::timeout(Duration::from_secs(session.wait), async { + while let Some(frame) = rx.recv().await { + payload.push_str(&frame); + } + session.engine_done.notified().await; + }) + .await; + } else { + let mut rx = session.output.lock().await; + match tokio::select! { v=tokio::time::timeout(Duration::from_secs(session.wait), rx.recv())=>v, _=session.poll_wakeup.notified()=>Ok(Some(String::new())) } + { + Ok(Some(first)) => { + payload.push_str(&first); + while let Ok(next) = rx.try_recv() { + payload.push_str(&next); + } + } + Ok(None) => { + state.sessions.lock().await.remove(sid); + return terminate("remote-stream-error"); + } + Err(_) => {} + } + } + let extra = if initial { + format!( + " sid='{sid}' wait='{}' hold='1' requests='2' inactivity='120' polling='1' ver='1.11' from='{}' xmlns:xmpp='urn:xmpp:xbosh' xmpp:version='1.0'", + session.wait, + escape(&session.domain) + ) + } else if closing { + " type='terminate'".into() + } else { + String::new() + }; + let result = format!("{payload}"); + let mut seq = session.sequence.lock().await; + seq.pending.remove(&rid); + seq.responses.insert(rid, result.clone()); + while seq.responses.len() > 4 { + seq.responses.pop_first(); + } + drop(seq); + session.changed.notify_waiters(); + if closing { + state.sessions.lock().await.remove(sid); + } + bosh_reply(result) +} +/// Apply the exact configured origin allowlist; requests without Origin remain usable by native clients. +pub fn with_origins(router: Router, origins: Vec) -> Router { + let origins = Arc::new(origins); + router.layer(axum::middleware::from_fn( + move |req: axum::extract::Request, next: axum::middleware::Next| { + let origins = origins.clone(); + async move { + let origin = req.headers().get(axum::http::header::ORIGIN).cloned(); + if let Some(origin) = &origin + && !origin + .to_str() + .ok() + .is_some_and(|o| origins.iter().any(|a| a == o || a == "*")) + { + return StatusCode::FORBIDDEN.into_response(); + } + let mut response = next.run(req).await; + response + .headers_mut() + .remove(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN); + if let Some(origin) = origin { + response + .headers_mut() + .insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.headers_mut().append( + axum::http::header::VARY, + axum::http::HeaderValue::from_static("Origin"), + ); + } + response + } + }, + )) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a380501 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,49 @@ +pub mod auth; +pub mod config; +pub mod lua; +pub mod server; +pub mod storage; +pub mod wire; + +pub mod protocol; + +pub mod sessions; + +pub mod sasl; + +pub mod extras; + +pub mod account_modules; +pub mod archive; +pub mod http_service; +pub mod mam; +pub mod muc; +pub mod pubsub; + +pub mod http_transport; + +pub mod push; + +pub mod federation; + +pub mod admin; + +pub mod csi; +pub mod data_lock; + +pub mod preauth; + +pub mod tls_binding; + +pub mod logging; + +pub mod pep_diagnostics; + +pub mod http_config; +pub mod http_proxy; + +pub mod muc_recovery; + +pub mod muc_history; + +pub mod http_altconnect; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..6a9cd44 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,385 @@ +//! Small, instance-owned structured logger with Prosody-style sink configuration. +//! +//! Shorthand level keys mean that level and above; explicit sink strings select +//! exactly one level. `*console` and `*stderr` write to +//! stderr; other destinations are append-only files. Array entries also accept +//! `{ "levels": { "min": "debug", "max": "error" }, "to": "file", +//! "filename": "server.log" }`. No process-global logger is installed. +use anyhow::{Result, anyhow, bail}; +use chrono::{SecondsFormat, Utc}; +use serde_json::Value; +use std::{ + fmt, + fs::{File, OpenOptions}, + io::{self, Write}, + sync::{Arc, Mutex}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + Debug, + Info, + Warn, + Error, +} +impl Level { + pub fn parse(value: &str) -> Result { + match value { + "debug" => Ok(Self::Debug), + "info" => Ok(Self::Info), + "warn" | "warning" => Ok(Self::Warn), + "error" => Ok(Self::Error), + _ => bail!("unknown log level: {value}"), + } + } +} +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + }) + } +} +struct Sink { + levels: u8, + output: Output, +} +enum Output { + Stderr, + File { file: File, path: String }, +} +struct State { + sinks: Vec, + json: bool, + reported_failure: bool, +} +/// Clones share sinks and serialize complete records, including file writes. +#[derive(Clone)] +pub struct Logger { + state: Arc>, +} +impl Logger { + pub fn from_config(config: Option<&Value>) -> Result { + Self::from_config_with_format(config, None) + } + /// `format` is the separate `log_format` option: `plain` (default) or `json`. + pub fn from_config_with_format(config: Option<&Value>, format: Option<&str>) -> Result { + let json = match format.unwrap_or("plain") { + "plain" => false, + "json" => true, + other => bail!("unknown log format: {other}"), + }; + let mut specs = Vec::new(); + match config { + None | Some(Value::Null) => { + specs.push((level_range(Level::Info, Level::Error), "*stderr".to_owned())) + } + Some(Value::String(s)) => { + if s.starts_with('*') { + specs.push((level_range(Level::Debug, Level::Error), s.clone())); + } else if let Ok(level) = Level::parse(s) { + specs.push((level_range(level, Level::Error), "*stderr".to_owned())); + } else { + specs.push((level_range(Level::Info, Level::Error), s.clone())); + } + } + Some(Value::Object(map)) if !map.contains_key("to") => { + for (level, destination) in map { + let min = Level::parse(level)?; + let path = destination + .as_str() + .ok_or_else(|| anyhow!("log destination for {level} must be a string"))?; + specs.push((level_range(min, Level::Error), path.to_owned())); + } + } + Some(Value::Object(_)) => specs.push(parse_sink(config.unwrap())?), + Some(Value::Array(entries)) => { + for entry in entries { + specs.push(parse_sink(entry)?); + } + } + _ => bail!("log must be a level, destination, table, or array of sinks"), + } + // Validate all destinations before opening any file. + for (_, destination) in &specs { + if destination.is_empty() + || (destination.starts_with('*') + && destination != "*console" + && destination != "*stderr") + { + bail!("unsupported log destination: {destination}"); + } + } + // Each destination owns one handle and receives each record once, even + // when several shorthand ranges or explicit level sets overlap. + let mut destinations: Vec<(u8, String)> = Vec::new(); + for (levels, destination) in specs { + let destination = if destination == "*console" { + "*stderr".to_owned() + } else { + destination + }; + if let Some((mask, _)) = destinations + .iter_mut() + .find(|(_, existing)| *existing == destination) + { + *mask |= levels; + } else { + destinations.push((levels, destination)); + } + } + let mut sinks = Vec::new(); + for (levels, destination) in destinations { + let output = if destination.starts_with('*') { + Output::Stderr + } else { + let file = open_log_file(&destination)?; + Output::File { + file, + path: destination, + } + }; + sinks.push(Sink { levels, output }); + } + Ok(Self { + state: Arc::new(Mutex::new(State { + sinks, + json, + reported_failure: false, + })), + }) + } + /// Reopen files after external log rotation. All replacements are prepared + /// before any sink is changed; clones immediately share the new handles. + pub fn reopen(&self) -> Result<()> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let mut replacements = Vec::new(); + for (index, sink) in state.sinks.iter().enumerate() { + if let Output::File { path, .. } = &sink.output { + replacements.push((index, open_log_file(path)?)); + } + } + for (index, replacement) in replacements { + if let Output::File { file, .. } = &mut state.sinks[index].output { + *file = replacement; + } + } + Ok(()) + } + /// Emit one record. Values of credential-bearing fields are always redacted. + /// I/O failures are reported once per logger to stderr and never panic. + pub fn event( + &self, + level: Level, + module: &str, + connection: Option<&str>, + event: &str, + fields: &[(&str, &str)], + ) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if !state + .sinks + .iter() + .any(|sink| sink.levels & level_bit(level) != 0) + { + return; + } + let record = format_record(state.json, level, module, connection, event, fields); + let mut failure = false; + for sink in &mut state.sinks { + if sink.levels & level_bit(level) == 0 { + continue; + } + let result = match &mut sink.output { + Output::Stderr => io::stderr().lock().write_all(record.as_bytes()), + Output::File { file, .. } => file.write_all(record.as_bytes()), + }; + failure |= result.is_err(); + } + if failure && !state.reported_failure { + state.reported_failure = true; + let _ = io::stderr() + .lock() + .write_all(b"logging error: failed to write a log record\n"); + } + } +} +fn open_log_file(path: &str) -> Result { + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(path) + .map_err(|err| anyhow!("cannot open log file {path}: {err}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(file) +} +fn level_bit(level: Level) -> u8 { + 1 << level as u8 +} +fn level_range(min: Level, max: Level) -> u8 { + [Level::Debug, Level::Info, Level::Warn, Level::Error] + .iter() + .filter(|&&level| level >= min && level <= max) + .fold(0, |mask, &level| mask | level_bit(level)) +} +fn parse_sink(value: &Value) -> Result<(u8, String)> { + if let Some(destination) = value.as_str() { + if !destination.starts_with('*') { + bail!("array log destinations must name a sink with *"); + } + return Ok(( + level_range(Level::Debug, Level::Error), + destination.to_owned(), + )); + } + let sink = value + .as_object() + .ok_or_else(|| anyhow!("log sink must be an object or sink name"))?; + let levels = match sink.get("levels") { + None => level_range(Level::Debug, Level::Error), + Some(Value::String(level)) => level_bit(Level::parse(level)?), + Some(Value::Array(levels)) => { + let mut mask = 0; + for level in levels { + mask |= level_bit(Level::parse( + level + .as_str() + .ok_or_else(|| anyhow!("log level must be a string"))?, + )?); + } + mask + } + Some(Value::Object(range)) => { + let parse_bound = |name: &str, default| -> Result { + match range.get(name) { + None => Ok(default), + Some(value) => Level::parse( + value + .as_str() + .ok_or_else(|| anyhow!("log {name} must be a string"))?, + ), + } + }; + let min = parse_bound("min", Level::Debug)?; + let max = parse_bound("max", Level::Error)?; + if min > max { + bail!("log minimum exceeds maximum"); + } + level_range(min, max) + } + _ => bail!("log levels must be a string, level array, or range"), + }; + let destination = match sink.get("to").and_then(Value::as_str) { + Some("file") => sink + .get("filename") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("file log sink requires filename"))? + .to_owned(), + Some("console" | "stderr") => "*stderr".to_owned(), + _ => bail!("unsupported log sink type"), + }; + Ok((levels, destination)) +} +fn sensitive(key: &str) -> bool { + let key: String = key + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + [ + "password", + "passwd", + "secret", + "token", + "proof", + "credential", + "authxml", + "raw", + "authorization", + "cookie", + "sasl", + "payload", + ] + .iter() + .any(|word| key.contains(word)) +} +fn quoted(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"[invalid]\"".into()) +} +fn format_record( + json: bool, + level: Level, + module: &str, + connection: Option<&str>, + event: &str, + fields: &[(&str, &str)], +) -> String { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + if json { + let fields: Vec = fields.iter().map(|(key, value)| serde_json::json!({"key": key, "value": if sensitive(key) { "[REDACTED]" } else { value }})).collect(); + return format!( + "{}\n", + serde_json::json!({"timestamp":timestamp,"level":level.to_string(),"module":module,"connection":connection,"event":event,"fields":fields}) + ); + } + let mut line = format!( + "timestamp={} level={} module={} event={}", + quoted(×tamp), + level, + quoted(module), + quoted(event) + ); + if let Some(connection) = connection { + line.push_str(&format!(" connection={}", quoted(connection))); + } + for (key, value) in fields { + line.push_str(&format!( + " {}={}", + quoted(key), + quoted(if sensitive(key) { "[REDACTED]" } else { value }) + )); + } + line.push('\n'); + line +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn console_aliases_share_one_sink_with_combined_levels() { + let config = serde_json::json!([ + "*console", + "*stderr", + {"to":"console","levels":"info"}, + {"to":"stderr","levels":"error"} + ]); + let logger = Logger::from_config(Some(&config)).unwrap(); + let state = logger.state.lock().unwrap(); + assert_eq!(state.sinks.len(), 1); + assert!(matches!(state.sinks[0].output, Output::Stderr)); + assert_eq!( + state.sinks[0].levels, + level_range(Level::Debug, Level::Error) + ); + } +} diff --git a/src/lua.rs b/src/lua.rs new file mode 100644 index 0000000..0868dfd --- /dev/null +++ b/src/lua.rs @@ -0,0 +1,70 @@ +//! Bounded Lua evaluation for configuration and legacy data. Never executes plugins. +use mlua::{HookTriggers, Lua, Value, VmState}; +use serde_json::{Map, Number, Value as Json}; +use std::{cell::Cell, rc::Rc}; + +pub fn bounded_vm() -> mlua::Result { + let lua = Lua::new(); + lua.set_memory_limit(16 * 1024 * 1024)?; + let count = Rc::new(Cell::new(0_u32)); + lua.set_hook( + HookTriggers::new().every_nth_instruction(1000), + move |_, _| { + count.set(count.get() + 1); + if count.get() > 2000 { + return Err(mlua::Error::runtime("Lua instruction budget exceeded")); + } + Ok(VmState::Continue) + }, + )?; + Ok(lua) +} + +/// Reject mixed/holey tables rather than silently dropping numeric keys. +/// Empty tables become objects (module-list readers accept empty objects). +pub fn json_value(value: Value, depth: usize) -> mlua::Result { + if depth > 64 { + return Err(mlua::Error::runtime("table nesting/cycle limit exceeded")); + } + Ok(match value { + Value::Nil => Json::Null, + Value::Boolean(v) => Json::Bool(v), + Value::Integer(v) => Json::Number(v.into()), + Value::Number(v) => Json::Number( + Number::from_f64(v).ok_or_else(|| mlua::Error::runtime("non-finite number"))?, + ), + Value::String(v) => Json::String(v.to_str()?.to_string()), + Value::Table(t) => { + let mut object = Map::new(); + let mut sequence = std::collections::BTreeMap::new(); + for pair in t.pairs::() { + let (key, value) = pair?; + let value = json_value(value, depth + 1)?; + match key { + Value::String(s) => { + object.insert(s.to_str()?.to_string(), value); + } + Value::Integer(i) if i > 0 => { + sequence.insert(i, value); + } + _ => return Err(mlua::Error::runtime("unsupported Lua table key")), + } + } + if sequence.is_empty() { + Json::Object(object) + } else { + if !object.is_empty() || sequence.keys().copied().ne(1..=sequence.len() as i64) { + return Err(mlua::Error::runtime( + "mixed or sparse Lua tables are not supported in configuration", + )); + } + Json::Array(sequence.into_values().collect()) + } + } + _ => { + return Err(mlua::Error::runtime( + "configuration contains executable or unsupported values", + )); + } + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e9d898d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,116 @@ +use anyhow::{Context, Result, bail}; +use rust_xmpp::config; +use std::{env, path::Path}; + +#[tokio::main] +async fn main() { + if let Err(e) = run().await { + eprintln!("error: {e:#}"); + std::process::exit(1); + } +} +async fn run() -> Result<()> { + let args: Vec<_> = env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("check") => { + let path = args + .get(1) + .map(String::as_str) + .unwrap_or("/etc/prosody/prosody.cfg.lua"); + let (c, d) = rust_xmpp::server::settings_config(Path::new(path))?; + let data = c + .option(&d, "data_path") + .and_then(|v| v.as_str()) + .context("data_path missing")?; + let socket = rust_xmpp::server::admin_path(&c, &d, Path::new(data)); + let reply = rust_xmpp::admin::query(&socket, "server:status()", None).await?; + if reply.error { + bail!("server status query failed") + }; + println!("{}", reply.message); + } + Some("shell") if args.len() >= 3 => { + let (c, d) = rust_xmpp::server::settings_config(Path::new(&args[1]))?; + let data = c + .option(&d, "data_path") + .and_then(|v| v.as_str()) + .context("data_path")?; + let path = rust_xmpp::server::admin_path(&c, &d, Path::new(data)); + let reply = rust_xmpp::admin::query(&path, &args[2], None).await?; + for line in reply.output { + println!("{line}"); + } + if reply.error { + bail!("{}", reply.message) + } + println!("{}", reply.message); + } + Some("--version") => println!( + "rust-xmpp {} — experimental XMPP runtime", + env!("CARGO_PKG_VERSION") + ), + Some("check-config") if args.len() == 2 => { + let cfg = config::load(Path::new(&args[1]))?; + println!( + "Configuration parsed and basic types validated. Runtime support is checked separately by serve." + ); + println!("Virtual hosts/components: {}", cfg.hosts.len()); + for name in cfg.hosts.keys() { + println!( + "{name}: {}", + cfg.modules(name)? + .into_iter() + .collect::>() + .join(", ") + ); + } + for d in cfg.diagnostics() { + println!("{} [{}]: {}", d.level, d.scope, d.message); + } + } + Some("convert") if args.len() == 4 => { + let cfg = config::load(Path::new(&args[1]))?; + let text = match args[2].as_str() { + "json" => serde_json::to_string_pretty(&cfg)?, + "yaml" | "yml" => serde_yaml_ng::to_string(&cfg)?, + "toml" => toml::to_string_pretty(&cfg)?, + _ => bail!("output format must be json, yaml or toml"), + }; + // Refuse overwrite; owner-only mode because converted configs contain secrets. + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts + .open(&args[3]) + .context("cannot create output (existing files are never overwritten)")?; + use std::io::Write; + file.write_all(text.as_bytes())?; + file.sync_all()?; + println!("Written {}", args[3]); + } + Some("serve") if args.len() == 3 && args[2] == "--experimental" => { + rust_xmpp::server::serve(Path::new(&args[1])).await?; + } + Some("serve") => { + bail!("use serve CONFIG --experimental; full Prosody replacement is not implemented") + } + Some("create-user") if args.len() == 4 && args[3] == "--password-stdin" => { + let (data, domain, hash) = rust_xmpp::server::account_config(Path::new(&args[1]))?; + let _data_guard = rust_xmpp::data_lock::DataDirectoryGuard::acquire(&data)?; + let mut password = String::new(); + std::io::stdin().read_line(&mut password)?; + let password = password.trim_end_matches(['\r', '\n']); + rust_xmpp::auth::create(&data, &domain, &args[2], password, &hash)?; + println!("Account created: {}@{}", args[2], domain); + } + Some("--help") | None => println!( + "rust-xmpp check-config PATH\nrust-xmpp convert PATH json|yaml|toml OUTPUT\nrust-xmpp --version\n\nExperimental runtime: serve CONFIG --experimental\ncreate-user CONFIG USER --password-stdin\ncheck [CONFIG] (running server healthcheck)\nshell CONFIG COMMAND\n\nSTARTTLS, SCRAM/PLAIN, local messaging, roster, MAM, MUC, PEP/pubsub, stream management, push, federation and HTTP transports are implemented with limitations; see docs/COMPATIBILITY.md." + ), + _ => bail!("invalid command; use --help"), + } + Ok(()) +} diff --git a/src/mam.rs b/src/mam.rs new file mode 100644 index 0000000..f8358e0 --- /dev/null +++ b/src/mam.rs @@ -0,0 +1,690 @@ +//! Personal XEP-0313 queries, preferences and RSM paging. +use crate::{ + archive, + protocol::{self, ModuleResult}, + storage::{self, LuaValue as V}, + wire::escape, +}; +use anyhow::{Context, Result, anyhow, bail}; +use std::path::Path; +pub const NS: &str = "urn:xmpp:mam:2"; +pub const EXTENDED_NS: &str = "urn:xmpp:mam:2#extended"; +fn s(x: &str) -> V { + V::String(x.as_bytes().to_vec()) +} +fn prep_jid(value: &str) -> Result { + let bare = crate::account_modules::normalize_bare_jid(protocol::bare(value))?; + if let Some((_, resource)) = value.split_once('/') { + let resource = stringprep::resourceprep(resource).map_err(|e| anyhow!(e.to_string()))?; + anyhow::ensure!( + !resource.is_empty() && resource.len() <= 1023, + "invalid resource" + ); + Ok(format!("{bare}/{resource}")) + } else { + Ok(bare) + } +} +fn policy(v: Option<&V>) -> &str { + match v { + Some(V::Bool(false)) => "never", + Some(V::String(_)) => "roster", + _ => "always", + } +} +fn prefs_xml(p: &V) -> String { + let mut yes = String::new(); + let mut no = String::new(); + if let V::Table(t) = p { + for (k, v) in t { + if let V::String(k) = k { + let j = format!("{}", escape(&String::from_utf8_lossy(k))); + if v == &V::Bool(true) { + yes.push_str(&j) + } else if v == &V::Bool(false) { + no.push_str(&j) + } + } + } + } + format!( + "{yes}{no}", + policy(p.get_key(&V::Bool(false))) + ) +} +/// Apply stored personal policy. default_policy is always/never/roster from config. +pub fn should_archive( + root: &Path, + domain: &str, + user: &str, + peer: &str, + default_policy: &str, +) -> Result { + should_archive_with_options(root, domain, user, peer, default_policy, false) +} +pub fn should_archive_with_options( + root: &Path, + domain: &str, + user: &str, + peer: &str, + default_policy: &str, + smart_enable: bool, +) -> Result { + let stored = storage::load(root, domain, Some(user), "archive_prefs")?; + if smart_enable && stored.is_none() { + return Ok(false); + } + let p = stored.unwrap_or_default(); + if let Some(V::Bool(b)) = p.get(peer) { + return Ok(*b); + } + let pol = if p.get_key(&V::Bool(false)).is_some() { + policy(p.get_key(&V::Bool(false))) + } else { + default_policy + }; + Ok(match pol { + "never" => false, + "roster" => storage::load(root, domain, Some(user), "roster")? + .and_then(|r| r.get(peer).cloned()) + .is_some(), + _ => true, + }) +} +pub fn handle( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, +) -> Result> { + handle_with_policy(root, domain, jid, iq, "always") +} +pub fn handle_with_policy( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, + default_policy: &str, +) -> Result> { + handle_with_options(root, domain, jid, iq, default_policy, false) +} +pub fn handle_with_options( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, + default_policy: &str, + smart_enable: bool, +) -> Result> { + let Some(q) = iq + .children() + .find(|n| n.is_element() && n.tag_name().namespace() == Some(NS)) + else { + return Ok(None); + }; + let own = protocol::bare(jid); + if let Some(to) = iq.attribute("to") + && to != own + && to != domain + { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + } + let Some((user, host)) = own.split_once('@') else { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + }; + if host != domain { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + } + if smart_enable + && matches!(q.tag_name().name(), "query" | "prefs") + && matches!(iq.attribute("type"), Some("get" | "set")) + && storage::load(root, domain, Some(user), "archive_prefs")?.is_none() + { + storage::update(root, domain, Some(user), "archive_prefs", |p| { + if p.entries().is_empty() { + p.set_key( + V::Bool(false), + match default_policy { + "never" => V::Bool(false), + "roster" => s("roster"), + _ => V::Bool(true), + }, + ); + } + Ok(()) + })?; + } + match process(root, domain, user, own, iq, q, default_policy) { + Ok(v) => Ok(Some(v)), + Err(e) => { + let condition = if e.to_string() == "item-not-found" { + "item-not-found" + } else if e.to_string().starts_with("MAM archive read failed") + || e.to_string().starts_with("MAM preferences storage failed") + { + "internal-server-error" + } else { + "bad-request" + }; + let xml = protocol::iq_error(iq, condition); + let xml = if condition == "internal-server-error" { + xml + } else { + xml.replace("", "") + }; + Ok(Some(ModuleResult::reply(xml))) + } + } +} +fn process( + root: &Path, + domain: &str, + user: &str, + own: &str, + iq: roxmltree::Node<'_, '_>, + q: roxmltree::Node<'_, '_>, + default_policy: &str, +) -> Result { + let typ = iq.attribute("type").unwrap_or(""); + if q.tag_name().name() == "prefs" { + let p = if typ == "set" { + let mut p = V::default(); + let d = match q.attribute("default") { + Some("always") => V::Bool(true), + Some("never") => V::Bool(false), + Some("roster") => s("roster"), + _ => bail!("invalid policy"), + }; + p.set_key(V::Bool(false), d); + for group in q.children().filter(|n| n.is_element()) { + let value = match group.tag_name().name() { + "always" => true, + "never" => false, + _ => bail!("invalid prefs"), + }; + for j in group.children().filter(|n| n.is_element()) { + if j.tag_name().name() != "jid" { + bail!("invalid jid field") + }; + let jid = j.text().ok_or_else(|| anyhow!("missing jid"))?; + if jid.is_empty() || jid.contains(char::is_whitespace) { + bail!("invalid jid") + }; + p.set(&prep_jid(jid)?, V::Bool(value)); + } + } + storage::save(root, domain, Some(user), "archive_prefs", &p) + .context("MAM preferences storage failed")?; + p + } else if typ == "get" { + { + let mut p = storage::load(root, domain, Some(user), "archive_prefs") + .context("MAM preferences storage failed")? + .unwrap_or_default(); + if p.get_key(&V::Bool(false)).is_none() { + p.set_key( + V::Bool(false), + match default_policy { + "never" => V::Bool(false), + "roster" => s("roster"), + _ => V::Bool(true), + }, + ); + } + p + } + } else { + bail!("invalid IQ") + }; + return Ok(ModuleResult::reply(protocol::iq_result(iq, &prefs_xml(&p)))); + } + handle_archive_query(root, domain, Some(user), "archive", own, iq, q) +} +/// Caller MUST authorize access before invoking (e.g. MUC membership). +pub fn handle_archive_query( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + archive_jid: &str, + iq: roxmltree::Node<'_, '_>, + q: roxmltree::Node<'_, '_>, +) -> Result { + handle_archive_query_with_options(root, host, user, store, archive_jid, (iq, q), true) +} +/// Room archives can suppress optional totals without altering personal MAM defaults. +pub fn handle_archive_query_with_options( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + archive_jid: &str, + request: (roxmltree::Node<'_, '_>, roxmltree::Node<'_, '_>), + include_total: bool, +) -> Result { + let (iq, q) = request; + let typ = iq.attribute("type").unwrap_or(""); + if q.tag_name().name() == "metadata" && typ == "get" { + let mut first = None; + let mut last = None; + archive::scan_store(root, host, user, store, |e| { + if first.is_none() { + first = Some((e.id.clone(), e.when)); + } + last = Some((e.id, e.when)); + Ok(()) + }) + .context("MAM archive read failed")?; + let mut payload = format!(""); + if let Some((id, when)) = first { + payload.push_str(&format!( + "", + escape(&id), + archive::timestamp(when)? + )); + } + if let Some((id, when)) = last { + payload.push_str(&format!( + "", + escape(&id), + archive::timestamp(when)? + )); + } + payload.push_str(""); + return Ok(ModuleResult::reply(protocol::iq_result(iq, &payload))); + } + if q.tag_name().name() != "query" { + return Ok(ModuleResult::reply(protocol::iq_error( + iq, + "feature-not-implemented", + ))); + } + if typ == "get" { + return Ok(ModuleResult::reply(protocol::iq_result( + iq, + &format!( + "{NS}" + ), + ))); + } + if typ != "set" { + bail!("invalid query") + } + // mod_muc_mam indexes stanza kind in the archive's `with` column. + let mut peer = (store == "muc_log").then(|| "message { + if value != NS { + bail!("wrong form type") + }; + form_type = true + } + "with" => { + if store != "muc_log" && !value.is_empty() { + peer = Some(protocol::bare(&prep_jid(value)?).to_owned()) + } + } + "start" => { + if !value.is_empty() { + start = Some(archive::parse_time(value)?) + } + } + "end" => { + if !value.is_empty() { + end = Some(archive::parse_time(value)?) + } + } + "before-id" => { + if !value.is_empty() { + before = Some(value.to_owned()); + } + } + "after-id" => { + if !value.is_empty() { + after = Some(value.to_owned()); + } + } + "ids" => { + ids = Some( + f.children() + .filter(|n| n.has_tag_name(("jabber:x:data", "value"))) + .map(|n| n.text().unwrap_or("").to_owned()) + .collect::>(), + ); + } + _ => bail!("unsupported field"), + } + } + if !form_type { + bail!("missing form type") + } + } + if start.zip(end).is_some_and(|(s, e)| s > e) { + bail!("invalid interval") + } + // Native internal storage omits optional totals; avoid a misleading base-set + // count/index when form ID boundaries further restrict the query. + let omit_totals = !include_total || before.is_some() || after.is_some(); + let mut max = 20usize; + let mut reverse = false; + if let Some(set) = q + .children() + .find(|n| n.has_tag_name(("http://jabber.org/protocol/rsm", "set"))) + { + for f in set.children().filter(|n| n.is_element()) { + match f.tag_name().name() { + "max" => max = f.text().unwrap_or("").parse::()?.min(50), + "after" => after = Some(f.text().unwrap_or("").to_owned()), + "before" => { + reverse = true; + before = Some(f.text().unwrap_or("").to_owned()); + } + _ => bail!("unsupported RSM field"), + } + } + } + let mut page = std::collections::VecDeque::new(); + let mut total = 0usize; + let mut eligible = 0usize; + let mut after_found = after.is_none(); + let mut before_found = before.as_ref().is_none_or(|id| id.is_empty()); + let mut before_reached = false; + archive::scan_store(root, host, user, store, |e| { + if peer.as_ref().is_some_and(|p| p != &e.peer) + || start.is_some_and(|s| e.when < s) + || end.is_some_and(|t| e.when > t) + || ids.as_ref().is_some_and(|ids| !ids.contains(&e.id)) + { + return Ok(()); + } + let index = total; + total += 1; + let is_after = after.as_ref() == Some(&e.id); + let passed_after = after_found; + if is_after { + after_found = true; + } + if before + .as_ref() + .is_some_and(|id| !id.is_empty() && id == &e.id) + { + before_found = true; + before_reached = true; + return Ok(()); + } + if !passed_after || is_after { + return Ok(()); + } + if before_reached { + return Ok(()); + } + eligible += 1; + if reverse { + if max > 0 { + page.push_back((index, e)); + if page.len() > max { + page.pop_front(); + } + } + } else if page.len() < max { + page.push_back((index, e)); + } + Ok(()) + }) + .context("MAM archive read failed")?; + if !after_found || !before_found { + bail!("item-not-found") + } + let a = page.front().map(|(i, _)| *i).unwrap_or(0); + let complete = eligible <= max; + let queryid = q + .attribute("queryid") + .map(|v| format!(" queryid='{}'", escape(v))) + .unwrap_or_default(); + let mut result = ModuleResult::default(); + let flip = q.children().any(|n| n.has_tag_name((NS, "flip-page"))); + let ordered: Box> = if flip { + Box::new(page.iter().rev()) + } else { + Box::new(page.iter()) + }; + for (_, e) in ordered { + let doc = roxmltree::Document::parse(&e.xml)?; + let forwarded = match doc.root_element().tag_name().namespace() { + Some("jabber:client") => e.xml.clone(), + None => { + anyhow::ensure!( + doc.root_element().tag_name().name() == "message", + "MAM archive contains a non-message stanza" + ); + e.xml + .replacen(" { + let mut stored = storage::stanza_value(doc.root_element()); + let mut attrs = stored.get("attr").cloned().unwrap_or_default(); + attrs.set("xmlns", "jabber:client".into()); + stored.set("attr", attrs); + storage::stanza_xml(&stored)? + } + }; + result.replies.push(format!("{}",escape(archive_jid),escape(&e.id),archive::timestamp(e.when)?,forwarded)); + } + let bounds = page + .front() + .zip(page.back()) + .map(|((_, f), (_, l))| { + let index = if omit_totals { + String::new() + } else { + format!(" index='{a}'") + }; + format!( + "{}{}", + escape(&f.id), + escape(&l.id) + ) + }) + .unwrap_or_default(); + let count = if omit_totals { + String::new() + } else { + format!("{total}") + }; + result.replies.push(protocol::iq_result(iq,&format!("{bounds}{count}"))); + Ok(result) +} + +/// Exact default message selection from Prosody 13 mod_mam, before user preferences. +pub fn should_store(stanza: roxmltree::Node<'_, '_>) -> bool { + if !stanza.has_tag_name(("jabber:client", "message")) && stanza.tag_name().name() != "message" { + return false; + } + match stanza.attribute("type").unwrap_or("normal") { + "headline" | "groupchat" => return false, + "error" => return stanza.attribute("to").is_some_and(|j| j.contains('/')), + _ => (), + } + let has = |ns: &str, name: &str| stanza.children().any(|n| n.has_tag_name((ns, name))); + if has("urn:xmpp:hints", "no-store") || has("urn:xmpp:hints", "no-permanent-store") { + return false; + } + if has("urn:xmpp:hints", "store") { + return true; + } + stanza.children().filter(|n| n.is_element()).any(|n| { + let ns = n.tag_name().namespace().unwrap_or(""); + let name = n.tag_name().name(); + ((name == "body" || name == "subject") && (ns == "jabber:client" || ns.is_empty())) + || (ns == "urn:xmpp:eme:0" && name == "encryption") + || matches!( + ns, + "urn:xmpp:receipts" + | "urn:xmpp:chat-markers:0" + | "urn:xmpp:jingle-message:0" + | "urn:xmpp:jingle-message:1" + ) + || (ns == "jabber:x:conference" && name == "x") + || (ns == "http://jabber.org/protocol/muc#user" + && name == "x" + && n.children().any(|c| c.has_tag_name((ns, "invite")))) + }) +} + +fn retain_stanza_children(value: &mut V, mut keep: impl FnMut(&V) -> bool) { + let mut children = vec![]; + let mut i = 1; + while let Some(child) = value.get_key(&V::Integer(i)) { + if keep(child) { + children.push(child.clone()); + } + i += 1; + } + if let V::Table(fields) = value { + fields.retain(|(k, _)| !matches!(k, V::Integer(_))); + } + for (i, child) in children.into_iter().enumerate() { + value.set_key(V::Integer(i as i64 + 1), child); + } +} +fn sid_belongs_to(by: &str, domain: &str, owner: &str) -> bool { + if by.contains('/') { + return false; + } + let Some((user, host)) = by.split_once('@') else { + return false; + }; + let Ok(user) = stringprep::nodeprep(user) else { + return false; + }; + let Ok(host) = stringprep::nameprep(host) else { + return false; + }; + let Ok(expected_host) = stringprep::nameprep(domain) else { + return false; + }; + user == owner && host == expected_host +} +fn strip_owner_sid_value(value: &mut V, domain: &str, owner: &str) { + retain_stanza_children(value, |child| { + let attr = child.get("attr"); + !(child.get("name").and_then(V::as_str) == Some("stanza-id") + && attr.and_then(|a| a.get("xmlns")).and_then(V::as_str) == Some("urn:xmpp:sid:0") + && attr + .and_then(|a| a.get("by")) + .and_then(V::as_str) + .is_some_and(|by| sid_belongs_to(by, domain, owner))) + }); +} +/// Remove SIDs claiming this user's archive before routing out of their account. +/// This also removes forged SIDs submitted by clients. Other issuers are preserved. +pub fn strip_owner_stanza_ids(xml: &str, domain: &str, owner: &str) -> Result { + let doc = roxmltree::Document::parse(xml)?; + let mut value = storage::stanza_value(doc.root_element()); + strip_owner_sid_value(&mut value, domain, owner); + storage::stanza_xml(&value) +} +/// Store the sanitized message and return a delivery copy bearing its real archive ID. +/// The newly generated SID is not embedded in the archived stanza itself. +/// Default Prosody chat-state namespace stripping applies only to persisted content. +pub fn archive_for_user( + root: &Path, + domain: &str, + owner: &str, + peer: &str, + xml: &str, + time: f64, +) -> Result<(String, String)> { + archive_for_user_with_namespaces( + root, + domain, + owner, + peer, + xml, + time, + &["http://jabber.org/protocol/chatstates"], + ) +} +pub fn archive_for_user_with_namespaces( + root: &Path, + domain: &str, + owner: &str, + peer: &str, + xml: &str, + time: f64, + strip_namespaces: &[&str], +) -> Result<(String, String)> { + let doc = roxmltree::Document::parse(xml)?; + anyhow::ensure!( + doc.root_element().tag_name().name() == "message", + "only message stanzas can be archived for a user" + ); + let mut delivery = storage::stanza_value(doc.root_element()); + strip_owner_sid_value(&mut delivery, domain, owner); + let mut persisted = delivery.clone(); + retain_stanza_children(&mut persisted, |child| { + !child + .get("attr") + .and_then(|a| a.get("xmlns")) + .and_then(V::as_str) + .is_some_and(|ns| strip_namespaces.contains(&ns)) + }); + let stored_xml = storage::stanza_xml(&persisted)?; + let id = + archive::archive_message(root, domain, owner, protocol::bare(peer), &stored_xml, time)?; + let mut sid = V::default(); + sid.set("name", "stanza-id".into()); + let mut attr = V::default(); + attr.set("xmlns", "urn:xmpp:sid:0".into()); + attr.set("by", format!("{owner}@{domain}").into()); + attr.set("id", id.clone().into()); + sid.set("attr", attr); + let mut index = 1; + while delivery.get_key(&V::Integer(index)).is_some() { + index += 1; + } + delivery.set_key(V::Integer(index), sid); + Ok((id, storage::stanza_xml(&delivery)?)) +} diff --git a/src/muc.rs b/src/muc.rs new file mode 100644 index 0000000..b0d2d5d --- /dev/null +++ b/src/muc.rs @@ -0,0 +1,2506 @@ +//! Stateful XEP-0045 component. Room configuration uses Prosody's frozen-room layout. +use crate::{ + protocol::{ModuleResult, bare, iq_error, iq_result, xml}, + wire::escape, +}; +use anyhow::Result; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, path::PathBuf}; +const MUC: &str = "http://jabber.org/protocol/muc"; +const USER: &str = "http://jabber.org/protocol/muc#user"; +const OWNER: &str = "http://jabber.org/protocol/muc#owner"; +const ADMIN: &str = "http://jabber.org/protocol/muc#admin"; +#[derive(Clone)] +struct Occupant { + jid: String, + nick: String, + role: String, + presence_payload: String, +} +struct Room { + frozen: Value, + original: crate::storage::LuaValue, + occupants: BTreeMap, + locked: bool, + hats_compat: bool, + force_archiving: bool, + occupant_id_enabled: bool, + history: crate::muc_history::RoomHistory, +} +impl Room { + fn data(&self, k: &str) -> &Value { + &self.frozen["_data"][k] + } + fn affiliation(&self, jid: &str) -> String { + let affiliations = self.frozen.get("_affiliations").unwrap_or(&self.frozen); + let jid = bare(jid); + let host = jid.rsplit_once('@').map_or(jid, |(_, host)| host); + affiliations + .get(jid) + .and_then(Value::as_str) + .or_else(|| { + (affiliations.get(host).and_then(Value::as_str) == Some("outcast")) + .then_some("outcast") + }) + .unwrap_or("none") + .into() + } + fn reserved_by(&self, nick: &str) -> Option<&str> { + self.frozen + .get("_affiliation_data") + .and_then(Value::as_object) + .and_then(|map| { + map.iter() + .find(|(_, v)| v["reserved_nickname"].as_str() == Some(nick)) + .map(|(k, _)| k.as_str()) + }) + } + fn clear_data(&mut self, key: &str) { + if let Some(data) = self.frozen.get_mut("_data").and_then(Value::as_object_mut) { + data.remove(key); + } + let mut raw = self.original.get("_data").cloned().unwrap_or_default(); + raw.set(key, crate::storage::LuaValue::Nil); + self.original.set("_data", raw); + } + fn occupant_id(&self, jid: &str) -> String { + if !self.occupant_id_enabled { + return String::new(); + } + use base64::Engine; + use hmac::{Hmac, Mac}; + // Prosody salts can contain arbitrary bytes, which the JSON view cannot + // represent. Keep using the original bytes rather than replacing them. + let raw_salt = self + .original + .get("_data") + .and_then(|d| d.get("occupant_id_salt")); + let salt = match raw_salt { + Some(crate::storage::LuaValue::String(bytes)) => bytes.as_slice(), + _ => self + .data("occupant_id_salt") + .as_str() + .unwrap_or("") + .as_bytes(), + }; + let mut h = Hmac::::new_from_slice(bare(jid).as_bytes()) + .expect("HMAC accepts arbitrary keys"); + h.update(salt); + format!( + "", + base64::engine::general_purpose::STANDARD.encode(h.finalize().into_bytes()) + ) + } + fn privileged(&self, jid: &str) -> bool { + matches!(self.affiliation(jid).as_str(), "owner" | "admin") + } + fn set_affiliation(&mut self, jid: &str, value: &str) { + if value == "none" { + if let Some(map) = self + .frozen + .get_mut("_affiliation_data") + .and_then(Value::as_object_mut) + { + map.remove(jid); + } + let mut data = self + .original + .get("_affiliation_data") + .cloned() + .unwrap_or_default(); + data.set(jid, crate::storage::LuaValue::Nil); + self.original.set("_affiliation_data", data); + if let Some(map) = self + .frozen + .get_mut("_affiliations") + .and_then(Value::as_object_mut) + { + map.remove(jid); + let mut old = self + .original + .get("_affiliations") + .cloned() + .unwrap_or_default(); + old.set(jid, crate::storage::LuaValue::Nil); + self.original.set("_affiliations", old); + } else { + if let Some(map) = self.frozen.as_object_mut() { + map.remove(jid); + } + self.original.set(jid, crate::storage::LuaValue::Nil); + } + return; + } + if self.frozen.get("_affiliations").is_some() { + self.frozen["_affiliations"][jid] = json!(value); + } else { + self.frozen[jid] = json!(value); + } + } + fn archiving(&self) -> bool { + self.force_archiving || self.data("archiving").as_bool() == Some(true) + } + fn broadcasts(&self, role: &str) -> bool { + match self.data("presence_broadcast").as_object() { + Some(roles) => roles.get(role).and_then(Value::as_bool) == Some(true), + None => role != "none", + } + } + fn offline_affiliates(&self, room: &str, to: &Occupant) -> Vec { + if !self.broadcasts("none") { + return Vec::new(); + } + let public = self.data("whois").as_str() == Some("anyone") || to.role == "moderator"; + let affiliations = self.frozen.get("_affiliations").unwrap_or(&self.frozen); + affiliations.as_object().into_iter().flatten().filter_map(|(jid,affiliation)| { + let affiliation=affiliation.as_str()?; + if bare(&to.jid)==jid || jid.starts_with('_') || !matches!(affiliation,"owner"|"admin"|"member"|"outcast") || self.occupants.values().any(|o|bare(&o.jid)==jid) {return None;} + let nick=self.frozen["_affiliation_data"][jid]["reserved_nickname"].as_str(); + if nick.is_none() && !public {return None;} + let from=nick.map(|nick|format!("{room}/{nick}")).unwrap_or_else(||room.into()); + let attrs=format!("{}{}",nick.map(|n|format!(" nick='{}'",escape(n))).unwrap_or_default(),if public {format!(" jid='{}'",escape(jid))}else{String::new()}); + Some(format!("",escape(&from),escape(&to.jid),escape(affiliation))) + }).collect() + } + fn hats(&self, jid: &str) -> String { + let mut items = String::new(); + if let Some(hats) = self.frozen["_affiliation_data"][bare(jid)]["hats"].as_object() { + for (uri, hat) in hats { + if hat["active"].as_bool() == Some(true) { + items.push_str(&format!( + "", + escape(uri), + hat["title"] + .as_str() + .map(|v| format!(" title='{}'", escape(v))) + .unwrap_or_default() + )); + } + } + } + if items.is_empty() { + return items; + } + let mut out = format!("{items}"); + if self.hats_compat { + out.push_str(&format!( + "{items}" + )); + } + out + } + fn allow_pm(&self) -> &str { + match self.data("allow_pm").as_str().unwrap_or("visitor") { + "everyone" | "anyone" => "visitor", + "participants" => "participant", + "moderators" => "moderator", + "members" => "affiliated", + v @ ("visitor" | "participant" | "moderator" | "affiliated" | "none") => v, + _ => "visitor", + } + } + fn pm_permitted(&self, from: &Occupant, to: &Occupant) -> bool { + if bare(&from.jid) == bare(&to.jid) { + return true; + } + if self.data("allow_modpm").as_bool() == Some(true) + && (from.role == "moderator" || to.role == "moderator") + { + return true; + } + match self.allow_pm() { + "visitor" => true, + "participant" => matches!(from.role.as_str(), "participant" | "moderator"), + "moderator" => from.role == "moderator", + "affiliated" => self.affiliation(&from.jid) != "none", + _ => false, + } + } + fn presence( + &self, + room: &str, + o: &Occupant, + to: &Occupant, + unavailable: bool, + code: &str, + ) -> String { + if bare(&o.jid) != bare(&to.jid) && !self.broadcasts(&o.role) { + return String::new(); + } + self.presence_force(room, o, to, unavailable, code) + } + fn presence_force( + &self, + room: &str, + o: &Occupant, + to: &Occupant, + unavailable: bool, + code: &str, + ) -> String { + let show_jid = self.data("whois").as_str() == Some("anyone") + || to.role == "moderator" + || bare(&o.jid) == bare(&to.jid); + let real = if show_jid { + format!(" jid='{}'", escape(&o.jid)) + } else { + String::new() + }; + let role = if unavailable && code != "303" { + "none" + } else { + &o.role + }; + let mut status = String::new(); + if o.nick == to.nick && bare(&o.jid) == bare(&to.jid) { + status.push_str(""); + } + if !code.is_empty() { + status.push_str(&format!("")); + } + format!( + "{}{status}{}", + escape(room), + escape(&o.nick), + escape(&to.jid), + if unavailable { + " type='unavailable'" + } else { + "" + }, + o.presence_payload, + self.affiliation(&o.jid), + format_args!("{}{}", self.occupant_id(&o.jid), self.hats(&o.jid)) + ) + } +} +pub struct Muc { + root: PathBuf, + pub domain: String, + rooms: BTreeMap, + defaults: Value, + pending_occupants: crate::muc_recovery::PendingOccupants, + recovering_role: Option, + local_domain: Option, + tombstones: bool, + room_locking: bool, + log_all_rooms: bool, + log_presences: bool, + max_history_messages: usize, + log_include_total: bool, + occupant_id_enabled: bool, + vcard_enabled: bool, + hats_compat: bool, + lock_timeout: f64, + retention_seconds: Option, + pending_iq: BTreeMap, +} +impl Muc { + pub fn new(root: PathBuf, domain: String) -> Self { + Self { + root, + domain, + rooms: BTreeMap::new(), + local_domain: None, + tombstones: true, + room_locking: true, + log_all_rooms: false, + log_presences: false, + max_history_messages: 100, + log_include_total: true, + occupant_id_enabled: true, + vcard_enabled: true, + hats_compat: false, + lock_timeout: 300.0, + retention_seconds: None, + pending_iq: BTreeMap::new(), + pending_occupants: Default::default(), + recovering_role: None, + defaults: json!({"hidden":true,"persistent":false,"whois":"moderators"}), + } + } + /// Component options (already merged with global options), not a frozen room. + pub fn set_component_options(&mut self, options: Value, mam_enabled: bool) { + let flag = |name: &str, default: bool| { + options + .get(name) + .and_then(Value::as_bool) + .unwrap_or(default) + }; + self.max_history_messages = options + .get("max_history_messages") + .and_then(Value::as_u64) + .unwrap_or(100) + .min(4096) as usize; + self.occupant_id_enabled = flag("muc_occupant_id", true); + self.vcard_enabled = flag("muc_vcard", true); + self.log_all_rooms = mam_enabled && flag("muc_log_all_rooms", false); + self.log_presences = mam_enabled && flag("muc_log_presences", false); + self.log_include_total = flag("muc_log_include_total", true); + self.hats_compat = flag("muc_hats_compat", false); + self.room_locking = flag("muc_room_locking", true); + self.lock_timeout = options + .get("muc_room_lock_timeout") + .and_then(Value::as_f64) + .unwrap_or(300.0); + self.defaults = json!({ + "language":options.get("muc_room_default_language").and_then(Value::as_str), + "presence_broadcast":options.get("muc_room_default_presence_broadcast").cloned().unwrap_or(Value::Null), + "allow_pm":options.get("muc_room_default_allow_pm").and_then(Value::as_str).unwrap_or("visitor"), + "allow_modpm":flag("muc_room_default_always_allow_moderator_pms",false), + "hidden":!flag("muc_room_default_public",false), + "persistent":flag("muc_room_default_persistent",false), + "members_only":flag("muc_room_default_members_only",false), + "moderated":flag("muc_room_default_moderated",false), + "changesubject":flag("muc_room_default_change_subject",false), + "allow_member_invites":flag("muc_room_default_allow_member_invites",false), + "whois":if flag("muc_room_default_public_jids",false){"anyone"}else{"moderators"}, + "archiving":mam_enabled&&flag("muc_log_by_default",true), + "history_length":options.get("muc_room_default_history_length").and_then(Value::as_u64).unwrap_or(20).min(options.get("max_history_messages").and_then(Value::as_u64).unwrap_or(100).min(4096)), + }); + if let Some(max) = options + .get("muc_room_default_max_occupants") + .and_then(Value::as_u64) + { + self.defaults["max_occupants"] = json!(max); + } + } + fn frozen_room(room: &Room) -> crate::storage::LuaValue { + let mut value = room.original.clone(); + merge_value(&mut value, &room.frozen); + value + } + /// Read dormant native checkpoints without activating any routing targets. + pub fn prepare_recovery(&mut self) -> Result<()> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + if let Some(index) = crate::storage::load(&self.root, &self.domain, None, "persistent")? { + for (key, _) in index.entries() { + let Some(name) = key + .as_str() + .filter(|r| r.ends_with(&format!("@{}", self.domain))) + else { + continue; + }; + self.load(name)?; + let Some(room) = self.rooms.get(name) else { + continue; + }; + if room.data("destroyed").as_bool() == Some(true) { + continue; + } + let fingerprint = + crate::muc_recovery::configuration_fingerprint(&Self::frozen_room(room))?; + if crate::muc_recovery::load_fingerprint(&self.root, &self.domain, name)?.as_deref() + != Some(fingerprint.as_str()) + { + continue; + } + let saved_at = + crate::muc_recovery::load_checkpoint_time(&self.root, &self.domain, name)? + .unwrap_or(0); + if now.saturating_sub(saved_at) >= crate::muc_recovery::RECOVERY_SECONDS { + continue; + } + let occupants = crate::muc_recovery::load_room(&self.root, &self.domain, name)?; + self.pending_occupants + .insert_checkpoint(name.into(), occupants, saved_at, now)?; + } + } + Ok(()) + } + /// Freeze room metadata and live occupants before transport teardown. + pub fn checkpoint_live(&mut self) -> Result<()> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + for (name, room) in &self.rooms { + if room.data("destroyed").as_bool() == Some(true) { + continue; + } + let config = Self::frozen_room(room); + crate::storage::atomic_write( + &self.path(name), + crate::storage::serialize(&config)?.as_bytes(), + )?; + crate::storage::update(&self.root, &self.domain, None, "persistent", |v| { + v.set(name, crate::storage::LuaValue::Bool(true)); + Ok(()) + })?; + let fingerprint = crate::muc_recovery::configuration_fingerprint(&config)?; + let mut occupants = room + .occupants + .values() + .map(|o| crate::muc_recovery::OccupantSnapshot { + jid: o.jid.clone(), + nick: o.nick.clone(), + role: o.role.clone(), + presence_payload: o.presence_payload.clone(), + }) + .collect::>(); + let mut saved_at = now; + if crate::muc_recovery::load_fingerprint(&self.root, &self.domain, name)?.as_deref() + == Some(fingerprint.as_str()) + && let Some((pending_at, pending)) = self.pending_occupants.room_snapshot(name, now) + { + if !pending.is_empty() { + saved_at = pending_at; + } + for snapshot in pending { + if occupants.len() < crate::muc_recovery::MAX_OCCUPANTS + && !occupants.iter().any(|o| o.jid == snapshot.jid) + { + occupants.push(snapshot); + } + } + } + crate::muc_recovery::save_room_checkpoint( + &self.root, + &self.domain, + name, + &occupants, + Some(&fingerprint), + saved_at, + )?; + crate::muc_recovery::set_last_message( + &self.root, + &self.domain, + name, + room.history + .last_message() + .filter(|(packet, _)| packet.len() <= 16 * 1024), + )?; + } + Ok(()) + } + fn consume_recovery_checkpoint(&self, room: &str, jid: &str) -> Result<()> { + let mut remaining = crate::muc_recovery::load_room(&self.root, &self.domain, room)?; + remaining.retain(|o| o.jid != jid); + let fingerprint = crate::muc_recovery::load_fingerprint(&self.root, &self.domain, room)?; + let saved_at = + crate::muc_recovery::load_checkpoint_time(&self.root, &self.domain, room)?.unwrap_or(0); + let last = crate::muc_recovery::load_last_message(&self.root, &self.domain, room)?; + crate::muc_recovery::save_room_checkpoint( + &self.root, + &self.domain, + room, + &remaining, + fingerprint.as_deref(), + saved_at, + )?; + crate::muc_recovery::set_last_message( + &self.root, + &self.domain, + room, + last.as_ref().map(|(xml, when)| (xml.as_str(), *when)), + )?; + Ok(()) + } + /// Called only for an authenticated full JID's initial available presence. + pub fn restore_for_session(&mut self, jid: &str) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let mut out = ModuleResult::default(); + for (name, snapshot) in self.pending_occupants.candidates(jid, now) { + let Some(room) = self.rooms.get(&name) else { + continue; + }; + let fingerprint = + crate::muc_recovery::configuration_fingerprint(&Self::frozen_room(room))?; + if crate::muc_recovery::load_fingerprint(&self.root, &self.domain, &name)?.as_deref() + != Some(fingerprint.as_str()) + { + continue; + } + if room.occupants.contains_key(jid) { + self.consume_recovery_checkpoint(&name, jid)?; + self.pending_occupants + .claim(&name, jid, &snapshot.nick, now); + continue; + } + let password = room + .data("password") + .as_str() + .filter(|p| !p.is_empty()) + .map(|p| format!("{}", escape(p))) + .unwrap_or_default(); + let packet = format!( + "{}{password}", + escape(&name), + escape(&snapshot.nick), + snapshot.presence_payload + ); + let doc = roxmltree::Document::parse(&packet)?; + self.recovering_role = Some(snapshot.role.clone()); + let restored = self.handle(jid, doc.root_element()); + self.recovering_role = None; + if let Some(mut restored) = restored? { + let joined = self.rooms.get(&name).is_some_and(|room| { + room.occupants + .get(jid) + .is_some_and(|o| o.nick == snapshot.nick) + }); + if joined { + if let Err(error) = self.consume_recovery_checkpoint(&name, jid) { + if let Some(room) = self.rooms.get_mut(&name) { + room.occupants.remove(jid); + } + return Err(error); + } + self.pending_occupants + .claim(&name, jid, &snapshot.nick, now); + out.replies.append(&mut restored.replies); + out.deliveries.append(&mut restored.deliveries); + } + } + } + Ok(out) + } + pub fn set_tombstones(&mut self, enabled: bool) { + self.tombstones = enabled; + } + pub fn set_archive_retention(&mut self, seconds: Option) { + self.retention_seconds = seconds; + } + pub fn set_local_domain(&mut self, domain: String) { + self.local_domain = Some(domain); + } + pub fn set_defaults(&mut self, defaults: Value) { + self.defaults = defaults; + } + fn path(&self, room: &str) -> PathBuf { + self.root + .join(crate::storage::encode_name(&self.domain)) + .join("config") + .join(format!( + "{}.dat", + crate::storage::encode_name(room.split('@').next().unwrap_or(room)) + )) + } + fn load(&mut self, room: &str) -> Result<()> { + if self.rooms.contains_key(room) { + return Ok(()); + } + let p = self.path(room); + if p.exists() { + let original = crate::storage::parse(&std::fs::read(&p)?)?; + let mut value = view_value(&original); + if value["_data"]["archiving"].is_null() { + value["_data"]["archiving"] = self.defaults["archiving"].clone(); + } + if !matches!( + original + .get("_data") + .and_then(|d| d.get("occupant_id_salt")), + Some(crate::storage::LuaValue::String(_)) + ) { + value["_data"]["occupant_id_salt"] = json!(uuid::Uuid::new_v4().to_string()); + } + let mut history = crate::muc_history::RoomHistory::new( + value["_data"]["history_length"].as_u64().unwrap_or(20) as usize, + ); + if let Some((packet, when)) = + crate::muc_recovery::load_last_message(&self.root, &self.domain, room)? + { + history.restore_last_message(&packet, when); + } + self.rooms.insert( + room.into(), + Room { + frozen: value.clone(), + original, + occupants: BTreeMap::new(), + history, + occupant_id_enabled: self.occupant_id_enabled, + force_archiving: self.log_all_rooms, + hats_compat: self.hats_compat, + locked: value["_data"]["locked"].as_bool() == Some(true) + || value["_data"]["locked"].as_f64().is_some_and(|v| { + v > std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + }), + }, + ); + } + Ok(()) + } + fn save(&self, room: &str) -> Result<()> { + if self.recovering_role.is_some() { + return Ok(()); + } + let r = &self.rooms[room]; + if r.data("persistent").as_bool() == Some(true) || self.pending_occupants.has_room(room) { + let mut merged = r.original.clone(); + merge_value(&mut merged, &r.frozen); + crate::storage::atomic_write( + &self.path(room), + crate::storage::serialize(&merged)?.as_bytes(), + )?; + let path = self + .root + .join(crate::storage::encode_name(&self.domain)) + .join("persistent.dat"); + let mut index = if path.exists() { + read_value(&path)? + } else { + json!({}) + }; + index[room] = json!(true); + write_value(&path, &index)?; + } else if self.path(room).exists() { + std::fs::remove_file(self.path(room))?; + crate::storage::update(&self.root, &self.domain, None, "persistent", |v| { + v.set(room, crate::storage::LuaValue::Nil); + Ok(()) + })?; + } + Ok(()) + } + fn delete_saved_room(&self, room: &str) -> Result<()> { + let node = room.split('@').next().unwrap(); + for store in ["config", "state"] { + let path = crate::storage::path(&self.root, &self.domain, Some(node), store); + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + if crate::storage::path(&self.root, &self.domain, None, "persistent").exists() { + crate::storage::update(&self.root, &self.domain, None, "persistent", |v| { + v.set(room, crate::storage::LuaValue::Nil); + Ok(()) + })?; + } + Ok(()) + } + /// Periodic maintenance, including rooms which have not received traffic. + /// Caller may invoke once a minute; `now` is UTC Unix seconds. + pub fn maintenance(&mut self, now: f64) -> Result<()> { + anyhow::ensure!( + now.is_finite() && now >= 0.0, + "invalid maintenance timestamp" + ); + if let Some(index) = crate::storage::load(&self.root, &self.domain, None, "persistent")? { + for (k, _) in index.entries() { + if let Some(room) = k + .as_str() + .filter(|r| r.ends_with(&format!("@{}", self.domain))) + { + self.load(room)?; + } + } + } + let names = self.rooms.keys().cloned().collect::>(); + self.pending_occupants.expire(now as u64); + for name in names { + if self.rooms[&name].occupants.is_empty() + && self.rooms[&name].data("persistent").as_bool() != Some(true) + && !self.pending_occupants.contains_room(&name, now as u64) + { + self.delete_saved_room(&name)?; + self.rooms.remove(&name); + continue; + } + let room = &self.rooms[&name]; + if room.data("destroyed").as_bool() == Some(true) + && room + .data("locked") + .as_f64() + .is_some_and(|until| until <= now) + { + self.delete_saved_room(&name)?; + self.rooms.remove(&name); + continue; + } + if let Some(seconds) = self.retention_seconds { + crate::archive::expire_before( + &self.root, + &self.domain, + Some(name.split('@').next().unwrap()), + "muc_log", + now - seconds, + )?; + } + let room = self.rooms.get_mut(&name).unwrap(); + if room.locked + && room + .data("locked") + .as_f64() + .is_some_and(|until| until <= now) + { + room.locked = false; + room.clear_data("locked"); + self.save(&name)?; + } + } + Ok(()) + } + pub fn disconnect(&mut self, jid: &str) -> ModuleResult { + let mut out = ModuleResult::default(); + for (name, r) in &mut self.rooms { + if let Some(o) = r.occupants.remove(jid) { + if r.occupants.values().any(|other| other.nick == o.nick) { + continue; + } + if self.log_presences + && r.archiving() + && let Err(e) = archive_presence(&self.root, &self.domain, name, &o.nick, true) + { + eprintln!("MUC presence archive failed: {e}"); + } + for target in r.occupants.values() { + out.deliveries + .push((target.jid.clone(), r.presence(name, &o, target, true, ""))); + } + } + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expired = self + .rooms + .iter() + .filter(|(name, r)| { + r.occupants.is_empty() + && r.data("persistent").as_bool() != Some(true) + && !self.pending_occupants.contains_room(name, now) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + for name in expired { + match self.delete_saved_room(&name) { + Ok(()) => { + self.rooms.remove(&name); + } + Err(e) => eprintln!("MUC temporary room cleanup failed: {e}"), + } + } + out.deliveries.retain(|(_, p)| !p.is_empty()); + out + } + pub fn handle(&mut self, jid: &str, stanza: roxmltree::Node) -> Result> { + let target = bare(stanza.attribute("to").unwrap_or("")); + let was_occupant = self + .rooms + .get(target) + .is_some_and(|r| r.occupants.contains_key(jid)); + let mut result = self.handle_inner(jid, stanza)?; + if self.recovering_role.is_none() + && stanza.tag_name().name() == "presence" + && !matches!(stanza.attribute("type"), Some("unavailable" | "error")) + && self + .rooms + .get(target) + .is_some_and(|r| r.occupants.contains_key(jid)) + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + if let Some((_, snapshot)) = self + .pending_occupants + .candidates(jid, now) + .into_iter() + .find(|(room, _)| room == target) + { + if let Err(error) = self.consume_recovery_checkpoint(target, jid) { + if !was_occupant && let Some(room) = self.rooms.get_mut(target) { + room.occupants.remove(jid); + } + return Err(error); + } + self.pending_occupants + .claim(target, jid, &snapshot.nick, now); + } + } + if let Some(out) = &mut result { + for reply in &mut out.replies { + if !self.occupant_id_enabled { + *reply = reply.replace("", ""); + } + if !self.vcard_enabled { + *reply = reply.replace("", ""); + } + } + out.replies.retain(|p| !p.is_empty()); + out.deliveries.retain(|(_, p)| !p.is_empty()); + } + Ok(result) + } + fn handle_inner(&mut self, jid: &str, stanza: roxmltree::Node) -> Result> { + let to = stanza.attribute("to").unwrap_or(""); + let room = bare(to); + if room != self.domain && !room.ends_with(&format!("@{}", self.domain)) { + return Ok(None); + } + let kind = stanza.tag_name().name(); + let typ = stanza.attribute("type").unwrap_or(""); + let mut out = ModuleResult::default(); + if kind == "message" && typ == "error" + || kind == "iq" && matches!(typ, "result" | "error") && !to.contains('/') + { + return Ok(Some(out)); + } + if room == self.domain { + if let Some(index) = crate::storage::load(&self.root, &self.domain, None, "persistent")? + { + for (k, _) in index.entries() { + if let Some(room) = k.as_str() + && room.ends_with(&format!("@{}", self.domain)) + { + self.load(room)?; + } + } + } + if kind == "iq" && typ == "get" { + let query = stanza.children().find(|n| n.is_element()); + if let Some(q) = query { + if q.has_tag_name(("http://jabber.org/protocol/muc#unique", "unique")) { + return Ok(Some(ModuleResult::reply(iq_result( + stanza, + &format!( + "{}", + uuid::Uuid::new_v4() + ), + )))); + } + match q.tag_name().namespace().unwrap_or("") {"http://jabber.org/protocol/disco#info"=>out.replies.push(iq_result(stanza,&format!(""))),"http://jabber.org/protocol/disco#items"=>{let mut items=String::new();for (n,r) in &self.rooms {if r.data("hidden").as_bool()!=Some(true){items.push_str(&format!("",escape(n),escape(r.data("name").as_str().unwrap_or(n))));}}out.replies.push(iq_result(stanza,&format!("{items}")));},_=>out.replies.push(iq_error(stanza,"feature-not-implemented"))} + } + } else { + out.replies.push(error(stanza, "service-unavailable")); + } + return Ok(Some(out)); + } + self.load(room)?; + if let Some(r) = self.rooms.get(room) + && r.data("destroyed").as_bool() == Some(true) + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(); + if r.data("locked").as_f64().is_none_or(|expiry| now < expiry) { + return Ok(Some(ModuleResult::reply(error(stanza, "gone")))); + } + self.delete_saved_room(room)?; + self.rooms.remove(room); + } + if let Some(r) = self.rooms.get_mut(room) + && r.locked + && r.data("locked").as_f64().is_some_and(|until| { + until + <= std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + }) + { + r.locked = false; + r.clear_data("locked"); + } + if kind == "presence" && typ != "unavailable" && typ != "error" { + let nick_owned = to + .split_once('/') + .map(|(_, n)| n.to_owned()) + .or_else(|| { + self.rooms + .get(room) + .and_then(|r| r.occupants.get(jid)) + .map(|o| o.nick.clone()) + }) + .unwrap_or_default(); + let nick = nick_owned.as_str(); + if nick.is_empty() { + out.replies.push(error(stanza, "jid-malformed")); + return Ok(Some(out)); + } + let prepared = match stringprep::resourceprep(nick) { + Ok(v) => v, + Err(_) => return Ok(Some(ModuleResult::reply(error(stanza, "jid-malformed")))), + }; + let nick = prepared.as_ref(); + let created = !self.rooms.contains_key(room); + if created + && self + .local_domain + .as_ref() + .is_some_and(|d| bare(jid).split_once('@').map(|(_, h)| h) != Some(d.as_str())) + { + return Ok(Some(ModuleResult::reply(error(stanza, "forbidden")))); + } + if created { + let mut frozen = + json!({"_jid":room,"_data":self.defaults.clone(),"_affiliation_data":{}}); + frozen[jid.split('/').next().unwrap_or(jid)] = json!("owner"); + frozen["_data"]["occupant_id_salt"] = json!(uuid::Uuid::new_v4().to_string()); + let locked = + self.room_locking && stanza.children().any(|n| n.has_tag_name((MUC, "x"))); + if locked { + frozen["_data"]["locked"] = json!( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64() + + self.lock_timeout + ); + } + self.rooms.insert( + room.into(), + Room { + history: crate::muc_history::RoomHistory::new( + frozen["_data"]["history_length"].as_u64().unwrap_or(20) as usize, + ), + frozen, + original: crate::storage::LuaValue::table(), + occupants: BTreeMap::new(), + locked, + occupant_id_enabled: self.occupant_id_enabled, + force_archiving: self.log_all_rooms, + hats_compat: self.hats_compat, + }, + ); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let mut identities = self + .rooms + .iter() + .flat_map(|(name, r)| { + r.occupants + .keys() + .map(move |jid| (name.clone(), jid.clone())) + }) + .collect::>(); + identities.extend(self.pending_occupants.identities(now)); + if !identities.contains(&(room.to_string(), jid.to_string())) + && identities.len() >= crate::muc_recovery::MAX_OCCUPANTS + { + return Ok(Some(ModuleResult::reply(error( + stanza, + "service-unavailable", + )))); + } + if self + .pending_occupants + .reserved_by(room, nick, now) + .is_some_and(|owner| owner != bare(jid)) + { + return Ok(Some(ModuleResult::reply(error(stanza, "conflict")))); + } + let r = self.rooms.get_mut(room).unwrap(); + let aff = r.affiliation(jid); + if r.reserved_by(nick).is_some_and(|owner| owner != bare(jid)) { + return Ok(Some(ModuleResult::reply(error(stanza, "conflict")))); + } + let fail = if aff == "outcast" { + Some("forbidden") + } else if r.locked && aff != "owner" { + Some("item-not-found") + } else if r.data("members_only").as_bool() == Some(true) && aff == "none" { + Some("registration-required") + } else if r + .occupants + .values() + .any(|o| o.nick == nick && bare(&o.jid) != bare(jid)) + { + Some("conflict") + } else { + None + }; + if let Some(f) = fail { + out.replies.push(error(stanza, f)); + return Ok(Some(out)); + } + if !r.occupants.contains_key(jid) + && !r.occupants.values().any(|o| o.nick == nick) + && let Some(max) = r.data("max_occupants").as_u64() + { + let count = r + .occupants + .values() + .map(|o| o.nick.as_str()) + .collect::>() + .len(); + if count >= max as usize && !r.privileged(jid) { + return Ok(Some(ModuleResult::reply(error( + stanza, + "service-unavailable", + )))); + } + } + if let Some(password) = r.data("password").as_str().filter(|p| !p.is_empty()) { + let supplied = stanza + .descendants() + .find(|n| { + n.is_element() + && n.tag_name().name() == "password" + && n.tag_name().namespace() == Some(MUC) + }) + .and_then(|n| n.text()); + if supplied != Some(password) && !r.occupants.contains_key(jid) { + out.replies.push(error(stanza, "not-authorized")); + return Ok(Some(out)); + } + } + if let Some(old) = r.occupants.get(jid) + && old.nick != nick + { + let remains = r + .occupants + .values() + .any(|other| other.nick == old.nick && other.jid != jid); + for target in r.occupants.values() { + if remains && target.jid != jid { + continue; + } + let p = r + .presence(room, old, target, true, "303") + .replace("().ok()) + .unwrap_or(r.data("history_length").as_u64().unwrap_or(20) as usize) + .min(self.max_history_messages); + let seconds = history + .and_then(|h| h.attribute("seconds")) + .and_then(|v| v.parse::().ok()); + let oldest = seconds.map(|v| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + - v as f64 + }); + let since = history + .and_then(|h| h.attribute("since")) + .and_then(|v| crate::archive::parse_time(v).ok()); + let cutoff = self.retention_seconds.map(|s| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + - s + }); + let mut recent = std::collections::VecDeque::with_capacity(max); + if max > 0 { + crate::archive::scan_store( + &self.root, + &self.domain, + Some(room.split('@').next().unwrap()), + "muc_log", + |entry| { + if entry.peer == "message= t) + && oldest.is_none_or(|t| entry.when >= t) + && cutoff.is_none_or(|t| entry.when >= t) + { + if recent.len() == max { + recent.pop_front(); + } + recent.push_back(entry); + } + Ok(()) + }, + )?; + } + let maxchars = history + .and_then(|h| h.attribute("maxchars")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(usize::MAX); + let mut chars = 0usize; + let selected = recent + .iter() + .rev() + .take(max) + .take_while(|e| { + chars = chars.saturating_add(e.xml.chars().count()); + chars <= maxchars + }) + .cloned() + .collect::>(); + for e in selected.into_iter().rev() { + let archived = + sanitize_archived(&e.xml, r.data("whois").as_str() == Some("anyone"))?; + let delay = format!( + "", + escape(room), + crate::archive::timestamp(e.when)? + ); + let packet = archived + .rsplit_once("
") + .map(|(start, _)| format!("{start}{delay}")) + .unwrap_or_else(|| archived.clone()); + out.replies.push(packet); + } + } + out.replies.push(format!("{}",escape(room),escape(r.data("subject").as_str().unwrap_or("")))); + if self.vcard_enabled + && first_join + && let Some(hash) = avatar_hash(&self.root, &self.domain, room)? + { + out.replies.push(format!("{hash}",escape(room))); + } + if new_occupant && self.log_presences && r.archiving() { + archive_presence(&self.root, &self.domain, room, nick, false)?; + } + self.save(room)?; + return Ok(Some(out)); + } + let Some(r) = self.rooms.get_mut(room) else { + out.replies.push(error(stanza, "item-not-found")); + return Ok(Some(out)); + }; + if kind == "presence" && (typ == "unavailable" || typ == "error") { + if let Some(o) = r.occupants.remove(jid) { + out.replies.push(r.presence(room, &o, &o, true, "")); + let remains = r.occupants.values().any(|other| other.nick == o.nick); + if !remains && self.log_presences && r.archiving() { + archive_presence(&self.root, &self.domain, room, &o.nick, true)?; + } + for target in r.occupants.values() { + if remains { + continue; + } + out.deliveries + .push((target.jid.clone(), r.presence(room, &o, target, true, ""))); + } + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let remove = r.occupants.is_empty() + && r.data("persistent").as_bool() != Some(true) + && !self.pending_occupants.contains_room(room, now); + if remove { + self.delete_saved_room(room)?; + self.rooms.remove(room); + } + return Ok(Some(out)); + } + if kind == "message" { + if let Some(x) = stanza.children().find(|n| n.has_tag_name((USER, "x"))) { + if let Some(decline) = x.children().find(|n| n.tag_name().name() == "decline") { + let Some(target) = decline + .attribute("to") + .filter(|s| !s.is_empty() && !s.contains(char::is_whitespace)) + else { + return Ok(Some(ModuleResult::reply(error(stanza, "jid-malformed")))); + }; + let reason = decline + .children() + .find(|n| n.tag_name().name() == "reason") + .and_then(|n| n.text()) + .unwrap_or(""); + let destinations = if bare(target) == room { + let nick = target.split_once('/').map(|(_, n)| n).unwrap_or(""); + r.occupants + .values() + .filter(|o| o.nick == nick) + .map(|o| o.jid.clone()) + .collect::>() + } else { + vec![target.to_owned()] + }; + for dest in destinations { + out.deliveries.push((dest.clone(),format!("{}",escape(room),escape(&dest),escape(jid),escape(reason)))); + } + return Ok(Some(out)); + } + if let Some(invite) = x.children().find(|n| n.tag_name().name() == "invite") { + if !r + .occupants + .get(jid) + .is_some_and(|o| o.role == "participant" || o.role == "moderator") + || (r.data("members_only").as_bool() == Some(true) + && !r.privileged(jid) + && r.data("allow_member_invites").as_bool() != Some(true)) + { + return Ok(Some(ModuleResult::reply(error(stanza, "forbidden")))); + } + let Some(target) = invite + .attribute("to") + .filter(|s| !s.is_empty() && !s.contains(char::is_whitespace)) + else { + return Ok(Some(ModuleResult::reply(error(stanza, "jid-malformed")))); + }; + if r.data("members_only").as_bool() == Some(true) + && r.affiliation(target) == "none" + { + r.set_affiliation(bare(target), "member"); + } + let reason = invite + .children() + .find(|n| n.tag_name().name() == "reason") + .map(xml) + .unwrap_or_default(); + let password = r + .data("password") + .as_str() + .map(|p| format!("{}", escape(p))) + .unwrap_or_default(); + out.deliveries.push((target.into(),format!("{reason}{password}",escape(room),escape(target),escape(jid)))); + self.save(room)?; + return Ok(Some(out)); + } + } + let Some(o) = r.occupants.get(jid).cloned() else { + out.replies.push(error(stanza, "not-acceptable")); + return Ok(Some(out)); + }; + if let Some(result) = voice_request(r, room, jid, stanza) { + return Ok(Some(result)); + } + if typ == "groupchat" { + if to.contains('/') { + out.replies.push(error(stanza, "bad-request")); + } else if o.role == "visitor" || r.locked { + out.replies.push(error(stanza, "forbidden")); + } else { + let subject = stanza + .children() + .find(|n| n.is_element() && n.tag_name().name() == "subject"); + if let Some(s) = subject { + if o.role != "moderator" && r.data("changesubject").as_bool() != Some(true) + { + out.replies.push(error(stanza, "forbidden")); + return Ok(Some(out)); + } + r.frozen["_data"]["subject"] = json!(s.text().unwrap_or("")); + } + let payload = stanza + .children() + .filter(|n| { + n.is_element() + && !(n.tag_name().namespace() == Some("urn:xmpp:sid:0") + && n.tag_name().name() == "stanza-id") + && !matches!( + n.tag_name().namespace(), + Some( + "urn:xmpp:occupant-id:0" + | "urn:xmpp:hats:0" + | "xmpp:prosody.im/protocol/hats:1" + | MUC + | USER + ) + ) + }) + .map(xml) + .collect::(); + let payload = format!("{payload}{}", r.occupant_id(jid)); + let archive_id = uuid::Uuid::new_v4().to_string(); + let payload = if r.archiving() { + let payload = format!( + "{payload}", + escape(room) + ); + let stored_identity = if r.data("whois").as_str() == Some("anyone") { + format!( + "", + escape(bare(jid)), + escape(&r.affiliation(jid)), + escape(&o.role) + ) + } else { + String::new() + }; + let archived = format!( + "{payload}{stored_identity}", + escape(room), + escape(&o.nick), + escape(stanza.attribute("id").unwrap_or("")) + ); + crate::archive::append_new( + &self.root, + &self.domain, + Some(room.split('@').next().unwrap()), + "muc_log", + crate::archive::Entry { + id: archive_id, + when: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(), + peer: "message{payload}", + escape(room), + escape(&o.nick), + escape(stanza.attribute("id").unwrap_or("")) + ); + r.history.set_capacity( + r.data("history_length") + .as_u64() + .unwrap_or(20) + .min(self.max_history_messages as u64) as usize, + ); + r.history.push( + &history_packet, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64, + ); + for target in r.occupants.values() { + out.deliveries.push((target.jid.clone(),format!("{payload}",escape(room),escape(&o.nick),escape(&target.jid),escape(stanza.attribute("id").unwrap_or(""))))); + } + } + self.save(room)?; + } else if typ == "chat" { + let nick = to.split_once('/').map(|(_, n)| n).unwrap_or(""); + let targets = r + .occupants + .values() + .filter(|o| o.nick == nick) + .collect::>(); + if !targets.is_empty() { + if !r.pm_permitted(&o, targets[0]) { + out.replies.push(error(stanza, "policy-violation")); + return Ok(Some(out)); + } + let payload = stanza + .children() + .filter(|n| { + !matches!( + n.tag_name().namespace(), + Some( + "urn:xmpp:occupant-id:0" + | "urn:xmpp:hats:0" + | "xmpp:prosody.im/protocol/hats:1" + | MUC + | USER + ) + ) + }) + .map(xml) + .collect::(); + let payload = format!("{payload}{}", r.occupant_id(jid)); + for target in targets { + out.deliveries.push((target.jid.clone(),format!("{payload}",escape(room),escape(&o.nick),escape(&target.jid)))); + } + } else { + out.replies.push(error(stanza, "item-not-found")); + } + } else if typ != "error" { + out.replies.push(error(stanza, "feature-not-implemented")); + } + return Ok(Some(out)); + } + if kind == "iq" && to.contains('/') { + self.pending_iq + .retain(|_, v| v.5.elapsed() < std::time::Duration::from_secs(120)); + if matches!(typ, "result" | "error") { + let id = stanza.attribute("id").unwrap_or(""); + let key = format!("{room}|{id}"); + if let Some((target, source, source_nick, target_nick, original_id, _)) = + self.pending_iq.get(&key).cloned() + && target == jid + && to == format!("{room}/{source_nick}") + { + self.pending_iq.remove(&key); + let payload = stanza.children().map(xml).collect::(); + out.deliveries.push((source.clone(),format!("{payload}",escape(&original_id),escape(room),escape(&target_nick),escape(&source)))); + } + return Ok(Some(out)); + } + let Some(source) = r.occupants.get(jid) else { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "not-acceptable", + )))); + }; + let nick = to.split_once('/').unwrap().1; + let Some(target) = r.occupants.values().find(|o| o.nick == nick) else { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "item-not-found", + )))); + }; + if !matches!(typ, "get" | "set") || self.pending_iq.len() >= 10000 { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "resource-constraint", + )))); + } + let id = uuid::Uuid::new_v4().to_string(); + self.pending_iq.insert( + format!("{room}|{id}"), + ( + target.jid.clone(), + jid.into(), + source.nick.clone(), + target.nick.clone(), + stanza.attribute("id").unwrap_or("").into(), + std::time::Instant::now(), + ), + ); + let payload = stanza.children().map(xml).collect::(); + out.deliveries.push((target.jid.clone(),format!("{payload}",escape(room),escape(&source.nick),escape(&target.jid)))); + return Ok(Some(out)); + } + if kind == "iq" { + let Some(q) = stanza.children().find(|n| n.is_element()) else { + out.replies.push(iq_error(stanza, "bad-request")); + return Ok(Some(out)); + }; + if q.has_tag_name(("vcard-temp", "vCard")) { + if !self.vcard_enabled { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "feature-not-implemented", + )))); + } + let user = room.split('@').next().unwrap(); + if typ == "set" { + if r.affiliation(jid) != "owner" { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden")))); + } + let value = crate::storage::stanza_value(q); + crate::storage::save(&self.root, &self.domain, Some(user), "vcard", &value)?; + out.replies.push(iq_result(stanza, "")); + let hash = avatar_hash(&self.root, &self.domain, room)?.unwrap_or_default(); + for o in r.occupants.values() { + out.deliveries.push((o.jid.clone(),format!("{hash}",escape(room),escape(&o.jid)))); + out.deliveries.push((o.jid.clone(),format!("",escape(room),escape(&o.jid)))); + } + } else if typ == "get" { + let payload = + crate::storage::load(&self.root, &self.domain, Some(user), "vcard")? + .map(|v| crate::storage::stanza_xml(&v)) + .transpose()? + .unwrap_or_else(|| "".into()); + out.replies.push(iq_result(stanza, &payload)); + } else { + out.replies.push(iq_error(stanza, "bad-request")); + } + return Ok(Some(out)); + } + if q.tag_name().namespace() == Some("jabber:iq:register") { + let affiliation = r.affiliation(jid); + if affiliation == "outcast" { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden")))); + } + if affiliation == "none" { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "registration-required", + )))); + } + if typ == "get" { + let registered = + r.frozen["_affiliation_data"][bare(jid)]["reserved_nickname"].as_str(); + let content=registered.map(|nick|format!("{}",escape(nick))).unwrap_or_else(||"http://jabber.org/protocol/muc#register".into()); + return Ok(Some(ModuleResult::reply(iq_result( + stanza, + &format!("{content}"), + )))); + } + if typ != "set" { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request")))); + } + if q.children().any(|n| n.tag_name().name() == "remove") { + if affiliation == "member" { + r.set_affiliation(bare(jid), "none"); + if r.data("members_only").as_bool() == Some(true) { + let affected = r + .occupants + .values() + .filter(|o| bare(&o.jid) == bare(jid)) + .cloned() + .collect::>(); + for o in affected { + for target in r.occupants.values() { + out.deliveries.push(( + target.jid.clone(), + r.presence(room, &o, target, true, "321"), + )); + } + r.occupants.remove(&o.jid); + } + } + } + if let Some(map) = r + .frozen + .get_mut("_affiliation_data") + .and_then(Value::as_object_mut) + { + map.remove(bare(jid)); + } + let mut raw = r + .original + .get("_affiliation_data") + .cloned() + .unwrap_or_default(); + raw.set(bare(jid), crate::storage::LuaValue::Nil); + r.original.set("_affiliation_data", raw); + } else { + let Some(form) = q.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 field = |name: &str| { + form.children() + .find(|n| n.attribute("var") == Some(name)) + .and_then(|n| n.children().find(|n| n.tag_name().name() == "value")) + .and_then(|n| n.text()) + }; + if field("FORM_TYPE") != Some("http://jabber.org/protocol/muc#register") { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request")))); + } + let Some(nick) = field("muc#register_roomnick").filter(|n| !n.is_empty()) + else { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request")))); + }; + let nick = match stringprep::resourceprep(nick) { + Ok(v) => v, + Err(_) => { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "bad-request")))); + } + }; + if r.reserved_by(&nick).is_some_and(|owner| owner != bare(jid)) + || r.occupants + .values() + .any(|o| o.nick == nick && bare(&o.jid) != bare(jid)) + { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "conflict")))); + } + r.frozen["_affiliation_data"][bare(jid)]["reserved_nickname"] = + json!(nick.as_ref()); + } + self.save(room)?; + out.replies.push(iq_result(stanza, "")); + return Ok(Some(out)); + } + if q.tag_name().namespace() == Some("http://jabber.org/protocol/disco#info") + && q.attribute("node") == Some("x-roomuser-item") + && typ == "get" + { + let content = r.frozen["_affiliation_data"][bare(jid)]["reserved_nickname"] + .as_str() + .map(|n| { + format!( + "", + escape(n) + ) + }) + .unwrap_or_default(); + return Ok(Some(ModuleResult::reply(iq_result( + stanza, + &format!( + "{content}" + ), + )))); + } + if !self.vcard_enabled && q.tag_name().namespace() == Some("vcard-temp") { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "feature-not-implemented", + )))); + } + if q.tag_name().namespace() == Some("urn:xmpp:mam:2") { + if q.tag_name().name() != "query" { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "feature-not-implemented", + )))); + } + if !r.archiving() { + return Ok(Some(ModuleResult::reply(iq_error( + stanza, + "feature-not-implemented", + )))); + } + if r.affiliation(jid) == "outcast" + || (r.data("members_only").as_bool() == Some(true) + && !matches!(r.affiliation(jid).as_str(), "owner" | "admin" | "member")) + { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden")))); + } + if let Some(seconds) = self.retention_seconds { + crate::archive::expire_before( + &self.root, + &self.domain, + Some(room.split('@').next().unwrap()), + "muc_log", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64() + - seconds, + )?; + } + return match crate::mam::handle_archive_query_with_options( + &self.root, + &self.domain, + Some(room.split('@').next().unwrap()), + "muc_log", + room, + (stanza, q), + self.log_include_total, + ) { + Ok(mut result) => { + for reply in &mut result.replies { + *reply = sanitize_archived( + reply, + r.data("whois").as_str() == Some("anyone"), + )?; + } + Ok(Some(result)) + } + Err(e) => Ok(Some(ModuleResult::reply(iq_error( + stanza, + if e.to_string() == "item-not-found" { + "item-not-found" + } else { + "bad-request" + }, + )))), + }; + } + if q.tag_name().namespace() == Some(OWNER) + && typ == "set" + && q.children().any(|n| n.tag_name().name() == "destroy") + { + if r.affiliation(jid) != "owner" { + return Ok(Some(ModuleResult::reply(iq_error(stanza, "forbidden")))); + } + let destroy = q + .children() + .find(|n| n.tag_name().name() == "destroy") + .unwrap(); + let reason = destroy + .children() + .find(|n| n.tag_name().name() == "reason") + .and_then(|n| n.text()) + .unwrap_or(""); + let alternate = destroy + .attribute("jid") + .map(|v| format!(" jid='{}'", escape(v))) + .unwrap_or_default(); + crate::archive::expire_before( + &self.root, + &self.domain, + Some(room.split('@').next().unwrap()), + "muc_log", + f64::INFINITY, + )?; + let destroy_payload = format!( + "{}", + escape(reason) + ); + for o in r.occupants.values() { + out.deliveries.push((o.jid.clone(),format!("{destroy_payload}",escape(room),escape(&o.nick)))); + } + let tombstone = self.tombstones && r.data("persistent").as_bool() == Some(true); + if tombstone { + r.frozen = json!({"_jid":room,"_data":{"destroyed":true,"hidden":true,"persistent":true,"locked":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs()+31*86400,"reason":destroy.children().find(|n|n.tag_name().name()=="reason").and_then(|n|n.text()).unwrap_or(""),"newjid":destroy.attribute("jid").unwrap_or("")}}); + r.original = crate::storage::LuaValue::table(); + r.occupants.clear(); + self.save(room)?; + out.replies.push(iq_result(stanza, "")); + return Ok(Some(out)); + } + self.delete_saved_room(room)?; + self.rooms.remove(room); + out.replies.push(iq_result(stanza, "")); + return Ok(Some(out)); + } + let avatar = if self.vcard_enabled + && q.tag_name().namespace() == Some("http://jabber.org/protocol/disco#info") + { + avatar_hash(&self.root, &self.domain, room)? + } else { + None + }; + match q.tag_name().namespace().unwrap_or("") { + "http://jabber.org/protocol/disco#info" if typ=="get"=>out.replies.push(iq_result(stanza,&format!("{}{}",escape(r.data("name").as_str().unwrap_or(room)),if r.archiving(){""}else{""}, room_info(r,avatar.as_deref())))), + "http://jabber.org/protocol/disco#items" if typ=="get"=>{if !r.occupants.contains_key(jid){out.replies.push(iq_error(stanza,"forbidden"));}else{let items=r.occupants.values().map(|o|format!("",escape(room),escape(&o.nick))).collect::();out.replies.push(iq_result(stanza,&format!("{items}")));}}, + OWNER=>{if r.affiliation(jid)!="owner"{out.replies.push(iq_error(stanza,"forbidden"));}else if typ=="get"{out.replies.push(iq_result(stanza,&format!("{}",config_form(r))));}else if typ=="set"{if let Some(form)=q.children().find(|n|n.tag_name().namespace()==Some("jabber:x:data")){if form.attribute("type")==Some("submit"){let mut next=r.frozen.clone();let mut valid=true;for f in form.children().filter(|n|n.tag_name().name()=="field"){let name=f.attribute("var").unwrap_or("");let value=f.children().find(|n|n.tag_name().name()=="value").and_then(|n|n.text()).unwrap_or("");if name=="FORM_TYPE" {continue;} +if name=="muc#roomconfig_presencebroadcast" { + let mut roles=json!({}); + for value in f.children().filter(|n|n.tag_name().name()=="value") { + let role=value.text().unwrap_or("");if !matches!(role,"none"|"visitor"|"participant"|"moderator"){valid=false;}else{roles[role]=json!(true);}} + next["_data"]["presence_broadcast"]=roles;continue; +} +if let Some((_,key,boolean))=FIELDS.iter().find(|(n,_,_)|*n==name){ +if matches!(*key,"history_length"|"default_history_messages") {if let Ok(number)=value.parse::() {if number<=self.max_history_messages as u64 {next["_data"][*key]=json!(number);}else{valid=false;}}else{valid=false;}continue;} +if *boolean {if !matches!(value,"0"|"1"|"true"|"false"){valid=false;}else{next["_data"][*key]=json!(if *key=="hidden"{value=="0"||value=="false"}else{value=="1"||value=="true"});}}else{if (*key=="whois"&&!matches!(value,"anyone"|"moderators")) || (*key=="allow_pm"&&!matches!(value,"visitor"|"participant"|"moderator"|"affiliated"|"none")){valid=false;}else{next["_data"][*key]=json!(value);}}}else{valid=false;}} + if valid{ + let became_members_only=r.data("members_only").as_bool()!=Some(true)&&next["_data"]["members_only"].as_bool()==Some(true); + let old_broadcast = ["none","visitor","participant","moderator"].map(|role| r.broadcasts(role)); + let changed = r.frozen != next; + r.frozen=next;r.locked=false;r.clear_data("locked"); + if r.data("language").as_str()==Some("") {r.clear_data("language");} + for (i,role) in ["none","visitor","participant","moderator"].iter().enumerate() { + let enabled=r.broadcasts(role); + if enabled!=old_broadcast[i] {for o in r.occupants.values().filter(|o|o.role==*role) {for target in r.occupants.values().filter(|t|t.nick!=o.nick) {out.deliveries.push((target.jid.clone(),r.presence_force(room,o,target,!enabled,"")));}}} + } + if changed {for target in r.occupants.values(){out.deliveries.push((target.jid.clone(),format!("",escape(room),escape(&target.jid))));}} + if became_members_only {let kicked=r.occupants.values().filter(|o|r.affiliation(&o.jid)=="none").cloned().collect::>();for o in kicked{for target in r.occupants.values(){out.deliveries.push((target.jid.clone(),r.presence(room,&o,target,true,"322")));}r.occupants.remove(&o.jid);}} + out.replies.push(iq_result(stanza,""));self.save(room)?; + }else{out.replies.push(iq_error(stanza,"not-acceptable"));} + }else if form.attribute("type")==Some("cancel"){out.replies.push(iq_result(stanza,""));}else{out.replies.push(iq_error(stanza,"bad-request"));}}else{out.replies.push(iq_error(stanza,"feature-not-implemented"));}}else{out.replies.push(iq_error(stanza,"bad-request"));}}, + ADMIN=>{admin(r,room,jid,stanza,q,&mut out);self.save(room)?;}, + _=>out.replies.push(iq_error(stanza,"feature-not-implemented")),} + return Ok(Some(out)); + } + Ok(Some(out)) + } +} +const FIELDS: &[(&str, &str, bool)] = &[ + ("muc#roomconfig_roomname", "name", false), + ("muc#roomconfig_roomdesc", "description", false), + ("muc#roomconfig_lang", "language", false), + ("muc#roomconfig_persistentroom", "persistent", true), + ("muc#roomconfig_publicroom", "hidden", true), + ("muc#roomconfig_roomsecret", "password", false), + ("muc#roomconfig_membersonly", "members_only", true), + ("muc#roomconfig_changesubject", "changesubject", true), + ("muc#roomconfig_moderatedroom", "moderated", true), + ("muc#roomconfig_whois", "whois", false), + ("muc#roomconfig_historylength", "history_length", false), + ( + "muc#roomconfig_defaulthistorymessages", + "default_history_messages", + false, + ), + ("muc#roomconfig_allowinvites", "allow_member_invites", true), + ("muc#roomconfig_enablearchiving", "archiving", true), + ("muc#roomconfig_allowpm", "allow_pm", false), + ("{xmpp:prosody.im}muc#allow_modpm", "allow_modpm", true), +]; +fn config_form(r: &Room) -> String { + let mut fields="http://jabber.org/protocol/muc#roomconfig".to_string(); + for (name, key, boolean) in FIELDS { + let numeric_value = r.data(key).as_u64().map(|n| n.to_string()); + let section = match *key { + "name" => Some("Room information"), + "hidden" => Some("Access to the room"), + "changesubject" => Some("Permissions in the room"), + "history_length" => Some("Other options"), + _ => None, + }; + if let Some(section) = section { + fields.push_str(&format!( + "{section}" + )); + } + let value = if *boolean { + if (r.data(key).as_bool() == Some(true)) != (*key == "hidden") { + "1" + } else { + "0" + } + } else if let Some(ref number) = numeric_value { + number.as_str() + } else { + if *key == "allow_pm" { + r.allow_pm() + } else { + r.data(key).as_str().unwrap_or("") + } + }; + fields.push_str(&format!( + "{}{}", + if *boolean { + "boolean" + } else if *key == "allow_pm" || *key == "whois" { + "list-single" + } else if *key == "password" { + "text-private" + } else { + "text-single" + }, + escape(value), + match *key { + "allow_pm" => ["visitor", "participant", "moderator", "affiliated", "none"] + .into_iter() + .map(|v| format!("")) + .collect::(), + "whois" => ["moderators", "anyone"] + .into_iter() + .map(|v| format!("")) + .collect::(), + _ => String::new(), + } + )); + } + fields.push_str(""); + for role in ["none", "visitor", "participant", "moderator"] { + if r.broadcasts(role) { + fields.push_str(&format!("{role}")); + } + fields.push_str(&format!("")); + } + fields.push_str(""); + format!("{fields}") +} +fn admin( + r: &mut Room, + room: &str, + jid: &str, + iq: roxmltree::Node, + q: roxmltree::Node, + out: &mut ModuleResult, +) { + let moderator = r.occupants.get(jid).is_some_and(|o| o.role == "moderator"); + if !r.privileged(jid) && !moderator { + out.replies.push(iq_error(iq, "forbidden")); + return; + } + let items: Vec<_> = q + .children() + .filter(|n| n.tag_name().name() == "item") + .collect(); + if items.len() != 1 { + out.replies.push(iq_error(iq, "bad-request")); + return; + } + let item = items[0]; + let aff = item.attribute("affiliation"); + if !r.privileged(jid) && (aff.is_some() || item.attribute("role") == Some("moderator")) { + out.replies.push(iq_error(iq, "forbidden")); + return; + } + if iq.attribute("type") == Some("get") { + if let Some(aff) = aff { + let map = r.frozen.get("_affiliations").unwrap_or(&r.frozen); + let payload = map + .as_object() + .into_iter() + .flatten() + .filter(|(k, v)| !k.starts_with('_') && v.as_str() == Some(aff)) + .map(|(k, _)| format!("", escape(k), escape(aff))) + .collect::(); + out.replies.push(iq_result( + iq, + &format!("{payload}"), + )); + } else if let Some(role) = item.attribute("role") { + let mut seen = std::collections::BTreeSet::new(); + let payload = r + .occupants + .values() + .filter(|o| o.role == role && seen.insert(o.nick.clone())) + .map(|o| { + format!( + "", + escape(&o.nick), + escape(&o.jid), + escape(&o.role), + escape(&r.affiliation(&o.jid)) + ) + }) + .collect::(); + out.replies.push(iq_result( + iq, + &format!("{payload}"), + )); + } else { + out.replies.push(iq_error(iq, "bad-request")); + } + return; + } + if iq.attribute("type") != Some("set") { + out.replies.push(iq_error(iq, "bad-request")); + return; + } + if let Some(aff) = aff { + let target = bare(item.attribute("jid").unwrap_or("")); + if target.is_empty() || !matches!(aff, "owner" | "admin" | "member" | "outcast" | "none") { + out.replies.push(iq_error(iq, "bad-request")); + return; + } + let old = r.affiliation(target); + if r.affiliation(jid) != "owner" + && (matches!(old.as_str(), "owner" | "admin") || matches!(aff, "owner" | "admin")) + { + out.replies.push(iq_error(iq, "forbidden")); + return; + } + if old == "owner" && aff != "owner" { + let map = r.frozen.get("_affiliations").unwrap_or(&r.frozen); + if map + .as_object() + .into_iter() + .flatten() + .filter(|(_, v)| v.as_str() == Some("owner")) + .count() + <= 1 + { + out.replies.push(iq_error(iq, "conflict")); + return; + } + } + r.set_affiliation(target, aff); + let affected = r + .occupants + .values() + .filter(|o| { + bare(&o.jid) == target + || (aff == "outcast" + && !target.contains('@') + && bare(&o.jid).rsplit_once('@').map(|(_, host)| host) == Some(target)) + }) + .cloned() + .collect::>(); + for mut o in affected { + let kick = aff == "outcast" + || (aff == "none" && r.data("members_only").as_bool() == Some(true)); + o.role = if matches!(aff, "owner" | "admin") { + "moderator" + } else if r.data("moderated").as_bool() == Some(true) && aff == "none" { + "visitor" + } else { + "participant" + } + .into(); + if !kick { + r.occupants.insert(o.jid.clone(), o.clone()); + } + for dest in r.occupants.values() { + out.deliveries.push(( + dest.jid.clone(), + r.presence( + room, + &o, + dest, + kick, + if kick { + if aff == "outcast" { "301" } else { "321" } + } else { + "" + }, + ), + )); + } + if kick { + r.occupants.remove(&o.jid); + } + } + out.replies.push(iq_result(iq, "")); + } else if let Some(role) = item.attribute("role") { + if !matches!(role, "none" | "visitor" | "participant" | "moderator") { + out.replies.push(iq_error(iq, "bad-request")); + return; + } + let nick = item.attribute("nick").unwrap_or(""); + let Some(mut o) = r.occupants.values().find(|o| o.nick == nick).cloned() else { + out.replies.push(iq_error(iq, "item-not-found")); + return; + }; + if r.privileged(&o.jid) || (o.role == "moderator" && !r.privileged(jid)) { + out.replies.push(iq_error(iq, "not-allowed")); + return; + } + let was_visible = r.broadcasts(&o.role); + o.role = role.into(); + let withdraw = was_visible && !r.broadcasts(role); + for dest in r.occupants.values() { + out.deliveries.push(( + dest.jid.clone(), + if was_visible { + r.presence_force( + room, + &o, + dest, + role == "none" || withdraw, + if role == "none" { "307" } else { "" }, + ) + } else { + r.presence( + room, + &o, + dest, + role == "none", + if role == "none" { "307" } else { "" }, + ) + }, + )); + } + if role == "none" { + r.occupants.retain(|_, other| other.nick != o.nick); + } else { + for other in r + .occupants + .values_mut() + .filter(|other| other.nick == o.nick) + { + other.role = role.into(); + } + } + out.replies.push(iq_result(iq, "")); + } else { + out.replies.push(iq_error(iq, "bad-request")); + } +} +fn error(stanza: roxmltree::Node, condition: &str) -> String { + format!( + "<{} xmlns='jabber:client' type='error' from='{}' id='{}'><{condition} xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>", + stanza.tag_name().name(), + escape(stanza.attribute("to").unwrap_or("")), + escape(stanza.attribute("id").unwrap_or("")), + stanza.tag_name().name() + ) +} +// Keep a lossless original alongside a JSON view of supported settings. Binary +// salts and mixed-key extension metadata survive configuration updates untouched. +fn view_value(v: &crate::storage::LuaValue) -> Value { + use crate::storage::LuaValue as L; + match v { + L::Nil => Value::Null, + L::Bool(v) => json!(v), + L::Integer(v) => json!(v), + L::Number(v) => json!(v), + L::String(v) => std::str::from_utf8(v) + .map(|s| json!(s)) + .unwrap_or(Value::Null), + L::Table(t) => { + let mut o = serde_json::Map::new(); + for (k, v) in t { + if let Some(k) = k.as_str() { + o.insert(k.into(), view_value(v)); + } + } + Value::Object(o) + } + } +} +fn merge_value(raw: &mut crate::storage::LuaValue, v: &Value) { + use crate::storage::LuaValue as L; + match v { + Value::Null => {} + Value::Bool(v) => *raw = L::Bool(*v), + Value::Number(v) => { + *raw = if let Some(i) = v.as_i64() { + L::Integer(i) + } else { + L::Number(v.as_f64().unwrap()) + } + } + Value::String(v) => *raw = L::from(v.clone()), + Value::Object(o) => { + if !matches!(raw, L::Table(_)) { + *raw = L::table(); + } + for (k, v) in o { + let mut item = raw.get(k).cloned().unwrap_or(L::Nil); + merge_value(&mut item, v); + raw.set(k, item); + } + } + Value::Array(_) => {} + } +} +fn read_value(path: &std::path::Path) -> Result { + Ok(view_value(&crate::storage::parse(&std::fs::read(path)?)?)) +} +fn write_value(path: &std::path::Path, value: &Value) -> Result<()> { + fn lua(v: &Value) -> String { + match v { + Value::Null => "nil".into(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => { + let mut out = "\"".to_string(); + for b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + 32..=126 => out.push(*b as char), + _ => out.push_str(&format!("\\{b:03}")), + } + } + out.push('"'); + out + } + Value::Array(a) => format!("{{{}}}", a.iter().map(lua).collect::>().join(",")), + Value::Object(o) => format!( + "{{{}}}", + o.iter() + .map(|(k, v)| format!("[{}]={}", lua(&json!(k)), lua(v))) + .collect::>() + .join(",") + ), + } + } + crate::storage::atomic_write(path, format!("return {};\n", lua(value)).as_bytes())?; + Ok(()) +} + +fn avatar_hash(root: &std::path::Path, domain: &str, room: &str) -> Result> { + use base64::Engine; + use sha1::Digest; + let Some(v) = + crate::storage::load(root, domain, Some(room.split('@').next().unwrap()), "vcard")? + else { + return Ok(None); + }; + let xml = crate::storage::stanza_xml(&v)?; + let doc = roxmltree::Document::parse(&xml)?; + let Some(bin) = doc + .descendants() + .find(|n| n.tag_name().name() == "BINVAL") + .and_then(|n| n.text()) + else { + return Ok(None); + }; + let compact = bin + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(); + match base64::engine::general_purpose::STANDARD.decode(compact) { + Ok(bytes) => Ok(Some(hex::encode(sha1::Sha1::digest(bytes)))), + Err(_) => Ok(None), + } +} + +fn voice_request( + room: &mut Room, + name: &str, + jid: &str, + stanza: roxmltree::Node, +) -> Option { + let form = stanza + .children() + .find(|n| n.has_tag_name(("jabber:x:data", "x")))?; + let field = |key: &str| { + form.children() + .find(|n| n.attribute("var") == Some(key)) + .and_then(|n| { + n.children() + .find(|n| n.has_tag_name(("jabber:x:data", "value"))) + }) + .and_then(|n| n.text()) + }; + if field("FORM_TYPE") != Some("http://jabber.org/protocol/muc#request") { + return None; + } + if form.attribute("type") != Some("submit") { + return Some(ModuleResult::reply(error(stanza, "bad-request"))); + } + let Some(actor) = room.occupants.get(jid).cloned() else { + return Some(ModuleResult::reply(error(stanza, "not-acceptable"))); + }; + let mut out = ModuleResult::default(); + if actor.role == "moderator" { + if !matches!(field("muc#request_allow"), Some("true" | "1")) { + return Some(out); + } + let Some(target) = field("muc#jid") + .and_then(|j| room.occupants.get(j)) + .cloned() + else { + return Some(out); + }; + if target.role != "visitor" { + return Some(out); + } + for session in room + .occupants + .values_mut() + .filter(|o| o.nick == target.nick && bare(&o.jid) == bare(&target.jid)) + { + session.role = "participant".into(); + } + let mut promoted = target; + promoted.role = "participant".into(); + for dest in room.occupants.values() { + out.deliveries.push(( + dest.jid.clone(), + room.presence(name, &promoted, dest, false, ""), + )); + } + } else if actor.role == "visitor" { + // Never trust requester-supplied identity, nickname, role or allow flag. + let payload = format!( + "Voice Requesthttp://jabber.org/protocol/muc#request{}{}participant0", + escape(jid), + escape(&actor.nick) + ); + for dest in room.occupants.values().filter(|o| o.role == "moderator") { + out.deliveries.push((dest.jid.clone(),format!("{payload}",escape(name),escape(&dest.jid)))); + } + } + Some(out) +} + +fn sanitize_archived(packet: &str, public: bool) -> Result { + use crate::storage::LuaValue as V; + fn walk(n: roxmltree::Node, public: bool) -> Option { + if n.has_tag_name((USER, "x")) && !public { + return None; + } + let mut value = crate::storage::stanza_value(n); + if n.is_element() { + if let V::Table(entries) = &mut value { + entries.retain(|(key, _)| !matches!(key, V::Integer(_))); + } + if n.tag_name().name() == "message" { + let mut attr = value.get("attr").cloned().unwrap_or_default(); + attr.set("to", V::Nil); + value.set("attr", attr); + } + let mut index = 1; + for child in n.children().filter(|n| n.is_element() || n.is_text()) { + if let Some(child) = walk(child, public) { + value.set_key(V::Integer(index), child); + index += 1; + } + } + } + Some(value) + } + let doc = roxmltree::Document::parse(packet)?; + crate::storage::stanza_xml(&walk(doc.root_element(), public).unwrap()) +} + +fn room_info(r: &Room, avatar: Option<&str>) -> String { + let mut out = String::new(); + for feature in [ + "http://jabber.org/protocol/muc#stable_id", + "http://jabber.org/protocol/muc#self-ping-optimization", + if r.data("hidden").as_bool() == Some(true) { + "muc_hidden" + } else { + "muc_public" + }, + if r.data("members_only").as_bool() == Some(true) { + "muc_membersonly" + } else { + "muc_open" + }, + if r.data("moderated").as_bool() == Some(true) { + "muc_moderated" + } else { + "muc_unmoderated" + }, + if r.data("persistent").as_bool() == Some(true) { + "muc_persistent" + } else { + "muc_temporary" + }, + if r.data("password").as_str().is_some_and(|p| !p.is_empty()) { + "muc_passwordprotected" + } else { + "muc_unsecured" + }, + if r.data("whois").as_str() == Some("anyone") { + "muc_nonanonymous" + } else { + "muc_semianonymous" + }, + ] { + out.push_str(&format!("")); + } + out.push_str("http://jabber.org/protocol/muc#roominfo"); + for (name, value) in [ + ( + "muc#roominfo_lang", + r.data("language").as_str().unwrap_or(""), + ), + ( + "muc#roominfo_description", + r.data("description").as_str().unwrap_or(""), + ), + ] { + out.push_str(&format!( + "{}", + escape(value) + )); + } + let count = r + .occupants + .values() + .map(|o| o.nick.as_str()) + .collect::>() + .len(); + out.push_str(&format!( + "{count}" + )); + for (name, value) in [ + ( + "muc#roomconfig_changesubject", + r.data("changesubject").as_bool() == Some(true), + ), + ( + "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", + r.data("allow_member_invites").as_bool() == Some(true), + ), + ( + "muc#roomconfig_allowinvites", + r.data("members_only").as_bool() != Some(true) + || r.data("allow_member_invites").as_bool() == Some(true), + ), + ] { + out.push_str(&format!( + "{}", + if value { "1" } else { "0" } + )); + } + if let Some(avatar) = avatar { + out.push_str(&format!( + "{}", + escape(avatar) + )); + } + out.push_str(""); + out +} + +fn archive_presence( + root: &std::path::Path, + domain: &str, + room: &str, + nick: &str, + unavailable: bool, +) -> Result<()> { + crate::archive::append_new( + root, + domain, + Some(room.split('@').next().unwrap()), + "muc_log", + crate::archive::Entry { + id: uuid::Uuid::new_v4().to_string(), + when: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(), + peer: if unavailable { + "presence{}", + escape(room), + escape(nick), + if unavailable { + " type='unavailable'" + } else { + "" + }, + if unavailable { + "" + } else { + "" + } + ), + }, + )?; + Ok(()) +} diff --git a/src/muc_history.rs b/src/muc_history.rs new file mode 100644 index 0000000..378416c --- /dev/null +++ b/src/muc_history.rs @@ -0,0 +1,204 @@ +//! Bounded, live XEP-0045 discussion history, independent of archive storage. +use std::collections::VecDeque; + +const MUC: &str = "http://jabber.org/protocol/muc"; + +/// Constraints apply together; an explicit constraint overrides the default count. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HistoryRequest { + pub maxchars: Option, + pub maxstanzas: Option, + pub since: Option, +} +impl HistoryRequest { + pub fn parse(presence: &str, now: i64) -> Self { + let Ok(doc) = roxmltree::Document::parse(presence) else { + return Self::default(); + }; + let history = doc + .root_element() + .children() + .find(|n| n.has_tag_name((MUC, "x"))) + .and_then(|x| x.children().find(|n| n.has_tag_name((MUC, "history")))); + let Some(history) = history else { + return Self::default(); + }; + let number = |name| { + history + .attribute(name) + .and_then(|v| v.parse::().ok()) + .filter(|n| n.is_finite()) + }; + // Native tonumber accepts fractional/negative values: limits below one + // select no stanzas, and a fractional count is effectively rounded down. + let count = |name| number(name).map(|n| n.max(0.0).min(usize::MAX as f64) as usize); + let mut since = history + .attribute("since") + .and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok()) + .map(|t| t.timestamp() as f64 + f64::from(t.timestamp_subsec_nanos()) / 1e9); + if let Some(seconds) = number("seconds") { + let cutoff = now as f64 - seconds; + since = Some(since.map_or(cutoff, |old| old.max(cutoff))); + } + Self { + maxchars: count("maxchars"), + maxstanzas: count("maxstanzas"), + since, + } + } + pub fn has_constraints(&self) -> bool { + self.maxchars.is_some() || self.maxstanzas.is_some() || self.since.is_some() + } +} + +#[derive(Clone, Debug)] +struct Entry { + stanza: String, + timestamp: i64, +} + +#[derive(Clone, Debug)] +pub struct RoomHistory { + capacity: usize, + default_messages: usize, + entries: VecDeque, +} +impl RoomHistory { + pub fn new(capacity: usize) -> Self { + Self { + capacity, + default_messages: 20, + entries: VecDeque::new(), + } + } + pub fn set_capacity(&mut self, capacity: usize) { + self.capacity = capacity; + while self.entries.len() > capacity { + self.entries.pop_front(); + } + } + pub fn set_default_messages(&mut self, count: usize) { + self.default_messages = count; + } + pub fn len(&self) -> usize { + self.entries.len() + } + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// 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 { + return; + } + let Ok(doc) = roxmltree::Document::parse(stanza) else { + return; + }; + let root = doc.root_element(); + if !is_historic(root) { + return; + } + let from = root.attribute("from").unwrap_or(""); + let room = from.split('/').next().unwrap_or(from); + let Some(time) = chrono::DateTime::from_timestamp(timestamp, 0) else { + return; + }; + let stamp = time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let mut packet = crate::protocol::address(root, from, ""); + let Some(end) = packet.rfind("", + crate::wire::escape(room) + ), + ); + self.entries.push_back(Entry { + stanza: packet, + timestamp, + }); + self.set_capacity(self.capacity); + } + + /// The live snapshot keeps only the last message, matching native MUC state. + pub fn last_message(&self) -> Option<(&str, i64)> { + self.entries + .back() + .map(|entry| (entry.stanza.as_str(), entry.timestamp)) + } + /// 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 { + return; + } + let Ok(doc) = roxmltree::Document::parse(stanza) else { + return; + }; + if !is_historic(doc.root_element()) { + return; + } + self.entries.clear(); + self.entries.push_back(Entry { + stanza: stanza.into(), + timestamp, + }); + } + + pub fn replay(&self, _room: &str, to: &str, presence: &str, now: i64) -> Vec { + let request = HistoryRequest::parse(presence, now); + let max = request.maxstanzas.unwrap_or_else(|| { + if request.has_constraints() { + self.entries.len() + } else { + self.default_messages + } + }); + let mut packets = Vec::new(); + let mut chars = 0usize; + for entry in self.entries.iter().rev().take(max) { + if request + .since + .is_some_and(|since| (entry.timestamp as f64) < since) + { + break; + } + let Ok(doc) = roxmltree::Document::parse(&entry.stanza) else { + continue; + }; + let root = doc.root_element(); + let packet = crate::protocol::address(root, root.attribute("from").unwrap_or(""), to); + chars = chars.saturating_add(packet.len()); + if request.maxchars.is_some_and(|limit| chars > limit) { + break; + } + packets.push(packet); + } + packets.reverse(); + packets + } +} + +fn is_historic(stanza: roxmltree::Node<'_, '_>) -> bool { + if stanza.tag_name().name() != "message" || stanza.attribute("type") != Some("groupchat") { + return false; + } + let has = + |name: &str, namespace: &str| stanza.children().any(|n| n.has_tag_name((namespace, name))); + if has("no-store", "urn:xmpp:hints") || has("no-permanent-store", "urn:xmpp:hints") { + return false; + } + has("store", "urn:xmpp:hints") + || stanza.children().any(|n| { + n.is_element() + && n.tag_name().name() == "body" + && matches!(n.tag_name().namespace(), None | Some("jabber:client")) + }) + || has("encryption", "urn:xmpp:eme:0") + || stanza + .children() + .any(|n| n.is_element() && n.tag_name().namespace() == Some("urn:xmpp:chat-markers:0")) +} diff --git a/src/muc_recovery.rs b/src/muc_recovery.rs new file mode 100644 index 0000000..d6b4a7c --- /dev/null +++ b/src/muc_recovery.rs @@ -0,0 +1,502 @@ +//! Bounded native Prosody MUC occupant checkpoints. +//! +//! A checkpoint describes prior membership, never a live transport. Callers must +//! keep recovered occupants out of routing until authenticated rejoin and current +//! room admission checks succeed. Saved roles are hints, not authorization. +use crate::{ + protocol::bare, + storage::{self, LuaValue as V}, +}; +use anyhow::{Result, ensure}; +use std::{collections::BTreeMap, path::Path}; + +pub const MAX_OCCUPANTS: usize = 4096; +const MAX_FIELD: usize = 3072; +const MAX_PRESENCE: usize = 16 * 1024; +pub const RECOVERY_SECONDS: u64 = 300; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OccupantSnapshot { + pub jid: String, + pub nick: String, + pub role: String, + /// Only show/status/priority are retained. Passwords and extension payloads + /// are intentionally excluded, including when reading native snapshots. + pub presence_payload: String, +} +fn valid(s: &str) -> bool { + !s.is_empty() && s.len() <= MAX_FIELD && !s.chars().any(char::is_control) +} +fn room_node<'a>(room: &'a str, domain: &str) -> Result<&'a str> { + let (node, host) = room.split_once('@').unwrap_or(("", "")); + ensure!( + valid(node) && host == domain && !room.contains('/'), + "invalid recovery room" + ); + Ok(node) +} +fn field<'a>(v: &'a V, key: &str) -> Option<&'a str> { + v.get(key)?.as_str() +} +fn scalar_stanza(name: &str, text: &str) -> V { + let mut v = V::table(); + v.set("name", name.into()); + v.set("attr", V::table()); + v.set_key(V::Integer(1), text.into()); + v +} +fn safe_payload(v: &V) -> String { + let mut out = String::new(); + for (_, child) in v.entries() { + let Some(name) = field(child, "name") else { + continue; + }; + if !matches!(name, "show" | "status" | "priority") { + continue; + } + if child + .get("attr") + .and_then(|a| field(a, "xmlns")) + .is_some_and(|ns| ns != "jabber:client") + { + continue; + } + let text = child + .get_key(&V::Integer(1)) + .and_then(V::as_str) + .unwrap_or(""); + if text.len() <= MAX_FIELD && out.len() + text.len() + 64 <= MAX_PRESENCE { + out.push_str(&format!("<{name}>{}", crate::wire::escape(text))); + } + } + out +} +/// 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> { + ensure!(valid(room) && !room.contains('/'), "invalid recovery room"); + ensure!( + value.entries().len() <= MAX_OCCUPANTS * 2 + 16, + "occupant recovery limit" + ); + let prefix = format!("{room}/"); + let mut out = BTreeMap::new(); + for (key, presence) in value.entries() { + let Some(jid) = key.as_str() else { continue }; + if !valid(jid) + || !jid.contains('/') + || jid.starts_with(&prefix) + || field(presence, "name") != Some("presence") + { + continue; + } + let Some(attr) = presence.get("attr") else { + continue; + }; + if field(attr, "type").is_some() { + continue; + } + let Some(occupant_jid) = field(attr, "from") else { + continue; + }; + let Some(nick) = occupant_jid.strip_prefix(&prefix).filter(|s| valid(s)) else { + continue; + }; + let Some(meta) = value.get(occupant_jid) else { + continue; + }; + if field(meta, "bare_jid") != Some(bare(jid)) { + continue; + } + let Some(role) = + field(meta, "role").filter(|r| matches!(*r, "visitor" | "participant" | "moderator")) + else { + continue; + }; + out.insert( + jid.to_owned(), + OccupantSnapshot { + jid: jid.into(), + nick: nick.into(), + role: role.into(), + presence_payload: safe_payload(presence), + }, + ); + ensure!(out.len() <= MAX_OCCUPANTS, "occupant recovery limit"); + } + Ok(out.into_values().collect()) +} + +pub fn load_room(root: &Path, domain: &str, room: &str) -> Result> { + let node = room_node(room, domain)?; + match storage::load(root, domain, Some(node), "state")? { + Some(value) => decode_room(room, &value), + None => Ok(Vec::new()), + } +} +/// Write a Prosody-compatible state map atomically. Empty membership removes it. +pub fn save_room( + root: &Path, + domain: &str, + room: &str, + occupants: &[OccupantSnapshot], +) -> Result<()> { + save_room_with_fingerprint(root, domain, room, occupants, None) +} + +/// The optional digest binds automatic password-room recovery to its saved config. +pub fn save_room_with_fingerprint( + root: &Path, + domain: &str, + room: &str, + occupants: &[OccupantSnapshot], + fingerprint: Option<&str>, +) -> Result<()> { + let saved_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + save_room_checkpoint(root, domain, room, occupants, fingerprint, saved_at) +} + +pub fn save_room_checkpoint( + root: &Path, + domain: &str, + room: &str, + occupants: &[OccupantSnapshot], + fingerprint: Option<&str>, + saved_at: u64, +) -> Result<()> { + let node = room_node(room, domain)?; + ensure!(saved_at <= i64::MAX as u64, "invalid checkpoint timestamp"); + ensure!(occupants.len() <= MAX_OCCUPANTS, "occupant recovery limit"); + let mut state = V::table(); + for o in occupants { + ensure!( + valid(&o.jid) && o.jid.contains('/') && valid(&o.nick), + "invalid occupant recovery identity" + ); + ensure!( + matches!(o.role.as_str(), "visitor" | "participant" | "moderator"), + "invalid recovery role" + ); + let occupant_jid = format!("{room}/{}", o.nick); + let mut meta = V::table(); + meta.set("bare_jid", bare(&o.jid).into()); + meta.set("jid", o.jid.clone().into()); + meta.set("role", o.role.clone().into()); + if let Some(previous) = state.get(&occupant_jid) { + ensure!( + field(previous, "bare_jid") == Some(bare(&o.jid)), + "duplicate occupant nickname" + ); + } + state.set(&occupant_jid, meta); + let mut presence = V::table(); + presence.set("name", "presence".into()); + let mut attr = V::table(); + attr.set("from", occupant_jid.into()); + attr.set("to", o.jid.clone().into()); + presence.set("attr", attr); + // Status text is optional checkpoint data. Large or unparseable + // client payloads must never prevent saving room membership at shutdown. + if o.presence_payload.len() <= MAX_PRESENCE { + let wrapped = format!("{}", o.presence_payload); + if let Ok(doc) = roxmltree::Document::parse(&wrapped) { + let mut index = 0; + for child in doc.root_element().children().filter(|c| c.is_element()) { + let name = child.tag_name().name(); + if !matches!(name, "show" | "status" | "priority") + || !matches!(child.tag_name().namespace(), None | Some("jabber:client")) + { + continue; + } + let text = child.text().unwrap_or(""); + if text.len() > MAX_FIELD { + continue; + } + index += 1; + presence.set_key(V::Integer(index), scalar_stanza(name, text)); + } + } + } + state.set(&o.jid, presence); + } + if !occupants.is_empty() { + state.set("_rust_xmpp_saved_at", V::Integer(saved_at as i64)); + if let Some(fingerprint) = fingerprint { + ensure!( + fingerprint.len() == 64 && fingerprint.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid recovery fingerprint" + ); + state.set("_rust_xmpp_config_hash", fingerprint.into()); + } + } + storage::save(root, domain, Some(node), "state", &state) +} + +/// Dormant claims have a fixed restart deadline; probes never extend it. +#[derive(Default)] +pub struct PendingOccupants { + rooms: BTreeMap)>, +} +impl PendingOccupants { + pub fn insert( + &mut self, + room: String, + occupants: Vec, + now: u64, + ) -> Result<()> { + ensure!( + occupants.len() <= MAX_OCCUPANTS + && self.rooms.values().map(|(_, v)| v.len()).sum::() + occupants.len() + <= MAX_OCCUPANTS, + "pending recovery limit" + ); + self.rooms + .insert(room, (now.saturating_add(RECOVERY_SECONDS), occupants)); + Ok(()) + } + /// Preserve a checkpoint's original deadline over repeated process restarts. + pub fn insert_checkpoint( + &mut self, + room: String, + occupants: Vec, + saved_at: u64, + now: u64, + ) -> Result<()> { + if saved_at > now || saved_at.saturating_add(RECOVERY_SECONDS) <= now { + return Ok(()); + } + self.insert(room, occupants, saved_at) + } + /// Call only after authenticating the account and checking current room ACLs. + /// Resource names alone never establish ownership, and old privilege must be + /// recomputed by the caller rather than copied from the returned snapshot. + pub fn claim( + &mut self, + room: &str, + authenticated_jid: &str, + nick: &str, + now: u64, + ) -> Option { + self.expire(now); + let (_, occupants) = self.rooms.get_mut(room)?; + let i = occupants + .iter() + .position(|o| o.jid == authenticated_jid && o.nick == nick)?; + let mut claim = occupants.remove(i); + claim.jid = authenticated_jid.into(); + Some(claim) + } + /// Candidate snapshots are still dormant. The caller rechecks admission + /// before claim, and must not route to these identities beforehand. + pub fn candidates( + &mut self, + authenticated_jid: &str, + now: u64, + ) -> Vec<(String, OccupantSnapshot)> { + self.expire(now); + self.rooms + .iter() + .flat_map(|(room, (_, occupants))| { + occupants + .iter() + .filter(|o| o.jid == authenticated_jid) + .map(|o| (room.clone(), o.clone())) + }) + .collect() + } + pub fn room_snapshot(&mut self, room: &str, now: u64) -> Option<(u64, Vec)> { + self.expire(now); + self.rooms.get(room).map(|(deadline, occupants)| { + (deadline.saturating_sub(RECOVERY_SECONDS), occupants.clone()) + }) + } + /// Read-only membership probe; deadline-sensitive callers use contains_room. + pub fn has_room(&self, room: &str) -> bool { + self.rooms + .get(room) + .is_some_and(|(_, occupants)| !occupants.is_empty()) + } + /// Live dormant identities for bounded admission accounting. + 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())) + }) + .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 { + let (deadline, occupants) = self.rooms.get(room)?; + if *deadline <= now { + return None; + } + occupants + .iter() + .find(|o| o.nick == nick) + .map(|o| bare(&o.jid).to_owned()) + } + pub fn contains_room(&mut self, room: &str, now: u64) -> bool { + self.expire(now); + self.rooms + .get(room) + .is_some_and(|(_, occupants)| !occupants.is_empty()) + } + 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 + } + }); + removed + } + pub fn len(&self) -> usize { + self.rooms.values().map(|(_, v)| v.len()).sum() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Hash canonical room contents. Lua table iteration order is not stable across +/// VM instances, so storage serialization order cannot be an authorization proof. +pub fn configuration_fingerprint(config: &V) -> Result { + use sha2::{Digest, Sha256}; + fn canonical(value: &V, depth: usize) -> Result { + ensure!(depth < 64, "configuration fingerprint depth limit"); + if let V::Table(entries) = value { + let mut sorted = entries + .iter() + .map(|(key, value)| { + Ok(( + storage::serialize(key)?, + key.clone(), + canonical(value, depth + 1)?, + )) + }) + .collect::>>()?; + sorted.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(V::Table( + sorted + .into_iter() + .map(|(_, key, value)| (key, value)) + .collect(), + )) + } else { + Ok(value.clone()) + } + } + Ok(hex::encode(Sha256::digest( + storage::serialize(&canonical(config, 0)?)?.as_bytes(), + ))) +} + +pub fn load_fingerprint(root: &Path, domain: &str, room: &str) -> Result> { + let node = room_node(room, domain)?; + Ok(storage::load(root, domain, Some(node), "state")? + .and_then(|v| field(&v, "_rust_xmpp_config_hash").map(str::to_owned))) +} + +/// Native snapshots have no timestamp; callers should stamp them once before +/// placing them in dormant recovery so subsequent restarts retain the deadline. +pub fn load_checkpoint_time(root: &Path, domain: &str, room: &str) -> Result> { + let node = room_node(room, domain)?; + Ok(storage::load(root, domain, Some(node), "state")? + .and_then(|v| v.get("_rust_xmpp_saved_at").and_then(V::as_i64)) + .and_then(|time| u64::try_from(time).ok())) +} + +fn validate_last_message(room: &str, stanza: &str) -> Result<()> { + ensure!(stanza.len() <= MAX_PRESENCE, "recovery last message limit"); + let doc = roxmltree::Document::parse(stanza)?; + let root = doc.root_element(); + ensure!( + root.tag_name().name() == "message" + && matches!( + root.tag_name().namespace(), + None | Some("") | Some("jabber:client") + ), + "invalid recovery message namespace" + ); + ensure!( + root.attribute("type") == Some("groupchat"), + "invalid recovery message type" + ); + let from = root.attribute("from").unwrap_or(""); + ensure!( + from == room + || from + .strip_prefix(room) + .is_some_and(|suffix| suffix.starts_with('/') && suffix.len() > 1), + "recovery message belongs to another room" + ); + Ok(()) +} + +/// Preserve native Prosody's final history entry while keeping occupant state. +/// Call after saving the room checkpoint, which replaces the state map. +pub fn set_last_message( + root: &Path, + domain: &str, + room: &str, + message: Option<(&str, i64)>, +) -> Result<()> { + let node = room_node(room, domain)?; + let encoded = if let Some((stanza, timestamp)) = message { + ensure!(timestamp >= 0, "invalid recovery message timestamp"); + validate_last_message(room, stanza)?; + let doc = roxmltree::Document::parse(stanza)?; + Some((storage::stanza_value(doc.root_element()), timestamp)) + } else { + None + }; + storage::update(root, domain, Some(node), "state", |state| { + state.set( + "_last_message", + encoded.as_ref().map(|(v, _)| v.clone()).unwrap_or(V::Nil), + ); + state.set( + "_last_message_at", + encoded + .as_ref() + .map(|(_, time)| V::Integer(*time)) + .unwrap_or(V::Nil), + ); + Ok(()) + })?; + Ok(()) +} + +pub fn load_last_message(root: &Path, domain: &str, room: &str) -> Result> { + let node = room_node(room, domain)?; + let Some(state) = storage::load(root, domain, Some(node), "state")? else { + return Ok(None); + }; + let (Some(message), Some(timestamp)) = + (state.get("_last_message"), state.get("_last_message_at")) + else { + return Ok(None); + }; + let timestamp = match timestamp { + V::Integer(value) if *value >= 0 => *value, + V::Number(value) if value.is_finite() && *value >= 0.0 && *value < i64::MAX as f64 => { + *value as i64 + } + _ => return Ok(None), + }; + ensure!( + storage::serialize(message)?.len() <= MAX_PRESENCE * 2, + "recovery last message limit" + ); + let stanza = storage::stanza_xml(message)?; + validate_last_message(room, &stanza)?; + Ok(Some((stanza, timestamp))) +} diff --git a/src/pep_diagnostics.rs b/src/pep_diagnostics.rs new file mode 100644 index 0000000..a03641f --- /dev/null +++ b/src/pep_diagnostics.rs @@ -0,0 +1,117 @@ +//! Payload-shape diagnostics. Values are fixed classifications and counts; +//! account names, node names, item identifiers and key material stay private. +use std::collections::BTreeSet; + +const EVENT: &str = "http://jabber.org/protocol/pubsub#event"; +const LEGACY: &str = "eu.siacs.conversations.axolotl"; +const OMEMO2: &str = "urn:xmpp:omemo:2"; + +/// Inspect the first direct item in an IQ or event `items` wrapper. +pub fn item_fields(items: roxmltree::Node<'_, '_>) -> Vec<(&'static str, String)> { + let payload = items + .children() + .find(|n| { + n.is_element() + && n.tag_name().name() == "item" + && n.tag_name().namespace() == items.tag_name().namespace() + }) + .and_then(|n| n.first_element_child()); + let protocol = match payload.map(|n| (n.tag_name().namespace(), n.tag_name().name())) { + Some((Some(LEGACY), "list")) => "legacy", + Some((Some(OMEMO2), "devices")) => "omemo2", + _ => "other", + }; + let mut valid = BTreeSet::new(); + let mut invalid = 0; + if protocol != "other" { + for device in payload + .unwrap() + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "device") + { + // Conversations' parser checks the local name, then uses Java + // Integer.valueOf; it does not trim or restrict IDs to positive values. + if let Some(id) = device.attribute("id").and_then(java_integer) { + valid.insert(id); + } else { + invalid += 1; + } + } + } + vec![ + ("device_protocol", protocol.into()), + ("valid_device_count", valid.len().to_string()), + ("invalid_device_count", invalid.to_string()), + ] +} + +/// Inspect outgoing PEP item notifications without disclosing stanza values. +pub fn event_fields(message: roxmltree::Node<'_, '_>) -> Option> { + if message.tag_name().name() != "message" { + return None; + } + let event = message + .children() + .find(|n| n.has_tag_name((EVENT, "event")))?; + let items = event + .children() + .find(|n| n.has_tag_name((EVENT, "items")))?; + let node_kind = match items.attribute("node") { + Some("eu.siacs.conversations.axolotl.devicelist" | "urn:xmpp:omemo:2:devices") => { + "omemo-devices" + } + Some("urn:xmpp:avatar:metadata") => "avatar-metadata", + Some("urn:xmpp:avatar:data") => "avatar-data", + _ => "other", + }; + let mut direct_items = items.children().filter(|n| n.has_tag_name((EVENT, "item"))); + let content_present = direct_items + .next() + .is_some_and(|n| n.first_element_child().is_some()); + let items_count = items + .children() + .filter(|n| n.has_tag_name((EVENT, "item"))) + .count(); + let mut fields = vec![ + ("node_kind", node_kind.into()), + ("items_count", items_count.to_string()), + ("content_present", content_present.to_string()), + ]; + fields.extend(item_fields(items)); + Some(fields) +} + +// Java Integer.parseInt accepts BMP decimal digits through Character.digit(char, +// 10), but rejects supplementary digits because it processes UTF-16 code units. +fn java_integer(value: &str) -> Option { + let (negative, digits) = if let Some(v) = value.strip_prefix('-') { + (true, v) + } else { + (false, value.strip_prefix('+').unwrap_or(value)) + }; + if digits.is_empty() { + return None; + } + let limit = if negative { + 2_147_483_648_i64 + } else { + 2_147_483_647 + }; + let mut number = 0_i64; + for digit in digits.chars() { + let code = digit as u32; + let zero = [ + 0x0030, 0x0660, 0x06f0, 0x07c0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 0x0b66, 0x0be6, 0x0c66, + 0x0ce6, 0x0d66, 0x0de6, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x1090, 0x17e0, 0x1810, 0x1946, + 0x19d0, 0x1a80, 0x1a90, 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620, 0xa8d0, 0xa900, 0xa9d0, + 0xa9f0, 0xaa50, 0xabf0, 0xff10, + ] + .into_iter() + .find(|zero| code >= *zero && code < *zero + 10)?; + number = number * 10 + i64::from(code - zero); + if number > limit { + return None; + } + } + Some(if negative { -number } else { number } as i32) +} diff --git a/src/preauth.rs b/src/preauth.rs new file mode 100644 index 0000000..3a18e0b --- /dev/null +++ b/src/preauth.rs @@ -0,0 +1,251 @@ +//! XEP-0379 presence tokens and the XEP-0401 contact invitation command. +//! +//! Unscoped tokens use Prosody mod_invites' per-user `invite_token` map +//! (token -> Unix expiry). Identity-scoped tokens live separately so a server +//! that does not understand the scope cannot accidentally redeem them. +use crate::{ + account_modules::normalize_bare_jid, + protocol::{ModuleResult, bare, iq_error, iq_result}, + storage::{self, LuaValue as V}, + wire::escape, +}; +use anyhow::{Result, ensure}; +use roxmltree::Node; +use std::path::Path; + +pub const NS: &str = "urn:xmpp:pars:0"; +pub const INVITE_COMMAND: &str = "urn:xmpp:invite#invite"; +pub const DEFAULT_TTL: i64 = 7 * 86400; +const SCOPED_STORE: &str = "preauth_scoped"; +const MAX_TOKENS: usize = 256; + +#[derive(Debug, Clone)] +pub struct Invitation { + pub token: String, + pub uri: String, + pub expires: i64, +} +fn local_user<'a>(domain: &str, owner: &'a str) -> Result<&'a str> { + let (user, host) = owner + .split_once('@') + .ok_or_else(|| anyhow::anyhow!("account JID required"))?; + ensure!( + host == domain && !user.is_empty() && !owner.contains('/'), + "not a local bare account" + ); + ensure!( + normalize_bare_jid(owner)? == owner, + "noncanonical account JID" + ); + Ok(user) +} +fn expiry(value: &V) -> Option { + match value { + V::Integer(n) => Some(*n), + V::Number(n) if n.is_finite() && n.fract() == 0.0 && *n >= 0.0 && *n < i64::MAX as f64 => { + Some(*n as i64) + } + _ => None, + } +} +/// Issue a single-use token. `owner` and optional `recipient` must be bare JIDs. +/// `now` is Unix seconds; callers normally use `chrono::Utc::now().timestamp()`. +pub fn create_invite( + root: &Path, + domain: &str, + owner: &str, + recipient: Option<&str>, + ttl: i64, + now: i64, +) -> Result { + let user = local_user(domain, owner)?; + ensure!( + storage::load(root, domain, Some(user), "accounts")? + .is_some_and(|account| account.get("disabled") != Some(&V::Bool(true))), + "unknown account" + ); + ensure!( + now >= 0 && ttl > 0 && ttl <= 30 * 86400, + "invalid token lifetime" + ); + let expires = now + .checked_add(ttl) + .ok_or_else(|| anyhow::anyhow!("expiry overflow"))?; + let recipient = recipient.map(normalize_bare_jid).transpose()?; + ensure!(recipient.as_deref() != Some(owner), "cannot invite self"); + let token = uuid::Uuid::new_v4().simple().to_string(); + let scoped = recipient.is_some(); + let store = if scoped { SCOPED_STORE } else { "invite_token" }; + storage::update(root, domain, Some(user), store, |data| { + let expired: Vec<_> = data + .entries() + .iter() + .filter_map(|(k, v)| { + let end = if scoped { + v.get("expires").and_then(expiry) + } else { + expiry(v) + }; + end.filter(|e| *e <= now).map(|_| k.clone()) + }) + .collect(); + for key in expired { + data.set_key(key, V::Nil); + } + ensure!( + data.entries().len() < MAX_TOKENS, + "too many pending invitations" + ); + let value = if let Some(jid) = &recipient { + let mut value = V::table(); + value.set("expires", V::Integer(expires)); + value.set("jid", jid.clone().into()); + value + } else { + V::Integer(expires) + }; + data.set(&token, value); + Ok(()) + })?; + Ok(Invitation { + uri: format!("xmpp:{owner}?roster;preauth={token}"), + token, + expires, + }) +} +/// Atomically validate and consume a token. Invalid/expired/mismatched tokens +/// return false and must follow normal manual subscription processing. +/// Caller must authenticate `sender`, verify recipient account and blocking, +/// then apply roster transitions only when this returns true. +pub fn consume( + root: &Path, + domain: &str, + owner: &str, + sender: &str, + token: &str, + now: i64, +) -> Result { + let user = local_user(domain, owner)?; + let sender = normalize_bare_jid(bare(sender))?; + if token.is_empty() || token.len() > 256 || sender == owner || now < 0 { + return Ok(false); + } + let mut accepted = false; + // Never fall back to an unscoped record when a scoped record is present. + let mut found_scope = false; + if storage::load(root, domain, Some(user), SCOPED_STORE)? + .is_some_and(|data| data.get(token).is_some()) + { + storage::update(root, domain, Some(user), SCOPED_STORE, |data| { + if let Some(value) = data.get(token) { + found_scope = true; + let end = value.get("expires").and_then(expiry); + accepted = end.is_some_and(|end| end > now) + && value.get("jid").and_then(V::as_str) == Some(sender.as_str()); + if accepted || end.is_some_and(|end| end <= now) { + data.set(token, V::Nil); + } + } + Ok(()) + })?; + } + if found_scope { + return Ok(accepted); + } + if !storage::load(root, domain, Some(user), "invite_token")? + .is_some_and(|data| data.get(token).is_some()) + { + return Ok(false); + } + storage::update(root, domain, Some(user), "invite_token", |data| { + if let Some(end) = data.get(token).and_then(expiry) { + accepted = end > now; + data.set(token, V::Nil); + } + Ok(()) + })?; + if accepted + && storage::load(root, domain, None, "invite_token")? + .is_some_and(|data| data.get(token).is_some()) + { + // mod_invites also removes the host-level registration side of a + // combined roster/account invitation when the roster token is used. + storage::update(root, domain, None, "invite_token", |data| { + if data.get(token).is_some_and(|record| { + record.get("type").and_then(V::as_str) == Some("roster") + && record.get("jid").and_then(V::as_str) == Some(owner) + }) { + data.set(token, V::Nil); + } + Ok(()) + })?; + } + Ok(accepted) +} +/// Only a single, well-formed direct child of a subscribe presence qualifies. +pub fn token_from_presence<'a>(presence: Node<'a, '_>) -> Option<&'a str> { + if presence.tag_name().name() != "presence" || presence.attribute("type") != Some("subscribe") { + return None; + } + let mut tokens = presence + .children() + .filter(|n| n.has_tag_name((NS, "preauth"))); + let token = tokens.next()?; + if tokens.next().is_some() || token.children().any(|n| n.is_element()) { + return None; + } + token + .attribute("token") + .filter(|t| !t.is_empty() && t.len() <= 256) +} +/// Contact-only subset of XEP-0401, with no registration permission or landing URL. +pub fn handle_iq( + root: &Path, + domain: &str, + actor: &str, + iq: Node<'_, '_>, +) -> Result> { + let Some(command) = iq.children().find(|n| { + n.has_tag_name(("http://jabber.org/protocol/commands", "command")) + && n.attribute("node") == Some(INVITE_COMMAND) + }) else { + return Ok(None); + }; + let error = |name| Ok(Some(ModuleResult::reply(iq_error(iq, name)))); + if iq.attribute("type") != Some("set") + || iq.attribute("id").is_none() + || iq.children().filter(|n| n.is_element()).count() != 1 + { + return error("bad-request"); + } + let owner = bare(actor); + if local_user(domain, owner).is_err() { + return error("not-authorized"); + } + if iq + .attribute("to") + .is_some_and(|to| to != domain && to != owner) + { + return error("forbidden"); + } + if !matches!(command.attribute("action"), None | Some("execute")) + || command.attribute("sessionid").is_some() + || command.children().any(|n| n.is_element()) + { + return error("bad-request"); + } + let now = chrono::Utc::now().timestamp(); + let invitation = match create_invite(root, domain, owner, None, DEFAULT_TTL, now) { + Ok(invite) => invite, + Err(_) => return error("resource-constraint"), + }; + let expires = chrono::DateTime::from_timestamp(invitation.expires, 0) + .ok_or_else(|| anyhow::anyhow!("invalid expiry"))? + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let payload = format!( + "urn:xmpp:invite#invitation{}{}", + escape(&invitation.uri), + escape(&expires) + ); + Ok(Some(ModuleResult::reply(iq_result(iq, &payload)))) +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..1f4fa85 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,102 @@ +use crate::wire::escape; +#[derive(Default, Debug)] +pub struct ModuleResult { + pub replies: Vec, + /// (destination full or bare JID, serialized stanza) + pub deliveries: Vec<(String, String)>, +} +impl ModuleResult { + pub fn reply(xml: String) -> Self { + Self { + replies: vec![xml], + deliveries: vec![], + } + } +} +pub fn iq_result(iq: roxmltree::Node<'_, '_>, payload: &str) -> String { + let id = escape(iq.attribute("id").unwrap_or("")); + let from = iq + .attribute("to") + .map(|v| format!(" from='{}'", escape(v))) + .unwrap_or_default(); + format!("{payload}") +} +pub fn iq_error(iq: roxmltree::Node<'_, '_>, condition: &str) -> String { + let id = escape(iq.attribute("id").unwrap_or("")); + let from = iq + .attribute("to") + .map(|v| format!(" from='{}'", escape(v))) + .unwrap_or_default(); + let payload = iq.first_element_child().map(xml).unwrap_or_default(); + format!( + "{payload}<{condition} xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>" + ) +} +/// Re-serialize a subtree, explicitly preserving namespace bindings. +pub fn xml(node: roxmltree::Node<'_, '_>) -> String { + xml_namespace(node, None) +} +pub fn xml_namespace(node: roxmltree::Node<'_, '_>, mapping: Option<(&str, &str)>) -> String { + serialize(node, mapping, None) +} +pub fn address(node: roxmltree::Node<'_, '_>, from: &str, to: &str) -> String { + serialize(node, None, Some((from, to))) +} +fn serialize( + node: roxmltree::Node<'_, '_>, + mapping: Option<(&str, &str)>, + addresses: Option<(&str, &str)>, +) -> String { + if node.is_text() { + return escape(node.text().unwrap_or("")); + } + if !node.is_element() { + return String::new(); + } + let tag = node.tag_name(); + let prefix = tag.namespace().and_then(|ns| node.lookup_prefix(ns)); + let name = prefix + .map(|p| format!("{p}:{}", tag.name())) + .unwrap_or_else(|| tag.name().to_owned()); + let mut out = format!("<{name}"); + for ns in node.namespaces() { + let uri = if let Some((from, to)) = mapping { + if ns.uri() == from { to } else { ns.uri() } + } else { + ns.uri() + }; + if ns.name() == Some("xml") { + continue; + } + match ns.name() { + Some(p) => out.push_str(&format!(" xmlns:{p}='{}'", escape(uri))), + None => out.push_str(&format!(" xmlns='{}'", escape(uri))), + } + } + for a in node.attributes() { + if addresses.is_some() && a.namespace().is_none() && matches!(a.name(), "from" | "to") { + continue; + } + let name = match a.namespace() { + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", a.name()), + Some(ns) => node + .lookup_prefix(ns) + .map(|p| format!("{p}:{}", a.name())) + .unwrap_or_else(|| a.name().into()), + None => a.name().into(), + }; + out.push_str(&format!(" {name}='{}'", escape(a.value()))); + } + if let Some((from, to)) = addresses { + out.push_str(&format!(" from='{}' to='{}'", escape(from), escape(to))); + } + out.push('>'); + for child in node.children() { + out.push_str(&serialize(child, mapping, None)); + } + out.push_str(&format!("")); + out +} +pub fn bare(jid: &str) -> &str { + jid.split('/').next().unwrap_or(jid) +} diff --git a/src/pubsub.rs b/src/pubsub.rs new file mode 100644 index 0000000..20f610d --- /dev/null +++ b/src/pubsub.rs @@ -0,0 +1,1608 @@ +//! Persistent PEP and PubSub subset. Access checks precede item reads and writes. +use crate::{ + protocol::{ModuleResult, bare, iq_error, iq_result}, + storage::{self, LuaValue as V}, + wire::escape, +}; +use anyhow::Result; +use std::{path::Path, sync::Mutex}; +const NS: &str = "http://jabber.org/protocol/pubsub"; +const OWNER: &str = "http://jabber.org/protocol/pubsub#owner"; +static LOCK: Mutex<()> = Mutex::new(()); +fn text<'a>(v: &'a V, k: &str, default: &'a str) -> &'a str { + v.get(k).and_then(V::as_str).unwrap_or(default) +} +fn tab(v: &V, k: &str) -> V { + v.get(k).cloned().unwrap_or_default() +} +fn defaults(name: &str, owner: &str, pep: bool) -> V { + let mut v = V::table(); + v.set("name", name.into()); + let mut c = V::table(); + c.set("access_model", if pep { "presence" } else { "open" }.into()); + c.set("max_items", V::Integer(if pep { 1 } else { 20 })); + c.set("publish_model", "publishers".into()); + c.set("notification_type", "headline".into()); + c.set("itemreply", "none".into()); + c.set( + "send_last_published_item", + if pep { "on_sub_and_presence" } else { "never" }.into(), + ); + c.set("persist_items", V::Bool(true)); + // These options default to true in delivery as well. Materialize them so + // publish preconditions compare against effective defaults, including nodes + // loaded from older stores which did not persist these fields. + for key in [ + "notify_items", + "include_payload", + "notify_delete", + "notify_retract", + ] { + c.set(key, V::Bool(true)); + } + v.set("config", c); + v.set("subscribers", V::table()); + let mut a = V::table(); + a.set(owner, "owner".into()); + v.set("affiliations", a); + v +} +fn publish_denied( + root: &Path, + host: &str, + user: Option<&str>, + owner: &str, + actor: &str, + node: &V, +) -> Result { + let actor_bare = bare(actor); + if let Some(user) = user { + if crate::account_modules::is_blocked(root, host, user, actor)? { + return Ok(true); + } + if let Some((peer, peer_host)) = actor_bare.split_once('@') + && peer_host == host + && crate::account_modules::is_blocked(root, host, peer, owner)? + { + return Ok(true); + } + } + let aff = tab(node, "affiliations"); + let a = text(&aff, actor_bare, ""); + if a == "outcast" { + return Ok(true); + } + Ok(false) +} +fn allowed( + root: &Path, + host: &str, + user: Option<&str>, + owner: &str, + actor: &str, + node: &V, +) -> Result { + let actor_bare = bare(actor); + if actor_bare == owner { + return Ok(true); + } + if publish_denied(root, host, user, owner, actor, node)? { + return Ok(false); + } + let aff = tab(node, "affiliations"); + let a = text(&aff, actor_bare, ""); + if matches!(a, "owner" | "publisher" | "member") { + return Ok(true); + } + let c = tab(node, "config"); + Ok( + match text( + &c, + "access_model", + if user.is_some() { "presence" } else { "open" }, + ) { + "open" => true, + "presence" => { + if let Some(u) = user { + let r = storage::load(root, host, Some(u), "roster")?.unwrap_or_default(); + matches!( + text(&tab(&r, actor_bare), "subscription", ""), + "both" | "from" + ) + } else { + false + } + } + "roster" => { + if let Some(u) = user { + let r = storage::load(root, host, Some(u), "roster")?.unwrap_or_default(); + let g = tab(&tab(&r, actor_bare), "groups"); + tab(&c, "roster_groups_allowed") + .entries() + .iter() + .any(|(_, v)| v.as_str().is_some_and(|s| g.get(s) == Some(&V::Bool(true)))) + } else { + false + } + } + _ => false, + }, + ) +} +fn form(action: roxmltree::Node<'_, '_>, maximum: usize) -> std::result::Result { + let Some(x) = action + .children() + .find(|n| n.has_tag_name(("jabber:x:data", "x"))) + else { + return Err("bad-request"); + }; + if x.attribute("type") != Some("submit") { + return Err("bad-request"); + } + let mut out = V::table(); + for f in x + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "field") + { + let Some(k) = f.attribute("var") else { + return Err("bad-request"); + }; + let vals: Vec<_> = f + .children() + .filter(|n| n.is_element() && n.tag_name().name() == "value") + .map(|n| n.text().unwrap_or("")) + .collect(); + if k == "FORM_TYPE" { + if !matches!( + vals.first().copied(), + Some( + "http://jabber.org/protocol/pubsub#node_config" + | "http://jabber.org/protocol/pubsub#publish-options" + ) + ) { + return Err("bad-request"); + } + continue; + } + let Some(k) = k.strip_prefix("pubsub#") else { + return Err("feature-not-implemented"); + }; + // XEP-0060 wire field names map to Prosody's internal config keys. + let k = match k { + "deliver_notifications" => "notify_items", + "deliver_payloads" => "include_payload", + "type" => "payload_type", + _ => k, + }; + let value = vals.first().copied().unwrap_or(""); + let v = match k { + "access_model" if matches!(value, "open" | "presence" | "roster" | "whitelist") => { + value.into() + } + "max_items" => V::Integer(if value == "max" { + maximum as i64 + } else { + value + .parse::() + .ok() + .filter(|n| (1..=maximum as i64).contains(n)) + .ok_or("not-acceptable")? + }), + + "persist_items" | "notify_items" | "notify_retract" | "notify_delete" + | "include_payload" => V::Bool(match value { + "1" | "true" => true, + "0" | "false" => false, + _ => return Err("bad-request"), + }), + "title" | "description" | "payload_type" => value.into(), + "publish_model" if matches!(value, "publishers" | "open" | "subscribers") => { + value.into() + } + "notification_type" if matches!(value, "normal" | "headline") => value.into(), + "itemreply" if matches!(value, "none" | "owner" | "publisher") => value.into(), + "publish_model" | "notification_type" | "itemreply" => return Err("not-acceptable"), + "send_last_published_item" + if matches!(value, "never" | "on_sub" | "on_sub_and_presence") => + { + value.into() + } + "roster_groups_allowed" => V::Table( + vals.iter() + .enumerate() + .map(|(i, s)| (V::Integer(i as i64 + 1), (*s).into())) + .collect(), + ), + _ => return Err("feature-not-implemented"), + }; + out.set(k, v) + } + Ok(out) +} +fn merge(a: &mut V, b: &V) { + for (k, v) in b.entries() { + a.set_key(k.clone(), v.clone()) + } +} +fn form_xml(c: &V) -> String { + let mut s="http://jabber.org/protocol/pubsub#node_config".to_string(); + for (k, v) in c.entries() { + let Some(k) = k.as_str() else { continue }; + let field = match k { + "notify_items" => "deliver_notifications", + "include_payload" => "deliver_payloads", + "payload_type" => "type", + _ => k, + }; + s.push_str(&format!("", escape(field))); + match v { + V::Table(t) => { + for (_, v) in t { + if let Some(v) = v.as_str() { + s.push_str(&format!("{}", escape(v))) + } + } + } + _ => { + let value = match v { + V::Bool(b) => { + if *b { + "1".into() + } else { + "0".into() + } + } + V::Integer(n) => n.to_string(), + _ => v.as_str().unwrap_or("").into(), + }; + s.push_str(&format!("{}", escape(&value))) + } + } + s.push_str("") + } + s.push_str(""); + s +} +#[derive(Clone, Debug)] +pub struct Options { + pub max_items: usize, + pub restrict_node_creation: bool, + pub autocreate_on_publish: bool, +} +impl Default for Options { + fn default() -> Self { + Self { + max_items: 256, + restrict_node_creation: true, + autocreate_on_publish: false, + } + } +} +pub fn handle( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, +) -> Result> { + handle_with_options( + root, + domain, + jid, + iq, + &Options { + autocreate_on_publish: true, + restrict_node_creation: false, + ..Options::default() + }, + ) +} +pub fn handle_with_options( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, + options: &Options, +) -> Result> { + // Server and resource disco describe those entities, not the virtual PEP service. + if iq.children().any(|n| { + n.has_tag_name(("http://jabber.org/protocol/disco#info", "query")) + || n.has_tag_name(("http://jabber.org/protocol/disco#items", "query")) + }) && iq.attribute("to").is_none_or(|to| to.contains('/')) + { + return Ok(None); + } + let target = bare(iq.attribute("to").unwrap_or(bare(jid))); + let Some((u, h)) = target.split_once('@') else { + return Ok(None); + }; + if h != domain { + return Ok(None); + } + handle_inner(root, domain, Some(u), target, jid, false, iq, options) +} +pub fn handle_component( + root: &Path, + component: &str, + jid: &str, + admins: &[String], + iq: roxmltree::Node<'_, '_>, +) -> Result> { + handle_component_with_options(root, component, jid, admins, iq, &Options::default()) +} +pub fn handle_component_with_options( + root: &Path, + component: &str, + jid: &str, + admins: &[String], + iq: roxmltree::Node<'_, '_>, + options: &Options, +) -> Result> { + handle_inner( + root, + component, + None, + component, + jid, + admins.iter().any(|a| a == bare(jid)), + iq, + options, + ) +} +// Explicit storage coordinates match both PEP and component backends. +#[allow(clippy::too_many_arguments)] +fn handle_inner( + root: &Path, + host: &str, + user: Option<&str>, + owner: &str, + jid: &str, + admin: bool, + iq: roxmltree::Node<'_, '_>, + options: &Options, +) -> Result> { + let Some(p) = iq.children().find(|n| { + n.is_element() + && ((n.tag_name().name() == "pubsub" + && matches!(n.tag_name().namespace(), Some(NS | OWNER))) + || n.has_tag_name(("http://jabber.org/protocol/disco#items", "query")) + || n.has_tag_name(("http://jabber.org/protocol/disco#info", "query"))) + }) else { + return Ok(None); + }; + let _lock = LOCK + .lock() + .map_err(|_| anyhow::anyhow!("pubsub lock poisoned"))?; + let actor = bare(jid); + let own = actor == owner || admin; + let fail = |s| Ok(Some(ModuleResult::reply(iq_error(iq, s)))); + if let Some(u) = user + && storage::load(root, host, Some(u), "accounts")?.is_none() + { + return fail("item-not-found"); + } + let pep = user.is_some(); + let mut nodes = if pep { + storage::load(root, host, user, "pep")?.unwrap_or_default() + } else { + V::table() + }; + // Component metadata is one keyval file per node; enumerate only its dedicated store. + if !pep { + let dir = storage::path(root, host, Some("dummy"), "pubsub_nodes") + .parent() + .unwrap() + .to_owned(); + if dir.exists() { + for e in std::fs::read_dir(dir)? { + let e = e?; + if e.path().extension().and_then(|s| s.to_str()) != Some("dat") { + continue; + } + let v = storage::parse(&std::fs::read(e.path())?)?; + if let Some(name) = v.get("name").and_then(V::as_str) { + nodes.set(name, v.clone()) + } + } + } + } + if p.has_tag_name(("http://jabber.org/protocol/disco#info", "query")) { + if iq.attribute("type") != Some("get") { + return fail("bad-request"); + } + let node_attr = p + .attribute("node") + .map(|n| format!(" node='{}'", escape(n))) + .unwrap_or_default(); + let mut body = format!( + "", + if p.attribute("node").is_some() { + "leaf" + } else if pep { + "pep" + } else { + "service" + } + ); + if pep && p.attribute("node").is_none() { + body.push_str(""); + } + if let Some(requested) = p.attribute("node") { + let Some(n) = nodes.get(requested) else { + return fail("item-not-found"); + }; + if !own && !allowed(root, host, user, owner, jid, n)? { + return fail("forbidden"); + } + body.push_str( + &form_xml(&tab(n, "config")) + .replace("type='form'", "type='result'") + .replace("pubsub#node_config", "pubsub#meta-data"), + ); + } + body.push_str(""); + body.push_str(&if pep { pep_features() } else { features() }); + body.push_str(""); + return Ok(Some(ModuleResult::reply(iq_result(iq, &body)))); + } + if p.tag_name().name() == "query" { + if iq.attribute("type") != Some("get") { + return fail("bad-request"); + } + if let Some(requested) = p.attribute("node") { + let Some(n) = nodes.get(requested) else { + return fail("item-not-found"); + }; + if !own && !allowed(root, host, user, owner, jid, n)? { + return fail("forbidden"); + } + let mut s = format!( + "", + escape(requested) + ); + for entry in read_items(root, host, user, requested)? { + s.push_str(&format!( + "", + escape(owner), + escape(&entry.id) + )); + } + s.push_str(""); + return Ok(Some(ModuleResult::reply(iq_result(iq, &s)))); + } + let mut s = "".to_string(); + for (k, v) in nodes.entries() { + if (own || allowed(root, host, user, owner, jid, v)?) && k.as_str().is_some() { + s.push_str(&format!( + "", + escape(owner), + escape(k.as_str().unwrap()) + )) + } + } + s.push_str(""); + return Ok(Some(ModuleResult::reply(iq_result(iq, &s)))); + } + let Some(a) = p.children().find(|n| n.is_element()) else { + return fail("bad-request"); + }; + if a.tag_name().namespace() != p.tag_name().namespace() { + return fail("bad-request"); + } + let action = a.tag_name().name(); + let typ = iq.attribute("type").unwrap_or(""); + let owner_ns = p.tag_name().namespace() == Some(OWNER); + let node_name = a.attribute("node").map(str::to_owned).unwrap_or_else(|| { + if action == "create" { + uuid::Uuid::new_v4().to_string() + } else { + String::new() + } + }); + let mut node = nodes.get(&node_name).cloned(); + if node == Some(V::Bool(true)) { + node = Some(defaults(&node_name, owner, pep)) + } + if let Some(n) = node.as_mut() { + let mut effective = tab(&defaults(&node_name, owner, pep), "config"); + merge(&mut effective, &tab(n, "config")); + // Early Rust releases stored the wire alias as `type`. Canonicalize it + // so a later native-form submission cannot produce duplicate fields. + if let Some(legacy) = effective.get("type").cloned() { + if effective.get("payload_type").is_none() { + effective.set("payload_type", legacy); + } + effective.set("type", V::Nil); + } + n.set("config", effective); + } + let node_owner = own + || node + .as_ref() + .is_some_and(|n| text(&tab(n, "affiliations"), actor, "") == "owner"); + if owner_ns && !node_owner && !(action == "default" && typ == "get") { + return fail("forbidden"); + } + let mut payload = String::new(); + let mut notify: Option<(String, String)> = None; + let mut changed = false; + if action == "affiliations" && typ == "get" && !owner_ns { + let mut out = String::new(); + for (k, n) in nodes.entries() { + let affiliation = if own { + "owner" + } else { + n.get("affiliations") + .and_then(|a| a.get(actor)) + .and_then(V::as_str) + .unwrap_or("none") + }; + if affiliation != "none" { + out.push_str(&format!( + "", + escape(k.as_str().unwrap_or("")), + escape(affiliation) + )); + } + } + payload = format!("{out}"); + } else if action == "default" && typ == "get" && owner_ns { + payload = format!( + "{}", + form_xml(&tab(&defaults("", owner, pep), "config")) + ); + } else if action == "subscriptions" && typ == "get" { + let mut s = String::new(); + for (k, n) in nodes.entries() { + if !node_name.is_empty() && k.as_str() != Some(&node_name) { + continue; + } + if owner_ns && !own && text(&tab(n, "affiliations"), actor, "") != "owner" { + return fail("forbidden"); + } + for (j, _) in tab(n, "subscribers").entries() { + if let Some(j) = j.as_str() + && (owner_ns || bare(j) == actor) + { + s.push_str(&format!( + "", + escape(k.as_str().unwrap_or("")), + escape(j) + )) + } + } + } + payload = format!("{s}"); + } else { + if node_name.is_empty() { + return fail("bad-request"); + } + match (typ, action, owner_ns) { + ("set", "create", false) | ("set", "publish", false) => { + let publisher = if let Some(n) = node.as_ref() { + let explicit = text(&tab(n, "affiliations"), actor, "") == "publisher"; + let model = tab(n, "config"); + let eligible = text(&model, "publish_model", "publishers") == "open" + || (text(&model, "publish_model", "publishers") == "subscribers" + && tab(n, "subscribers") + .entries() + .iter() + .any(|(j, _)| j.as_str().is_some_and(|j| bare(j) == actor))); + explicit || (eligible && !publish_denied(root, host, user, owner, jid, n)?) + } else { + false + }; + let may_create = own || (!pep && !options.restrict_node_creation); + if (action == "create" && !may_create) + || (action == "publish" + && !(node_owner || publisher || (node.is_none() && may_create))) + { + return fail("forbidden"); + } + if action == "create" && node.is_some() { + return fail("conflict"); + } + if node.is_none() && !pep && !options.autocreate_on_publish && action == "publish" { + return fail("item-not-found"); + } + let was_new = node.is_none(); + let n = node.get_or_insert_with(|| defaults(&node_name, actor, pep)); + let mut c = tab(n, "config"); + if let Some(f) = p.children().find(|n| { + n.is_element() + && n.tag_name().name() + == if action == "create" { + "configure" + } else { + "publish-options" + } + }) { + let opts = match form(f, options.max_items) { + Ok(v) => v, + Err(e) => return fail(e), + }; + if !was_new + && opts.entries().iter().any(|(k, v)| { + // Prosody persists max_items as either a number or a form string. + if k.as_str() == Some("max_items") { + item_limit(&c, options.max_items) + != item_limit(&opts, options.max_items) + } else { + c.get_key(k) != Some(v) + } + }) + { + let reply = iq_error(iq, "conflict").replace( + "", + "", + ); + return Ok(Some(ModuleResult::reply(reply))); + } + merge(&mut c, &opts) + } + n.set("config", c.clone()); + if action == "publish" { + let items: Vec<_> = a.children().filter(|n| n.is_element()).collect(); + if items.len() != 1 || !items[0].has_tag_name((NS, "item")) { + return fail("bad-request"); + } + let i = items[0]; + let children: Vec<_> = i.children().filter(|n| n.is_element()).collect(); + if children.len() != 1 { + return fail("bad-request"); + } + let id = i + .attribute("id") + .map(str::to_owned) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let xml = crate::protocol::xml(children[0]); + write_item( + root, + host, + user, + &node_name, + &id, + &xml, + actor, + item_limit(&c, options.max_items), + c.get("persist_items") != Some(&V::Bool(false)), + )?; + payload = format!( + "", + escape(&node_name), + escape(&id) + ); + notify = Some(("items".into(), item_xml(&c, &id, &xml, actor))); + } else { + payload = format!("", escape(&node_name)) + } + changed = true; + } + ("get", "items", false) => { + let Some(n) = node.as_ref() else { + return fail("item-not-found"); + }; + if !own && !allowed(root, host, user, owner, jid, n)? { + return fail("forbidden"); + }; + let wanted: Vec<_> = a + .children() + .filter(|n| n.is_element()) + .filter_map(|n| n.attribute("id")) + .collect(); + let max = match a.attribute("max_items") { + Some(s) => match s.parse::() { + Ok(n) => n, + Err(_) => return fail("bad-request"), + }, + None => options.max_items, + }; + let items = read_items(root, host, user, &node_name)?; + let chosen: Vec<_> = items + .iter() + .rev() + .filter(|i| wanted.is_empty() || wanted.contains(&i.id.as_str())) + .take(max) + .collect(); + if !wanted.is_empty() && wanted.iter().any(|id| !chosen.iter().any(|i| i.id == *id)) + { + return fail("item-not-found"); + } + // Native Prosody returns newest first; clients commonly read + // only the first item (notably OMEMO device-list parsers). + let s = chosen + .iter() + .map(|i| item_xml(&tab(n, "config"), &i.id, &i.xml, &i.peer)) + .collect::(); + payload = format!("{s}", escape(&node_name)); + } + ("get", "configure", true) => { + let Some(n) = node.as_ref() else { + return fail("item-not-found"); + }; + payload = format!( + "{}", + escape(&node_name), + form_xml(&tab(n, "config")) + ); + } + ("set", "configure", true) => { + let Some(n) = node.as_mut() else { + return fail("item-not-found"); + }; + let opts = match form(a, options.max_items) { + Ok(v) => v, + Err(e) => return fail(e), + }; + let mut c = tab(n, "config"); + merge(&mut c, &opts); + let max = item_limit(&c, options.max_items); + let mut entries = read_items(root, host, user, &node_name)?; + if entries.len() > max { + entries.drain(..entries.len() - max); + } + save_items( + root, + host, + user, + &node_name, + &entries, + c.get("persist_items") != Some(&V::Bool(false)), + )?; + n.set("config", c); + changed = true; + } + ("set", "subscribe", false) | ("set", "unsubscribe", false) => { + let Some(n) = node.as_mut() else { + return fail("item-not-found"); + }; + let Some(sub) = a.attribute("jid") else { + return fail("bad-request"); + }; + if bare(sub) != actor { + return fail("forbidden"); + } + if action == "subscribe" + && !own + && (!allowed(root, host, user, owner, jid, n)? + || !allowed(root, host, user, owner, sub, n)?) + { + return fail("forbidden"); + }; + let mut subs = tab(n, "subscribers"); + let sub_options = if let Some(o) = p + .children() + .find(|n| n.is_element() && n.tag_name().name() == "options") + { + match subscription_form(o) { + Ok(v) => v, + Err(e) => return fail(e), + } + } else { + V::Bool(true) + }; + subs.set( + sub, + if action == "subscribe" { + sub_options + } else { + V::Nil + }, + ); + n.set("subscribers", subs); + changed = true; + payload = format!( + "", + escape(&node_name), + escape(sub), + if action == "subscribe" { + "subscribed" + } else { + "none" + } + ); + } + ("get", "affiliations", true) => { + let Some(n) = node.as_ref() else { + return fail("item-not-found"); + }; + let mut out = String::new(); + for (j, a) in tab(n, "affiliations").entries() { + if let (Some(j), Some(a)) = (j.as_str(), a.as_str()) { + out.push_str(&format!( + "", + escape(j), + escape(a) + )) + } + } + payload = format!( + "{out}", + escape(&node_name) + ); + } + ("set", "affiliations", true) => { + let Some(n) = node.as_mut() else { + return fail("item-not-found"); + }; + let mut aff = tab(n, "affiliations"); + for a in a.children().filter(|n| n.is_element()) { + let (Some(j), Some(v)) = (a.attribute("jid"), a.attribute("affiliation")) + else { + return fail("bad-request"); + }; + if !j.contains('@') + || j.contains('/') + || !matches!(v, "owner" | "publisher" | "member" | "outcast" | "none") + { + return fail("bad-request"); + } + if pep && j == owner && v != "owner" { + return fail("forbidden"); + } + aff.set(j, if v == "none" { V::Nil } else { v.into() }); + } + if !aff + .entries() + .iter() + .any(|(_, v)| v.as_str() == Some("owner")) + { + return fail("not-acceptable"); + } + n.set("affiliations", aff); + changed = true; + } + ("set", "subscriptions", true) => { + let Some(n) = node.as_mut() else { + return fail("item-not-found"); + }; + let mut subs = tab(n, "subscribers"); + for a in a.children().filter(|n| n.is_element()) { + let (Some(j), Some(v)) = (a.attribute("jid"), a.attribute("subscription")) + else { + return fail("bad-request"); + }; + if !j.contains('@') || !matches!(v, "subscribed" | "none") { + return fail("bad-request"); + } + subs.set(j, if v == "none" { V::Nil } else { V::Bool(true) }); + } + n.set("subscribers", subs); + changed = true; + } + ("get", "options", false) | ("set", "options", false) => { + let Some(n) = node.as_mut() else { + return fail("item-not-found"); + }; + let Some(sub) = a.attribute("jid") else { + return fail("bad-request"); + }; + if bare(sub) != actor { + return fail("forbidden"); + } + let mut subs = tab(n, "subscribers"); + let Some(current) = subs.get(sub).cloned() else { + return fail("item-not-found"); + }; + if typ == "get" { + let deliver = current.get("pubsub#deliver") != Some(&V::Bool(false)); + payload = format!( + "http://jabber.org/protocol/pubsub#subscribe_options{}", + escape(&node_name), + escape(sub), + if deliver { "1" } else { "0" } + ); + } else { + let opts = match subscription_form(a) { + Ok(v) => v, + Err(e) => return fail(e), + }; + subs.set(sub, opts); + n.set("subscribers", subs); + changed = true; + } + } + ("set", "retract", false) => { + if !node_owner { + return fail("forbidden"); + } + if node.is_none() { + return fail("item-not-found"); + } + let Some(id) = a + .children() + .find(|n| n.is_element()) + .and_then(|n| n.attribute("id")) + else { + return fail("bad-request"); + }; + delete_items(root, host, user, &node_name, Some(id))?; + if matches!(a.attribute("notify"), Some("1" | "true")) { + notify = Some(("retract".into(), format!("", escape(id)))) + } + } + ("set", "purge", true) | ("set", "delete", true) => { + if node.is_none() { + return fail("item-not-found"); + } + delete_items(root, host, user, &node_name, None)?; + notify = Some((action.into(), String::new())); + if action == "delete" { + changed = true; + } + } + _ => return fail("feature-not-implemented"), + } + } + let wrapped = if payload.is_empty() { + String::new() + } else { + format!( + "{payload}", + if owner_ns { OWNER } else { NS } + ) + }; + let mut result = ModuleResult::reply(iq_result(iq, &wrapped)); + if let (Some((kind, item)), Some(n)) = (notify, node.as_ref()) { + let c = tab(n, "config"); + if c.get(&format!("notify_{kind}")) != Some(&V::Bool(false)) { + // Check notify_retract before mapping the event to its wire container. + let kind = if kind == "retract" { "items" } else { &kind }; + let mut recipients = tab(n, "subscribers"); + if pep { + for recipient in interested(root, owner, &node_name)? { + if recipients.get(&recipient).is_none() { + recipients.set(&recipient, V::Bool(true)); + } + } + } + for (j, opts) in recipients.entries() { + if opts.get("pubsub#deliver") == Some(&V::Bool(false)) { + continue; + } + if let Some(j) = j.as_str() + && allowed(root, host, user, owner, j, n)? + { + let item = notification_payload(&c, &item); + result.deliveries.push((j.into(),format!("<{} node='{}'>{}",escape(owner),escape(j),notification_type(&c, pep),kind,escape(&node_name),item,kind))) + } + } + } + } + if pep + && node_name == "urn:xmpp:bookmarks:1" + && typ == "set" + && matches!(action, "publish" | "retract" | "purge" | "delete") + && let Some(meta) = node.as_ref() + { + let entries = read_items(root, host, user, &node_name)?; + let items = entries + .iter() + .map(|e| (e.id.clone(), format!("{}", e.xml))) + .collect::>(); + let legacy = crate::account_modules::legacy_bookmark_storage(&items)?; + for recipient in interested(root, owner, "storage:bookmarks")? { + if allowed(root, host, user, owner, &recipient, meta)? { + let item = notification_payload( + &tab(meta, "config"), + &format!("{legacy}"), + ); + result.deliveries.push((recipient.clone(),format!("{item}",escape(owner),escape(&recipient)))); + } + } + } + if action == "subscribe" + && typ == "set" + && node + .as_ref() + .and_then(|n| n.get("subscribers")) + .and_then(|s| s.get(a.attribute("jid").unwrap_or(""))) + .and_then(|o| o.get("pubsub#deliver")) + != Some(&V::Bool(false)) + && let Some(n) = node.as_ref() + && text( + &tab(n, "config"), + "send_last_published_item", + "on_sub_and_presence", + ) != "never" + && let Some(last) = read_items(root, host, user, &node_name)?.last() + { + let to = a.attribute("jid").unwrap(); + let item = notification_payload( + &tab(n, "config"), + &item_xml(&tab(n, "config"), &last.id, &last.xml, &last.peer), + ); + result.deliveries.push((to.into(),format!("{item}",escape(owner),escape(to),notification_type(&tab(n, "config"), pep),escape(&node_name)))); + } + if changed { + if action == "delete" { + EPHEMERAL + .lock() + .unwrap() + .remove(&ephemeral_key(root, host, user, &node_name)); + node = None + } + if pep { + nodes.set(&node_name, node.clone().unwrap_or(V::Nil)); + storage::save(root, host, user, "pep", &nodes)? + } else if let Some(n) = node { + storage::save(root, host, Some(&node_name), "pubsub_nodes", &n)? + } else { + let path = storage::path(root, host, Some(&node_name), "pubsub_nodes"); + if path.exists() { + std::fs::remove_file(path)? + } + } + } + Ok(Some(result)) +} +// Native mod_pep uses headline messages, while mod_pubsub honors this option. +fn notification_type(config: &V, pep: bool) -> &str { + if !pep && text(config, "notification_type", "headline") == "normal" { + "normal" + } else { + "headline" + } +} +// Native handlers expose item publisher JIDs only for itemreply=publisher; +// owner and none both keep them private. Publishers are stored bare in Entry::peer. +fn item_xml(config: &V, id: &str, payload: &str, publisher: &str) -> String { + let publisher = if text(config, "itemreply", "none") == "publisher" && !publisher.is_empty() { + format!(" publisher='{}'", escape(bare(publisher))) + } else { + String::new() + }; + format!("{payload}", escape(id)) +} +// Apply the same notification policy to live events and last-item replays. +fn notification_payload(config: &V, item: &str) -> String { + if config.get("include_payload") == Some(&V::Bool(false)) + && let Ok(doc) = roxmltree::Document::parse(item) + && doc.root_element().tag_name().name() == "item" + { + let item = doc.root_element(); + let publisher = item + .attribute("publisher") + .map(|j| format!(" publisher='{}'", escape(j))) + .unwrap_or_default(); + return format!( + "", + escape(item.attribute("id").unwrap_or("")) + ); + } + item.to_owned() +} + +fn store(user: Option<&str>, node: &str) -> String { + format!("{}_{}", if user.is_some() { "pep" } else { "pubsub" }, node) +} +fn read_items( + root: &Path, + host: &str, + user: Option<&str>, + node: &str, +) -> Result> { + if let Some(entries) = EPHEMERAL + .lock() + .unwrap() + .get(&ephemeral_key(root, host, user, node)) + { + return Ok(entries.clone()); + } + if !persistent(root, host, user, node)? { + return Ok(vec![]); + } + crate::archive::load_store(root, host, user, &store(user, node)) +} +// Explicit storage coordinates match both PEP and component backends. +#[allow(clippy::too_many_arguments)] +fn write_item( + root: &Path, + host: &str, + user: Option<&str>, + node: &str, + id: &str, + xml: &str, + actor: &str, + max: usize, + persist: bool, +) -> Result<()> { + let mut entries = read_items(root, host, user, node)?; + entries.retain(|e| e.id != id); + entries.push(crate::archive::Entry { + id: id.into(), + when: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(), + peer: actor.into(), + xml: xml.into(), + }); + if entries.len() > max { + entries.drain(..entries.len() - max); + } + save_items(root, host, user, node, &entries, persist) +} +fn delete_items( + root: &Path, + host: &str, + user: Option<&str>, + node: &str, + id: Option<&str>, +) -> Result<()> { + let mut entries = read_items(root, host, user, node)?; + entries.retain(|e| id.is_some_and(|id| id != e.id)); + save_items( + root, + host, + user, + node, + &entries, + persistent(root, host, user, node)?, + ) +} + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::LazyLock; +#[derive(Default)] +struct Interests { + subscriptions: BTreeMap<(String, String, String), BTreeSet>, + pending: BTreeMap<(String, String, String), Vec>, +} +static INTERESTS: LazyLock> = LazyLock::new(|| Mutex::new(Interests::default())); +fn interested(root: &Path, owner: &str, node: &str) -> Result> { + let root_key = root.to_string_lossy(); + let candidates: Vec<_> = INTERESTS + .lock() + .unwrap() + .subscriptions + .iter() + .filter(|((r, o, _), nodes)| r == root_key.as_ref() && o == owner && nodes.contains(node)) + .map(|((_, _, j), _)| j.clone()) + .collect(); + let mut out = vec![]; + for jid in candidates { + if presence_authorized(root, owner, &jid)? { + out.push(jid) + } + } + Ok(out) +} +fn presence_authorized(root: &Path, owner: &str, recipient: &str) -> Result { + if bare(recipient) == owner { + return Ok(true); + } + let Some((user, host)) = owner.split_once('@') else { + return Ok(false); + }; + if crate::account_modules::is_blocked(root, host, user, recipient)? { + return Ok(false); + } + let roster = storage::load(root, host, Some(user), "roster")?.unwrap_or_default(); + Ok(matches!( + text(&tab(&roster, bare(recipient)), "subscription", ""), + "from" | "both" + )) +} +/// Call with the authenticated full JID, never a client-supplied from attribute. +pub fn on_presence( + root: &Path, + domain: &str, + jid: &str, + presence: roxmltree::Node<'_, '_>, +) -> Result { + if presence.attribute("to").is_some() + || presence + .attribute("type") + .is_some_and(|t| t != "unavailable" && t != "error") + { + return Ok(ModuleResult::default()); + } + if presence.attribute("type").is_none() + && !presence + .children() + .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))) + { + return Ok(ModuleResult::default()); + } + let root_key = root.to_string_lossy().into_owned(); + let mut state = INTERESTS.lock().unwrap(); + state + .subscriptions + .retain(|(r, _, j), _| r != &root_key || j != jid); + state + .pending + .retain(|(r, j, _), _| r != &root_key || j != jid); + if matches!(presence.attribute("type"), Some("unavailable" | "error")) { + return Ok(ModuleResult::default()); + } + if presence.attribute("type").is_some() || presence.attribute("to").is_some() { + return Ok(ModuleResult::default()); + } + if !presence + .children() + .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))) + { + return Ok(ModuleResult::default()); + } + let Some((user, host)) = bare(jid).split_once('@') else { + return Ok(ModuleResult::default()); + }; + if host != domain { + return Ok(ModuleResult::default()); + } + let mut owners = vec![bare(jid).to_owned()]; + let roster = storage::load(root, domain, Some(user), "roster")?.unwrap_or_default(); + for (k, v) in roster.entries() { + if matches!(text(v, "subscription", ""), "to" | "both") + && let Some(j) = k.as_str() + && j.rsplit_once('@').is_some_and(|(_, h)| h == domain) + { + owners.push(j.into()) + } + } + if state.pending.len() > 10000 || state.subscriptions.len() > 50000 { + return Ok(ModuleResult::default()); + } + let id = format!("pep-caps-{}", uuid::Uuid::new_v4()); + state + .pending + .insert((root_key, jid.into(), id.clone()), owners); + let query = format!( + "", + escape(domain), + escape(jid), + escape(&id) + ); + Ok(ModuleResult { + replies: vec![], + deliveries: vec![(jid.into(), query)], + }) +} +/// Consume only server-issued capability requests correlated to the authenticated resource. +pub fn on_disco_result( + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, +) -> Result> { + if !matches!(iq.attribute("type"), Some("result" | "error")) { + return Ok(None); + } + let root_key = root.to_string_lossy().into_owned(); + let key = ( + root_key.clone(), + jid.into(), + iq.attribute("id").unwrap_or("").into(), + ); + let owners = { INTERESTS.lock().unwrap().pending.remove(&key) }; + let Some(owners) = owners else { + return Ok(None); + }; + let mut result = ModuleResult::default(); + if iq.attribute("type") == Some("error") { + return Ok(Some(result)); + } + let Some(query) = iq + .children() + .find(|n| n.has_tag_name(("http://jabber.org/protocol/disco#info", "query"))) + else { + return Ok(Some(result)); + }; + let nodes: BTreeSet = query + .children() + .filter(|n| n.has_tag_name(("http://jabber.org/protocol/disco#info", "feature"))) + .filter_map(|n| n.attribute("var")) + .filter_map(|v| v.strip_suffix("+notify").map(str::to_owned)) + .take(1024) + .collect(); + let _lock = LOCK.lock().unwrap(); + for owner in owners { + let Some((user, host)) = owner.split_once('@') else { + continue; + }; + if host != domain { + continue; + } + if !presence_authorized(root, &owner, jid)? { + continue; + } + let metadata = storage::load(root, domain, Some(user), "pep")?.unwrap_or_default(); + let mut permitted = BTreeSet::new(); + for node in &nodes { + // Retain interests for nodes which are created after discovery; delivery rechecks ACLs. + permitted.insert(node.clone()); + if node == "storage:bookmarks" { + if let Some(meta) = metadata.get("urn:xmpp:bookmarks:1") + && allowed(root, domain, Some(user), &owner, jid, meta)? + { + let entries = read_items(root, domain, Some(user), "urn:xmpp:bookmarks:1")?; + let items = entries + .iter() + .map(|e| (e.id.clone(), format!("{}", e.xml))) + .collect::>(); + let legacy = crate::account_modules::legacy_bookmark_storage(&items)?; + let item = notification_payload( + &tab(meta, "config"), + &format!("{legacy}"), + ); + result.deliveries.push((jid.into(),format!("{item}",escape(&owner),escape(jid)))); + } + continue; + } + let Some(meta) = metadata.get(node) else { + continue; + }; + if !allowed(root, domain, Some(user), &owner, jid, meta)? { + continue; + } + if text( + &tab(meta, "config"), + "send_last_published_item", + "on_sub_and_presence", + ) != "on_sub_and_presence" + { + continue; + } + if let Some(entry) = read_items(root, domain, Some(user), node)?.last() { + let item = notification_payload( + &tab(meta, "config"), + &item_xml(&tab(meta, "config"), &entry.id, &entry.xml, &entry.peer), + ); + result.deliveries.push((jid.into(),format!("{item}",escape(&owner),escape(jid),escape(node)))); + } + } + INTERESTS + .lock() + .unwrap() + .subscriptions + .insert((root_key.clone(), owner, jid.into()), permitted); + } + Ok(Some(result)) +} + +/// Common features implemented by both the component and account PEP services. +pub fn features() -> String { + let mut s = format!(""); + for feature in [ + "access-open", + "access-presence", + "access-whitelist", + "access-roster", + "config-node", + "config-node-max", + "create-and-configure", + "create-nodes", + "delete-items", + "delete-nodes", + "item-ids", + "manage-subscriptions", + "modify-affiliations", + "multi-items", + "persistent-items", + "publish", + "publish-options", + "purge-nodes", + "retrieve-affiliations", + "retrieve-items", + "retrieve-subscriptions", + "subscribe", + "last-published", + ] { + s.push_str(&format!("")); + } + s +} + +/// Account capabilities backed by automatic creation and presence/caps handling. +/// Advertise these on the account service rather than on the server or component. +pub fn pep_features() -> String { + let mut s = features(); + for feature in ["auto-create", "auto-subscribe", "filtered-notifications"] { + s.push_str(&format!("")); + } + s +} + +fn item_limit(config: &V, maximum: usize) -> usize { + config + .get("max_items") + .and_then(|v| { + v.as_i64() + .and_then(|n| usize::try_from(n).ok()) + .or_else(|| { + v.as_str().and_then(|s| { + if s == "max" { + Some(maximum) + } else { + s.parse().ok() + } + }) + }) + }) + .unwrap_or(1) + .min(maximum) +} + +fn subscription_form(action: roxmltree::Node<'_, '_>) -> std::result::Result { + let Some(x) = action + .children() + .find(|n| n.has_tag_name(("jabber:x:data", "x"))) + else { + return Err("bad-request"); + }; + if x.attribute("type") != Some("submit") { + return Err("bad-request"); + } + let mut out = V::table(); + for f in x.children().filter(|n| n.is_element()) { + let Some(var) = f.attribute("var") else { + return Err("bad-request"); + }; + let value = f + .children() + .find(|n| n.is_element() && n.tag_name().name() == "value") + .and_then(|n| n.text()) + .unwrap_or(""); + if var == "FORM_TYPE" { + if value != "http://jabber.org/protocol/pubsub#subscribe_options" { + return Err("bad-request"); + } + continue; + } + if var != "pubsub#deliver" { + return Err("feature-not-implemented"); + } + out.set( + var, + V::Bool(match value { + "true" | "1" => true, + "false" | "0" => false, + _ => return Err("bad-request"), + }), + ); + } + Ok(out) +} + +type EphemeralKey = (String, String, Option, String); +static EPHEMERAL: LazyLock>>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); +fn ephemeral_key(root: &Path, host: &str, user: Option<&str>, node: &str) -> EphemeralKey { + ( + root.to_string_lossy().into_owned(), + host.into(), + user.map(str::to_owned), + node.into(), + ) +} +fn persistent(root: &Path, host: &str, user: Option<&str>, node: &str) -> Result { + let meta = if user.is_some() { + storage::load(root, host, user, "pep")?.and_then(|v| v.get(node).cloned()) + } else { + storage::load(root, host, Some(node), "pubsub_nodes")? + }; + Ok(meta + .and_then(|v| v.get("config").cloned()) + .and_then(|v| v.get("persist_items").cloned()) + != Some(V::Bool(false))) +} +fn save_items( + root: &Path, + host: &str, + user: Option<&str>, + node: &str, + entries: &[crate::archive::Entry], + persist: bool, +) -> Result<()> { + let key = ephemeral_key(root, host, user, node); + if persist { + crate::archive::save_store(root, host, user, &store(user, node), entries)?; + EPHEMERAL.lock().unwrap().remove(&key); + } else { + let mut cache = EPHEMERAL.lock().unwrap(); + anyhow::ensure!( + cache.contains_key(&key) || cache.len() < 10000, + "ephemeral node limit" + ); + let total = entries.iter().map(|e| e.xml.len()).sum::(); + anyhow::ensure!(total <= 16 * 1024 * 1024, "ephemeral payload limit"); + let existing = cache + .iter() + .filter(|(k, _)| *k != &key) + .flat_map(|(_, items)| items) + .map(|e| e.xml.len()) + .sum::(); + anyhow::ensure!( + existing.saturating_add(total) <= 64 * 1024 * 1024, + "ephemeral global payload limit" + ); + cache.insert(key, entries.to_vec()); + crate::archive::save_store(root, host, user, &store(user, node), &[])?; + } + Ok(()) +} + +/// Federated presence callback. Caller must authenticate `verified_from` against the s2s stream domain. +/// Only the addressed account's interests are changed; presence for another contact is independent. +pub fn on_remote_presence( + root: &Path, + domain: &str, + verified_from: &str, + presence: roxmltree::Node<'_, '_>, +) -> Result { + let Some(owner) = presence.attribute("to").map(bare) else { + return Ok(ModuleResult::default()); + }; + let Some((user, host)) = owner.split_once('@') else { + return Ok(ModuleResult::default()); + }; + let Some((_, remote_host)) = bare(verified_from).split_once('@') else { + return Ok(ModuleResult::default()); + }; + if host != domain + || remote_host == domain + || presence + .attribute("from") + .is_some_and(|f| f != verified_from) + { + return Ok(ModuleResult::default()); + } + let unavailable = matches!(presence.attribute("type"), Some("unavailable" | "error")); + if !verified_from.contains('/') && !unavailable { + return Ok(ModuleResult::default()); + } + if presence.attribute("type").is_some() && !unavailable { + return Ok(ModuleResult::default()); + } + let has_caps = presence + .children() + .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))); + if !unavailable && !has_caps { + return Ok(ModuleResult::default()); + } + let root_key = root.to_string_lossy().into_owned(); + { + let mut state = INTERESTS.lock().unwrap(); + state.subscriptions.retain(|(r, o, j), _| { + !(r == &root_key + && o == owner + && (j == verified_from + || (!verified_from.contains('/') && bare(j) == verified_from))) + }); + state.pending.retain(|(r, j, _), owners| { + if r == &root_key + && (j == verified_from + || (!verified_from.contains('/') && bare(j) == verified_from)) + { + owners.retain(|o| o != owner) + } + !owners.is_empty() + }); + } + if unavailable + || !presence_authorized(root, owner, verified_from)? + || storage::load(root, domain, Some(user), "accounts")?.is_none() + { + return Ok(ModuleResult::default()); + } + let id = format!("pep-caps-{}", uuid::Uuid::new_v4()); + let mut state = INTERESTS.lock().unwrap(); + if state.pending.len() >= 10000 || state.subscriptions.len() >= 50000 { + return Ok(ModuleResult::default()); + } + state.pending.insert( + (root_key, verified_from.into(), id.clone()), + vec![owner.into()], + ); + Ok(ModuleResult { + replies: vec![], + deliveries: vec![( + verified_from.into(), + format!( + "", + escape(owner), + escape(verified_from), + escape(&id) + ), + )], + }) +} diff --git a/src/push.rs b/src/push.rs new file mode 100644 index 0000000..34bdfba --- /dev/null +++ b/src/push.rs @@ -0,0 +1,451 @@ +//! XEP-0357 registrations and notifications in Prosody's cloud_notify store. +use crate::{ + config::Config, + protocol::{self, ModuleResult}, + storage::{self, LuaValue as V}, + wire::escape, +}; +use anyhow::{Context, Result, ensure}; +use std::{ + collections::BTreeMap, + path::Path, + time::{Duration, Instant}, +}; +fn text(v: Option<&V>) -> Option { + match v { + Some(V::String(b)) => String::from_utf8(b.clone()).ok(), + _ => None, + } +} +fn s(v: &str) -> V { + V::String(v.as_bytes().to_vec()) +} +// Prosody get_child_text() selects direct children in the stanza's namespace +// and rejects elements containing child tags, rather than flattening extensions. +fn stanza_child_text(stanza: roxmltree::Node<'_, '_>, name: &str) -> Option { + let child = stanza.children().find(|n| { + n.is_element() + && n.tag_name().name() == name + && n.tag_name().namespace() == stanza.tag_name().namespace() + })?; + if child.children().any(|n| n.is_element()) { + return None; + } + Some( + child + .children() + .filter(|n| n.is_text()) + .filter_map(|n| n.text()) + .collect(), + ) +} +#[derive(Default)] +pub struct Push { + pending: BTreeMap, + errors: BTreeMap<(String, String), u64>, + removed: Vec<(String, String)>, +} +impl Push { + /// Registration removals which must be reflected in live SM session watchdogs. + pub fn take_removed(&mut self) -> Vec<(String, String)> { + std::mem::take(&mut self.removed) + } + #[allow(clippy::too_many_arguments)] + pub fn notify_queue( + &mut self, + root: &Path, + domain: &str, + user: &str, + messages: &[String], + config: &Config, + identifier: &str, + ) -> Result { + let mut result = QueueNotification::default(); + let mut sent = [false; 2]; + let equal = config.option(domain, "push_notification_important_body") + == Some(&serde_json::Value::Bool(false)); + for xml in messages { + let Ok(doc) = roxmltree::Document::parse(xml) else { + continue; + }; + let stanza = doc.root_element(); + let important = is_important(stanza); + let index = usize::from(important && !equal); + if sent[index] { + continue; + } + let deliveries = + self.notify_for_identifier(root, domain, user, stanza, config, Some(identifier))?; + if !deliveries.is_empty() { + sent[index] = true; + result.reset_watchdog |= important || equal; + result.deliveries.extend(deliveries); + } + if sent[0] && sent[1] { + break; + } + } + Ok(result) + } + + pub fn handle( + &mut self, + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, + max_devices: usize, + ) -> Result> { + self.handle_with_client(root, domain, jid, iq, max_devices, None) + } + #[allow(clippy::too_many_arguments)] + pub fn handle_with_client( + &mut self, + root: &Path, + domain: &str, + jid: &str, + iq: roxmltree::Node<'_, '_>, + max_devices: usize, + client_id: Option<&str>, + ) -> Result> { + let Some(q) = iq.first_element_child() else { + return Ok(None); + }; + if q.tag_name().namespace() != Some("urn:xmpp:push:0") { + return Ok(None); + } + let owner = protocol::bare(jid); + let (user, h) = owner.split_once('@').context("invalid account")?; + if h != domain + || iq.attribute("type") != Some("set") + || iq + .attribute("to") + .is_some_and(|to| to != domain && protocol::bare(to) != owner) + { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + } + let Some(service) = q.attribute("jid").filter(|v| !v.is_empty() && *v != jid) else { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "bad-request", + )))); + }; + let node = q.attribute("node"); + if !matches!(q.tag_name().name(), "enable" | "disable") { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "bad-request", + )))); + } + storage::update(root, domain, Some(user), "cloud_notify", |table| { + if q.tag_name().name() == "enable" { + let key = format!("{service}<{}", node.unwrap_or("")); + let mut record = V::default(); + record.set("jid", s(service)); + if let Some(node) = node { + record.set("node", s(node)); + } + if let Some(p) = q.attribute("include_payload") { + record.set("include_payload", s(p)); + } + record.set("timestamp", V::Integer(chrono::Utc::now().timestamp())); + if let Some(client_id) = client_id { + record.set("client_id", s(client_id)); + } else { + record.set( + "resource", + s(jid.split_once('/').map(|(_, r)| r).unwrap_or("")), + ); + } + if let Some(language) = + iq.attribute(("http://www.w3.org/XML/1998/namespace", "lang")) + { + record.set("language", s(language)); + } + if let Some(options) = q + .children() + .find(|n| n.is_element() && n.tag_name().namespace() == Some("jabber:x:data")) + { + record.set("options", storage::stanza_value(options)); + } + table.set(&key, record); + if let V::Table(entries) = table { + while entries.len() > max_devices.max(1) { + let i = entries + .iter() + .enumerate() + .min_by_key(|(_, (_, v))| match v.get("timestamp") { + Some(V::Integer(n)) => *n, + _ => 0, + }) + .map(|(i, _)| i) + .unwrap(); + entries.remove(i); + } + } + } else if let V::Table(entries) = table { + entries.retain(|(_, v)| { + !(text(v.get("jid")).as_deref() == Some(service) + && node.is_none_or(|n| text(v.get("node")).as_deref() == Some(n))) + }); + } + Ok(()) + })?; + // A fresh registration must not inherit failures or responses for its previous incarnation. + self.pending.retain(|_, (pending_user, key, _, _)| { + !(pending_user == user + && (key == &format!("{service}<{}", node.unwrap_or("")) + || (q.tag_name().name() == "disable" + && node.is_none() + && key.split_once('<').is_some_and(|(s, _)| s == service)))) + }); + self.errors.retain(|(pending_user, key), _| { + !(pending_user == user + && (key == &format!("{service}<{}", node.unwrap_or("")) + || (q.tag_name().name() == "disable" + && node.is_none() + && key.split_once('<').is_some_and(|(s, _)| s == service)))) + }); + Ok(Some(ModuleResult::reply(protocol::iq_result(iq, "")))) + } + pub fn notify( + &mut self, + root: &Path, + domain: &str, + user: &str, + message: roxmltree::Node<'_, '_>, + config: &Config, + ) -> Result> { + self.notify_for_identifier(root, domain, user, message, config, None) + } + #[allow(clippy::too_many_arguments)] + pub fn notify_for_identifier( + &mut self, + root: &Path, + domain: &str, + user: &str, + message: roxmltree::Node<'_, '_>, + config: &Config, + only: Option<&str>, + ) -> Result> { + self.pending + .retain(|_, (_, _, _, at)| at.elapsed() < Duration::from_secs(120)); + ensure!(self.pending.len() < 8192, "too many pending pushes"); + let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default(); + let V::Table(entries) = table else { + return Ok(vec![]); + }; + let body = stanza_child_text(message, "body"); + let include_body = config + .option(domain, "push_notification_with_body") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let include_sender = config + .option(domain, "push_notification_with_sender") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut outputs = vec![]; + for (key, record) in entries { + let Some(service) = text(record.get("jid")) else { + continue; + }; + let node = text(record.get("node")); + let Some(identifier) = text(Some(&key)) else { + continue; + }; + if only.is_some_and(|only| only != identifier) { + continue; + } + let id = uuid::Uuid::new_v4().to_string(); + let mut form="urn:xmpp:push:summary1".to_owned(); + if include_sender { + form.push_str(&format!( + "{}", + escape(message.attribute("from").unwrap_or("")) + )); + } + let summary = if include_body { + body.as_deref() + } else if is_important(message) { + match config.option(domain, "push_notification_important_body") { + Some(serde_json::Value::Bool(false)) => None, + Some(serde_json::Value::String(s)) => Some(s.as_str()), + _ => Some("New Message!"), + } + } else { + None + }; + if let Some(body) = summary { + form.push_str(&format!( + "{}", + escape(body) + )); + } + form.push_str(""); + let node_attr = node + .map(|n| format!(" node='{}'", escape(&n))) + .unwrap_or_default(); + let mut payload = format!( + "{form}", + escape(domain), + escape(&service) + ); + if let Some(options) = record.get("options") { + payload.push_str(&format!( + "{}", + storage::stanza_xml(options)? + )); + } + payload.push_str(""); + self.pending.insert( + id, + (user.into(), identifier, service.clone(), Instant::now()), + ); + outputs.push((service, payload)); + } + Ok(outputs) + } + pub fn result( + &mut self, + root: &Path, + domain: &str, + iq: roxmltree::Node<'_, '_>, + max_errors: u64, + ) -> Result { + let Some((user, key, service, sent_at)) = + self.pending.get(iq.attribute("id").unwrap_or("")).cloned() + else { + return Ok(false); + }; + if sent_at.elapsed() >= Duration::from_secs(120) { + self.pending.remove(iq.attribute("id").unwrap_or("")); + return Ok(false); + } + if iq.attribute("from") != Some(service.as_str()) { + return Ok(false); + } + if !matches!(iq.attribute("type"), Some("result" | "error")) { + return Ok(false); + } + self.pending.remove(iq.attribute("id").unwrap_or("")); + let error_key = (user.clone(), key.clone()); + if iq.attribute("type") == Some("result") { + self.errors.remove(&error_key); + return Ok(true); + } + if iq.attribute("type") == Some("error") + && !iq.children().any(|n| { + n.is_element() + && n.tag_name().name() == "error" + && n.attribute("type") == Some("wait") + }) + { + let count = self.errors.entry(error_key.clone()).or_default(); + *count += 1; + if *count >= max_errors { + storage::update(root, domain, Some(&user), "cloud_notify", |table| { + table.set(&key, V::Nil); + Ok(()) + })?; + self.errors.remove(&error_key); + self.removed.push((user.clone(), key.clone())); + } + } + Ok(true) + } +} + +/// Locate the most recent registration for a stable client identity or a legacy resource. +/// A resource match never overrides a stored stable identity belonging to another device. +pub fn client_device( + root: &Path, + domain: &str, + user: &str, + client_id: Option<&str>, + resource: &str, +) -> Result> { + let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default(); + let V::Table(entries) = table else { + return Ok(None); + }; + Ok(entries + .into_iter() + .filter_map(|(key, record)| { + let matches = match text(record.get("client_id")) { + Some(stored) => client_id == Some(stored.as_str()), + None => text(record.get("resource")).as_deref() == Some(resource), + }; + if !matches || text(record.get("jid")).is_none() { + return None; + } + let timestamp = match record.get("timestamp") { + Some(V::Integer(n)) => *n, + _ => 0, + }; + text(Some(&key)).map(|key| (timestamp, key)) + }) + .max() + .map(|(_, key)| key)) +} + +#[derive(Default, Debug)] +pub struct QueueNotification { + pub deliveries: Vec<(String, String)>, + pub reset_watchdog: bool, +} +/// Prosody cloud_notify importance classification, including incoming carbon wrappers. +pub fn is_important(message: roxmltree::Node<'_, '_>) -> bool { + if message.tag_name().name() != "message" { + return false; + } + let mut inner = message; + if let Some(carbon) = message.children().find(|n| { + n.is_element() + && n.tag_name().namespace() == Some("urn:xmpp:carbons:2") + && matches!(n.tag_name().name(), "sent" | "received") + }) { + if carbon.tag_name().name() == "sent" { + return false; + } + if let Some(stanza) = carbon + .children() + .find(|n| n.has_tag_name(("urn:xmpp:forward:0", "forwarded"))) + .and_then(|f| { + f.children() + .find(|n| n.has_tag_name(("jabber:client", "message"))) + }) + { + inner = stanza; + } + } + if inner.attribute("type") == Some("headline") { + return false; + } + if inner.children().any(|n| { + matches!( + (n.tag_name().namespace(), n.tag_name().name()), + ( + Some("eu.siacs.conversations.axolotl" | "urn:xmpp:omemo:0" | "urn:xmpp:omemo:2"), + "encrypted" + ) | (Some("jabber:x:encrypted"), "x") + | (Some("urn:xmpp:openpgp:0"), "openpgp") + | (Some("urn:xmpp:jingle-message:0"), "propose") + ) + }) { + return true; + } + if inner.attribute("type") == Some("groupchat") && stanza_child_text(inner, "subject").is_some() + { + return false; + } + stanza_child_text(inner, "body").is_some_and(|body| !body.is_empty()) +} +/// Native first-important-push rule; caller invokes only after a push was emitted. +pub fn resets_watchdog(message: roxmltree::Node<'_, '_>, config: &Config, domain: &str) -> bool { + is_important(message) + || config.option(domain, "push_notification_important_body") + == Some(&serde_json::Value::Bool(false)) +} diff --git a/src/sasl.rs b/src/sasl.rs new file mode 100644 index 0000000..8d2767f --- /dev/null +++ b/src/sasl.rs @@ -0,0 +1,467 @@ +//! Transport-independent SCRAM exchange. Inputs/outputs are decoded SASL payloads. +//! The transport must enforce TLS and advertise only mechanisms enabled for the account store. +use crate::auth::ScramCredentials; +use anyhow::{Result, bail, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +#[derive(Clone, Debug)] +pub struct ChannelBinding { + pub name: String, + pub data: Vec, +} +#[derive(Clone, Debug, Default)] +pub struct Options { + pub channel_binding: Option, + /// Exact advertised mechanism list. None disables Prosody's SSDP extension. + pub ssdp_mechanisms: Option>, + /// Exact supported CB types. None means the profile has no CB table. + pub ssdp_channel_bindings: Option>, +} +#[derive(Clone, Copy)] +enum Hash { + Sha1, + Sha256, +} +impl Hash { + fn digest(self, bytes: &[u8]) -> Vec { + match self { + Self::Sha1 => Sha1::digest(bytes).to_vec(), + Self::Sha256 => Sha256::digest(bytes).to_vec(), + } + } + fn mac(self, key: &[u8], bytes: &[u8]) -> Vec { + macro_rules! mac { + ($t:ty) => {{ + let mut m = Hmac::<$t>::new_from_slice(key).expect("HMAC accepts any key length"); + m.update(bytes); + m.finalize().into_bytes().to_vec() + }}; + } + match self { + Self::Sha1 => mac!(Sha1), + Self::Sha256 => mac!(Sha256), + } + } + fn len(self) -> usize { + match self { + Self::Sha1 => 20, + Self::Sha256 => 32, + } + } +} +pub struct Scram { + username: String, + hash: Hash, + credentials: ScramCredentials, + first_bare: String, + server_first: String, + nonce: String, + binding: Vec, + eligible: bool, +} +#[derive(Debug)] +pub struct ScramSuccess { + pub username: String, + pub server_final: String, +} +fn unescape_name(value: &str) -> Result { + let mut out = String::new(); + let mut chars = value.chars(); + while let Some(c) = chars.next() { + if c != '=' { + out.push(c); + continue; + } + match (chars.next(), chars.next()) { + (Some('2'), Some('C')) => out.push(','), + (Some('3'), Some('D')) => out.push('='), + _ => bail!("malformed-request"), + } + } + Ok(out) +} +fn attributes(input: &str) -> Result> { + let mut out = Vec::new(); + for part in input.split(',') { + let (key, value) = part + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("malformed-request"))?; + ensure!( + key.len() == 1 + && key.as_bytes()[0].is_ascii_alphabetic() + && !out.iter().any(|(k, _)| *k == key), + "malformed-request" + ); + ensure!(key != "m", "extensions-not-supported"); + out.push((key, value)); + } + Ok(out) +} +impl Scram { + /// `lookup` receives the prepared username and the selected password hash name. + pub fn start( + mechanism: &str, + initial: &[u8], + domain: &str, + lookup: F, + options: Options, + ) -> Result<(Self, String)> + where + F: FnOnce(&str, &str) -> Result>, + { + Self::start_nonce( + mechanism, + initial, + domain, + lookup, + options, + &uuid::Uuid::new_v4().simple().to_string(), + ) + } + fn start_nonce( + mechanism: &str, + initial: &[u8], + domain: &str, + lookup: F, + options: Options, + server_nonce: &str, + ) -> Result<(Self, String)> + where + F: FnOnce(&str, &str) -> Result>, + { + ensure!(initial.len() <= 8192, "malformed-request"); + let (hash, hash_name, plus) = match mechanism { + "SCRAM-SHA-1" => (Hash::Sha1, "SHA-1", false), + "SCRAM-SHA-256" => (Hash::Sha256, "SHA-256", false), + "SCRAM-SHA-1-PLUS" => (Hash::Sha1, "SHA-1", true), + "SCRAM-SHA-256-PLUS" => (Hash::Sha256, "SHA-256", true), + _ => bail!("invalid-mechanism"), + }; + let input = std::str::from_utf8(initial)?; + let mut parts = input.splitn(3, ','); + let flag = parts.next().unwrap_or(""); + let authz = parts + .next() + .ok_or_else(|| anyhow::anyhow!("malformed-request"))?; + let bare = parts + .next() + .ok_or_else(|| anyhow::anyhow!("malformed-request"))?; + let gs2 = format!("{flag},{authz},"); + let mut binding = gs2.as_bytes().to_vec(); + if plus { + let cb = options + .channel_binding + .as_ref() + .ok_or_else(|| anyhow::anyhow!("channel-binding-not-supported"))?; + ensure!( + flag == format!("p={}", cb.name) && !cb.data.is_empty(), + "unsupported-channel-binding-type" + ); + binding.extend_from_slice(&cb.data); + } else { + ensure!(flag == "n" || flag == "y", "malformed-request"); + ensure!( + flag != "y" || options.channel_binding.is_none(), + "server-does-support-channel-binding" + ); + } + let attrs = attributes(bare)?; + ensure!( + attrs.len() >= 2 && attrs[0].0 == "n" && attrs[1].0 == "r", + "malformed-request" + ); + let decoded = unescape_name(attrs[0].1)?; + let prepared = stringprep::saslprep(&decoded) + .map_err(|_| anyhow::anyhow!("invalid-username-encoding"))?; + let username = stringprep::nodeprep(&prepared) + .map_err(|_| anyhow::anyhow!("invalid-username-encoding"))? + .into_owned(); + ensure!( + !username.is_empty() && username.len() <= 1023, + "invalid-username-encoding" + ); + if !authz.is_empty() { + let az = authz + .strip_prefix("a=") + .ok_or_else(|| anyhow::anyhow!("malformed-request"))?; + let az = unescape_name(az)?; + ensure!(az == format!("{username}@{domain}"), "invalid-authzid"); + } + let nonce = attrs[1].1; + ensure!( + !nonce.is_empty() + && nonce.len() <= 1024 + && nonce + .bytes() + .all(|b| (0x21..=0x7e).contains(&b) && b != b','), + "malformed-request" + ); + let record = lookup(&username, hash_name)?; + let eligible = record.is_some(); + // Return an ordinary challenge for unknown/disabled accounts. Never accept its proof. + let credentials = record.unwrap_or_else(|| ScramCredentials { + salt: uuid::Uuid::new_v4().to_string().into_bytes(), + iterations: 10000, + stored_key: hash.digest(uuid::Uuid::new_v4().as_bytes()), + server_key: hash.digest(uuid::Uuid::new_v4().as_bytes()), + }); + ensure!( + credentials.stored_key.len() == hash.len() + && credentials.server_key.len() == hash.len(), + "invalid-credentials" + ); + ensure!( + credentials.iterations > 0 + && credentials.iterations <= 1_000_000 + && !credentials.salt.is_empty(), + "invalid-credentials" + ); + let nonce = format!("{nonce}{server_nonce}"); + let mut challenge = format!( + "r={nonce},s={},i={}", + B64.encode(&credentials.salt), + credentials.iterations + ); + if let Some(mut mechs) = options.ssdp_mechanisms { + mechs.sort(); + mechs.dedup(); + let mut text = mechs.join("\u{1e}"); + if let Some(mut cb) = options.ssdp_channel_bindings { + cb.sort(); + cb.dedup(); + text.push('\u{1f}'); + text.push_str(&cb.join("\u{1e}")); + } + challenge.push_str(&format!(",h={}", B64.encode(hash.digest(text.as_bytes())))); + } + Ok(( + Self { + username, + hash, + credentials, + first_bare: bare.into(), + server_first: challenge.clone(), + nonce, + binding, + eligible, + }, + challenge, + )) + } + /// Consumes the exchange so a proof can never be submitted twice. + pub fn finish(self, response: &[u8]) -> Result { + ensure!(response.len() <= 8192, "malformed-request"); + let response = std::str::from_utf8(response)?; + let attrs = attributes(response)?; + ensure!( + attrs.len() >= 3 + && attrs[0].0 == "c" + && attrs[1].0 == "r" + && attrs.last().unwrap().0 == "p", + "malformed-request" + ); + let binding = B64.decode(attrs[0].1)?; + ensure!( + bool::from(binding.ct_eq(&self.binding)), + "channel-bindings-dont-match" + ); + ensure!(attrs[1].1 == self.nonce, "nonce-mismatch"); + let proof = B64.decode(attrs.last().unwrap().1)?; + ensure!(proof.len() == self.hash.len(), "malformed-request"); + let without = response + .rsplit_once(",p=") + .ok_or_else(|| anyhow::anyhow!("malformed-request"))? + .0; + let auth_message = format!("{},{},{}", self.first_bare, self.server_first, without); + let signature = self + .hash + .mac(&self.credentials.stored_key, auth_message.as_bytes()); + let client_key: Vec = proof.iter().zip(signature).map(|(a, b)| a ^ b).collect(); + let recovered = self.hash.digest(&client_key); + ensure!( + bool::from(recovered.ct_eq(&self.credentials.stored_key)) && self.eligible, + "invalid-client-proof" + ); + let server_sig = self + .hash + .mac(&self.credentials.server_key, auth_message.as_bytes()); + Ok(ScramSuccess { + username: self.username, + server_final: format!("v={}", B64.encode(server_sig)), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn credentials(hash: Hash, password: &str, salt: &[u8], iterations: u32) -> ScramCredentials { + let mut salted = vec![0; hash.len()]; + match hash { + Hash::Sha1 => { + pbkdf2::pbkdf2_hmac::(password.as_bytes(), salt, iterations, &mut salted) + } + Hash::Sha256 => { + pbkdf2::pbkdf2_hmac::(password.as_bytes(), salt, iterations, &mut salted) + } + } + ScramCredentials { + salt: salt.to_vec(), + iterations, + stored_key: hash.digest(&hash.mac(&salted, b"Client Key")), + server_key: hash.mac(&salted, b"Server Key"), + } + } + #[test] + fn rfc5802_sha1_vector() { + let salt = B64.decode("QSXCR+Q6sek8bf92").unwrap(); + let c = credentials(Hash::Sha1, "pencil", &salt, 4096); + let (exchange, challenge) = Scram::start_nonce( + "SCRAM-SHA-1", + b"n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL", + "example.com", + |_, _| Ok(Some(c)), + Options::default(), + "3rfcNHYJY1ZVvWVs7j", + ) + .unwrap(); + assert_eq!( + challenge, + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096" + ); + let success=exchange.finish(b"c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=").unwrap(); + assert_eq!(success.username, "user"); + assert_eq!(success.server_final, "v=rmF9pqV8S7suAoZWja4dJRkFsKQ="); + } + #[test] + fn sha256_independent_python_vector() { + // Generated independently with Python hashlib.pbkdf2_hmac/hmac, password pencil. + let c = credentials(Hash::Sha256, "pencil", b"test salt", 4096); + let (exchange, challenge) = Scram::start_nonce( + "SCRAM-SHA-256", + b"n,,n=user,r=clientnonce", + "example.com", + |_, _| Ok(Some(c)), + Options::default(), + "servernonce", + ) + .unwrap(); + assert_eq!(challenge, "r=clientnonceservernonce,s=dGVzdCBzYWx0,i=4096"); + let success = exchange + .finish( + b"c=biws,r=clientnonceservernonce,p=azcZj1W9+zOyKcsNxPTVP8SgNvdyoJICB///1hzW17Y=", + ) + .unwrap(); + assert_eq!( + success.server_final, + "v=sfQ9BDV6Z/TTbpW9vXGpn0fUZ0N9+zgUnhGHEylidY4=" + ); + } + #[test] + fn reject_malformed_and_downgrade() { + for initial in [ + "n,,m=required,n=user,r=nonce", + "n,,n=user,n=other,r=nonce", + "n,,n=user,r=", + "n,a=other@example.com,n=user,r=nonce", + "p=tls-exporter,,n=user,r=nonce", + "n,,n=user=ZZ,r=nonce", + ] { + assert!( + Scram::start( + "SCRAM-SHA-256", + initial.as_bytes(), + "example.com", + |_, _| Ok(None), + Options::default() + ) + .is_err(), + "{initial}" + ); + } + assert!( + Scram::start( + "SCRAM-SHA-256", + b"y,,n=user,r=nonce", + "example.com", + |_, _| Ok(None), + Options { + channel_binding: Some(ChannelBinding { + name: "tls-exporter".into(), + data: vec![1; 32] + }), + ..Options::default() + } + ) + .is_err() + ); + } + #[test] + fn wrong_proof_nonce_binding_and_duplicates() { + for response in [ + "c=biws,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "c=biws,r=wrong,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "c=eSws,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "c=biws,r=nonceserver,r=nonceserver,p=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ] { + let c = credentials(Hash::Sha256, "password", b"salt", 4096); + let (state, _) = Scram::start_nonce( + "SCRAM-SHA-256", + b"n,,n=user,r=nonce", + "example.com", + |_, _| Ok(Some(c)), + Options::default(), + "server", + ) + .unwrap(); + assert!(state.finish(response.as_bytes()).is_err()); + } + } + #[test] + fn plus_channel_binding_and_ssdp_are_authenticated() { + let options = Options { + channel_binding: Some(ChannelBinding { + name: "tls-exporter".into(), + data: vec![7; 32], + }), + ssdp_mechanisms: Some(vec!["PLAIN".into(), "SCRAM-SHA-256-PLUS".into()]), + ssdp_channel_bindings: Some(vec!["tls-exporter".into()]), + }; + let c = credentials(Hash::Sha256, "pencil", b"salt", 4096); + let (state, challenge) = Scram::start_nonce( + "SCRAM-SHA-256-PLUS", + b"p=tls-exporter,,n=user,r=nonce", + "example.com", + |_, _| Ok(Some(c)), + options, + "server", + ) + .unwrap(); + let expected_hash = B64.encode(Sha256::digest( + b"PLAIN\x1eSCRAM-SHA-256-PLUS\x1ftls-exporter", + )); + assert!(challenge.ends_with(&format!(",h={expected_hash}"))); + let mut binding = b"p=tls-exporter,,".to_vec(); + binding.extend_from_slice(&[7; 32]); + let final_without = format!("c={},r=nonceserver", B64.encode(binding)); + let auth = format!("n=user,r=nonce,{challenge},{final_without}"); + let mut salted = [0; 32]; + pbkdf2::pbkdf2_hmac::(b"pencil", b"salt", 4096, &mut salted); + let client_key = Hash::Sha256.mac(&salted, b"Client Key"); + let signature = Hash::Sha256.mac(&Sha256::digest(&client_key), auth.as_bytes()); + let proof: Vec = client_key + .iter() + .zip(signature) + .map(|(a, b)| a ^ b) + .collect(); + assert!( + state + .finish(format!("{final_without},p={}", B64.encode(proof)).as_bytes()) + .is_ok() + ); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..4a3f84d --- /dev/null +++ b/src/server.rs @@ -0,0 +1,3787 @@ +use crate::{ + auth, + config::{self, Config}, + logging::{Level, Logger}, + protocol::{self, ModuleResult}, + sasl, + sessions::{Hub, SharedSession}, + wire::{Framer, escape}, +}; +use anyhow::{Context, Result, bail, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use std::{ + collections::BTreeMap, + fs, + io::BufReader, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Instant, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::{Semaphore, mpsc}, + time::{Duration, timeout}, +}; +use tokio_rustls::{TlsAcceptor, rustls::ServerConfig}; +const CLIENT: &str = "jabber:client"; +const SASL: &str = "urn:ietf:params:xml:ns:xmpp-sasl"; +const SASL2: &str = "urn:xmpp:sasl:2"; +const BIND2: &str = "urn:xmpp:bind:0"; +const BIND: &str = "urn:ietf:params:xml:ns:xmpp-bind"; +const STREAM: &str = "http://etherx.jabber.org/streams"; +const TLS: &str = "urn:ietf:params:xml:ns:xmpp-tls"; +#[derive(Clone)] +pub struct Runtime { + _data_guard: Arc, + domain: String, + data: PathBuf, + hash: String, + tls: TlsAcceptor, + logger: Logger, + tls_endpoint: Option, + config: Arc, + hub: Hub, + mucs: Arc>>, + uploads: Arc>, + started: Instant, + push: Arc>, + federation: Option, + stopping: Arc, +} +struct ConnectionLog { + logger: Logger, + id: String, + started: Instant, + phase: &'static str, +} +impl ConnectionLog { + fn new(rt: &Runtime, transport: &str, peer: &str) -> Self { + let ctx = Self { + logger: rt.logger.clone(), + id: uuid::Uuid::new_v4().to_string(), + started: Instant::now(), + phase: "stream-open", + }; + ctx.event( + Level::Info, + "connection-open", + &[ + ("transport", transport), + ("peer", peer), + ("host", &rt.domain), + ], + ); + ctx + } + fn event(&self, level: Level, event: &str, fields: &[(&str, &str)]) { + self.logger + .event(level, "c2s", Some(&self.id), event, fields); + } + fn finish(&self, result: &Result<()>) { + let elapsed = self.started.elapsed().as_millis().to_string(); + let reason = match result { + Ok(()) => "stream-complete", + Err(e) if e.is::() => "stream-complete", + Err(e) if e.to_string() == "connection closed" => "peer-disconnected", + Err(e) if e.to_string() == "authentication failed" => { + "authentication-attempts-exhausted" + } + Err(e) if e.downcast_ref::().is_some() => "timeout", + Err(e) if e.to_string() == "invalid stream header" => "invalid-stream-header", + Err(e) if e.to_string() == "TLS required" => "starttls-required", + Err(e) if e.to_string() == "binding required" => "resource-binding-required", + Err(e) if e.to_string() == "stanza too large" => "stanza-size-limit", + Err(e) if e.downcast_ref::().is_some() => "invalid-xml", + Err(_) if self.phase == "tls-handshake" => "tls-handshake-failed", + Err(_) => "protocol-or-transport-error", + }; + let io_kind = result + .as_ref() + .err() + .and_then(|e| e.downcast_ref::()) + .map(|e| format!("{:?}", e.kind())) + .unwrap_or_else(|| "none".into()); + self.event( + if result.is_err() && !matches!(reason, "peer-disconnected" | "stream-complete") { + Level::Warn + } else { + Level::Info + }, + "connection-closed", + &[ + ("phase", self.phase), + ("reason", reason), + ("duration_ms", &elapsed), + ("io_kind", &io_kind), + ], + ); + } +} +fn sasl_reason(error: &anyhow::Error) -> &'static str { + match error.to_string().as_str() { + "malformed-request" => "malformed-request", + "extensions-not-supported" => "extensions-not-supported", + "channel-binding-not-supported" => "channel-binding-not-supported", + "unsupported-channel-binding-type" => "unsupported-channel-binding-type", + "server-does-support-channel-binding" => "channel-binding-downgrade-detected", + "channel-bindings-dont-match" => "channel-binding-mismatch", + "invalid-username-encoding" => "invalid-username-encoding", + "invalid-authzid" => "invalid-authzid", + "invalid-credentials" | "invalid SCRAM credentials" => "account-hash-or-record-invalid", + "unsupported/incomplete account record" | "invalid account record" => { + "account-record-invalid" + } + "invalid-client-proof" => "invalid-client-proof", + "nonce-mismatch" => "nonce-mismatch", + "not-authorized" => "invalid-client-proof-or-account-unavailable", + _ => "invalid-exchange-or-account-store-error", + } +} +struct Cleanup { + runtime: Runtime, + token: String, + graceful: bool, +} +impl Drop for Cleanup { + fn drop(&mut self) { + for session in self.runtime.hub.all() { + let state = session.lock().unwrap(); + if state.connection == self.token { + self.runtime.logger.event( + Level::Info, + "smacks", + Some(&self.token), + "session-disconnected", + &[ + ("jid", &state.jid), + ( + "mode", + if !self.graceful && state.sm && state.resume_id.is_some() { + "hibernating" + } else { + "removed" + }, + ), + ("unacked", &state.pending.len().to_string()), + ], + ); + } + } + for jid in self.runtime.hub.disconnect(&self.token, self.graceful) { + self.runtime.disconnected(&jid); + } + if !self.graceful && self.runtime.enabled("cloud_notify") { + let mut queued = Vec::new(); + for session in self.runtime.hub.all() { + let s = session.lock().unwrap(); + if s.connection != self.token || s.sender.is_some() { + continue; + } + if let Some(identifier) = &s.push_identifier + && let Some(user) = self.runtime.owner(&s.jid) + { + queued.push(( + user, + identifier.clone(), + s.pending.iter().cloned().collect::>(), + )); + } + } + if !queued.is_empty() + && let Ok(handle) = tokio::runtime::Handle::try_current() + { + let rt = self.runtime.clone(); + handle.spawn(async move { + for (user, identifier, messages) in queued { + let result = rt.push.lock().unwrap().notify_queue( + &rt.data, + &rt.domain, + &user, + &messages, + &rt.config, + &identifier, + ); + let notifications = if let Ok(result) = result { + if result.reset_watchdog { + reset_push_watchdog(&rt, &user, &identifier); + } + result.deliveries + } else { + Vec::new() + }; + for (to, payload) in notifications { + let _ = route_delivery(&rt, &to, &payload).await; + } + } + }); + } + } + } +} +impl Runtime { + fn enabled(&self, m: &str) -> bool { + self.config + .modules(&self.domain) + .is_ok_and(|s| s.contains(m)) + } + fn disconnected(&self, jid: &str) { + if let Ok(d) = + roxmltree::Document::parse("") + { + let _ = crate::pubsub::on_presence(&self.data, &self.domain, jid, d.root_element()); + } + // Shutdown freezes MUC membership before transport teardown. Teardown + // must not erase temporary room checkpoints as if every user had left. + if self.stopping.load(std::sync::atomic::Ordering::Acquire) { + return; + } + let mut rooms = self.mucs.lock().unwrap(); + for muc in rooms.values_mut() { + let result = muc.disconnect(jid); + for (to, xml) in result.deliveries { + self.hub.deliver(&to, &xml); + } + } + } + fn bind_session( + &self, + full: &str, + token: &str, + sender: mpsc::Sender, + ) -> Result { + let policy = crate::sessions::ConflictPolicy::from_config( + self.config + .option(&self.domain, "conflict_resolve") + .and_then(|v| v.as_str()), + ); + let (session, replaced) = self.hub.bind_resolving( + full, + token, + sender, + Duration::from_secs(number( + &self.config, + &self.domain, + "smacks_hibernation_time", + 300, + )), + policy, + )?; + if let Some(old) = replaced { + let (jid, previous) = { + let state = old.lock().unwrap(); + (state.jid.clone(), state.connection.clone()) + }; + self.logger.event( + Level::Info, + "bind", + Some(token), + "resource-replaced", + &[ + ("jid", &jid), + ("previous_connection", &previous), + ("policy", &format!("{policy:?}")), + ], + ); + self.disconnected(&jid); + } + Ok(session) + } + fn owner(&self, jid: &str) -> Option { + let (user, domain) = protocol::bare(jid).split_once('@')?; + (domain == self.domain).then(|| user.to_owned()) + } +} +pub fn settings_config(path: &Path) -> Result<(Config, String)> { + let c = config::load(path)?; + let hosts: Vec<_> = c + .hosts + .iter() + .filter(|(_, h)| h.component.is_none()) + .collect(); + ensure!( + hosts.len() == 1, + "runtime requires exactly one VirtualHost (components allowed)" + ); + let d = hosts[0].0.clone(); + ensure!( + c.option(&d, "authentication").and_then(|v| v.as_str()) == Some("internal_hashed"), + "authentication must be internal_hashed" + ); + Ok((c, d)) +} +pub fn account_config(path: &Path) -> Result<(PathBuf, String, String)> { + let (c, d) = settings_config(path)?; + let data = c + .option(&d, "data_path") + .and_then(|v| v.as_str()) + .context("data_path required")?; + let hash = c + .option(&d, "password_hash") + .and_then(|v| v.as_str()) + .unwrap_or("SHA-1"); + ensure!( + matches!(hash, "SHA-1" | "SHA-256"), + "unsupported password_hash" + ); + Ok((data.into(), d.clone(), hash.into())) +} +fn ports(c: &Config, d: &str, key: &str) -> Vec { + c.option(d, key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_u64().map(|n| n as u16)) + .collect() + }) + .unwrap_or_default() +} +fn boolean(c: &Config, d: &str, k: &str, default: bool) -> bool { + c.option(d, k).and_then(|v| v.as_bool()).unwrap_or(default) +} +fn refresh_push_session(rt: &Runtime, session: &SharedSession) -> Result<()> { + let (jid, client_id) = { + let s = session.lock().unwrap(); + (s.jid.clone(), s.client_id.clone()) + }; + let identifier = if rt.enabled("cloud_notify") { + crate::push::client_device( + &rt.data, + &rt.domain, + &rt.owner(&jid).context("local account")?, + client_id.as_deref(), + jid.split_once('/').map(|(_, r)| r).unwrap_or(""), + )? + } else { + None + }; + let timeout = if identifier.is_some() { + number( + &rt.config, + &rt.domain, + "push_max_hibernation_timeout", + 72 * 3600, + ) + } else { + number(&rt.config, &rt.domain, "smacks_hibernation_time", 300) + }; + let mut s = session.lock().unwrap(); + s.push_identifier = identifier; + s.first_push_sent = false; + s.timeout = Duration::from_secs(timeout); + Ok(()) +} +fn message_pushes( + rt: &Runtime, + user: &str, + to: &str, + delivered: usize, + message: roxmltree::Node<'_, '_>, +) -> Result> { + if !rt.enabled("cloud_notify") || message.attribute("type") == Some("error") { + return Ok(vec![]); + } + if delivered == 0 { + return rt + .push + .lock() + .unwrap() + .notify(&rt.data, &rt.domain, user, message, &rt.config); + } + let mut identifiers = std::collections::BTreeSet::new(); + for session in rt.hub.message_targets(to) { + let s = session.lock().unwrap(); + if s.sender.is_none() + && let Some(id) = &s.push_identifier + { + identifiers.insert(id.clone()); + } + } + let mut notifications = Vec::new(); + for id in identifiers { + let deliveries = rt.push.lock().unwrap().notify_for_identifier( + &rt.data, + &rt.domain, + user, + message, + &rt.config, + Some(&id), + )?; + if !deliveries.is_empty() && crate::push::resets_watchdog(message, &rt.config, &rt.domain) { + reset_push_watchdog(rt, user, &id); + } + notifications.extend(deliveries); + } + Ok(notifications) +} +fn reset_push_watchdog(rt: &Runtime, user: &str, identifier: &str) { + let owner = format!("{user}@{}", rt.domain); + let timeout = Duration::from_secs(number( + &rt.config, + &rt.domain, + "smacks_hibernation_time", + 300, + )); + for session in rt.hub.all() { + let mut s = session.lock().unwrap(); + if s.owner == owner && s.push_identifier.as_deref() == Some(identifier) { + s.first_important_push(timeout); + } + } +} +fn remove_push_sessions(rt: &Runtime) { + let removed = rt.push.lock().unwrap().take_removed(); + if removed.is_empty() { + return; + } + let timeout = Duration::from_secs(number( + &rt.config, + &rt.domain, + "smacks_hibernation_time", + 300, + )); + for session in rt.hub.all() { + let mut s = session.lock().unwrap(); + if removed.iter().any(|(user, id)| { + s.owner == format!("{user}@{}", rt.domain) && s.push_identifier.as_deref() == Some(id) + }) { + s.push_identifier = None; + s.timeout = timeout; + s.first_push_sent = false; + if s.expires.is_some() { + s.expires = Some(Instant::now() + timeout); + } + } + } +} +fn http_interfaces(c: &Config, domain: &str, secure: bool) -> Result> { + let key = if secure { + "https_interfaces" + } else { + "http_interfaces" + }; + let configured = c + .option(domain, key) + .or_else(|| c.option(domain, "interfaces")); + let mut addresses = std::collections::BTreeSet::new(); + let mut add = |value: &serde_json::Value| -> Result<()> { + let value = value + .as_str() + .context("HTTP interface must be an IP address or *")?; + if value == "*" { + addresses.insert(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + addresses.insert(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)); + } else { + addresses.insert( + value + .parse::() + .context("invalid HTTP interface IP")?, + ); + } + Ok(()) + }; + match configured { + Some(serde_json::Value::Array(values)) => { + for value in values { + add(value)?; + } + } + Some(value) => add(value)?, + None => add(&serde_json::Value::String("*".into()))?, + } + Ok(addresses.into_iter().collect()) +} + +fn number(c: &Config, d: &str, k: &str, default: u64) -> u64 { + c.option(d, k).and_then(|v| v.as_u64()).unwrap_or(default) +} +pub async fn serve(path: &Path) -> Result<()> { + let (c, domain) = settings_config(path)?; + let default_debug_log = serde_json::Value::String("debug".into()); + let logger = Logger::from_config_with_format( + c.option(&domain, "log") + .or_else(|| boolean(&c, &domain, "debug", false).then_some(&default_debug_log)), + c.option(&domain, "log_format").and_then(|v| v.as_str()), + )?; + logger.event( + Level::Info, + "server", + None, + "starting", + &[("version", env!("CARGO_PKG_VERSION")), ("host", &domain)], + ); + let implemented = [ + "tls", + "saslauth", + "sasl2", + "bind2", + "sasl2_bind2", + "sasl_ssdp", + "disco", + "ping", + "version", + "roster", + "private", + "vcard_legacy", + "blocklist", + "mam", + "pep", + "pep_simple", + "bookmarks", + "smacks", + "csi_simple", + "carbons", + "time", + "uptime", + "turn_external", + "seclabels", + "register", + "admin_shell", + "websocket", + "bosh", + "http_altconnect", + "cloud_notify", + "s2s", + "s2s_auth_certs", + ]; + let missing: Vec<_> = c + .modules(&domain)? + .into_iter() + .filter(|m| !implemented.contains(&m.as_str())) + .collect(); + ensure!( + missing.is_empty(), + "modules not integrated: {}", + missing.join(", ") + ); + + let (data, _, hash) = account_config(path)?; + let data_guard = Arc::new(crate::data_lock::DataDirectoryGuard::acquire(&data)?); + let ssl = c.option(&domain, "ssl").context("ssl required")?; + let cert = ssl + .get("certificate") + .and_then(|v| v.as_str()) + .context("ssl.certificate required")?; + let key = ssl + .get("key") + .and_then(|v| v.as_str()) + .context("ssl.key required")?; + let certs = rustls_pemfile::certs(&mut BufReader::new(fs::File::open(cert)?)) + .collect::, _>>()?; + let key = rustls_pemfile::private_key(&mut BufReader::new(fs::File::open(key)?))? + .context("private key missing")?; + let tls = TlsAcceptor::from(Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs.clone(), key.clone_key())?, + )); + let federation = if c.modules(&domain)?.contains("s2s") { + let mut f = crate::federation::Federation::new( + domain.clone(), + certs.clone(), + key.clone_key(), + crate::federation::Federation::native_roots()?, + )?; + for name in c.hosts.keys() { + f.add_local_domain(name.clone())?; + } + Some(f) + } else { + None + }; + crate::account_modules::set_legacy_pep_limit( + &data, + &domain, + number(&c, &domain, "pep_max_items", 256) as usize, + )?; + let mut mucs = BTreeMap::new(); + let mut uploads = BTreeMap::new(); + for (name, host) in &c.hosts { + match host.component.as_deref() { + Some("muc") => { + let mut muc = crate::muc::Muc::new(data.clone(), name.clone()); + let mut options = c.global.clone(); + options.extend(host.options.clone()); + muc.set_component_options( + serde_json::Value::Object(options), + c.modules(name)?.contains("muc_mam"), + ); + muc.set_local_domain(domain.clone()); + muc.set_tombstones(boolean(&c, name, "muc_tombstones", true)); + muc.set_archive_retention(period( + c.option(name, "muc_log_expires_after") + .or_else(|| c.option(name, "mam_muc_archive_expires_after")), + Some(604800.0), + )); + muc.prepare_recovery()?; + mucs.insert(name.clone(), muc); + } + Some("http_file_share") => { + let https = ports(&c, &domain, "https_ports"); + let http = ports(&c, &domain, "http_ports"); + let http_config = crate::http_config::HttpConfig::for_host(&c, name)?; + let (secure_url, port) = https + .first() + .map(|p| (true, *p)) + .unwrap_or_else(|| (false, *http.first().unwrap_or(&5280))); + let base = http_config.external_url("file_share", secure_url, port)?; + let base = c + .option(name, "http_file_share_base_url") + .and_then(|v| v.as_str()) + .map(|s| s.trim_end_matches('/').to_owned()) + .unwrap_or(base); + let service = + crate::http_service::UploadService::new(crate::http_service::UploadConfig { + root: data.clone(), + host: name.clone(), + base_url: base, + size_limit: number( + &c, + name, + "http_file_share_size_limit", + 10 * 1024 * 1024, + ), + daily_quota: number( + &c, + name, + "http_file_share_daily_quota", + number(&c, name, "http_file_share_size_limit", 10 * 1024 * 1024) + .saturating_mul(10), + ), + global_quota: number(&c, name, "http_file_share_global_quota", u64::MAX), + expires_after: period( + c.option(name, "http_file_share_expires_after"), + Some(7.0 * 86400.0), + ) + .map(|n| n.max(0.0) as u64), + allowed_types: c + .option(name, "http_file_share_allowed_file_types") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(), + secret: c + .option(name, "http_file_share_secret") + .and_then(|v| v.as_str()) + .map(str::to_owned), + })?; + uploads.insert(name.clone(), service); + } + Some("pubsub") | None => {} + Some(other) => bail!("component not integrated: {other}"), + } + } + ensure!( + uploads.len() <= 1, + "multiple HTTP upload components not yet integrated" + ); + let runtime = Runtime { + _data_guard: data_guard, + domain: domain.clone(), + data, + hash, + tls: tls.clone(), + logger: logger.clone(), + tls_endpoint: certs.first().and_then(|cert| { + match crate::tls_binding::server_end_point(cert.as_ref()) { + Ok(data) => Some(sasl::ChannelBinding { + name: "tls-server-end-point".into(), + data, + }), + Err(_) => { + logger.event( + Level::Warn, + "tls", + None, + "endpoint-binding-unavailable", + &[("reason", "unsupported-certificate-signature-algorithm")], + ); + None + } + } + }), + config: Arc::new(c.clone()), + hub: Hub::default(), + mucs: Arc::new(Mutex::new(mucs)), + uploads: Arc::new(uploads), + started: Instant::now(), + push: Default::default(), + stopping: Default::default(), + federation, + }; + let interfaces = c + .option(&domain, "interfaces") + .and_then(|v| v.as_array()) + .context("interfaces required")?; + let mut tasks = tokio::task::JoinSet::new(); + let semaphore = Arc::new(Semaphore::new(4096)); + for interface in interfaces { + let ip: std::net::IpAddr = interface.as_str().context("IP expected")?.parse()?; + for port in ports(&c, &domain, "c2s_ports") { + let listener = bind_listener(std::net::SocketAddr::new(ip, port))?; + logger.event( + Level::Info, + "c2s", + None, + "listener-ready", + &[ + ("address", &listener.local_addr()?.to_string()), + ("transport", "tcp-starttls"), + ], + ); + let rt = runtime.clone(); + let slots = semaphore.clone(); + tasks.spawn(async move { + loop { + let (socket, peer) = listener.accept().await?; + if let Ok(permit) = slots.clone().try_acquire_owned() { + let rt = rt.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = connection(socket, rt).await; + }); + } else { + rt.logger.event( + Level::Warn, + "c2s", + None, + "connection-rejected", + &[("peer", &peer.to_string()), ("reason", "connection-limit")], + ); + } + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); + } + } + if runtime.enabled("admin_shell") { + let path = admin_path(&c, &domain, &runtime.data); + let snapshot = { + let rt = runtime.clone(); + Arc::new(move || crate::admin::AdminSnapshot { + uptime: rt.started.elapsed(), + sessions: rt + .hub + .all() + .into_iter() + .map(|s| s.lock().unwrap().jid.clone()) + .collect(), + }) + }; + let disconnect: crate::admin::UserMutation = { + let rt = runtime.clone(); + Arc::new(move |jid: &str| -> Result<()> { + let old = rt + .hub + .all() + .iter() + .filter_map(|s| { + let s = s.lock().unwrap(); + (s.owner == jid).then(|| s.jid.clone()) + }) + .collect::>(); + rt.hub.kick_owner(jid); + for j in old { + rt.disconnected(&j); + } + Ok(()) + }) + }; + let delete: crate::admin::UserMutation = { + let rt = runtime.clone(); + let disconnect = disconnect.clone(); + Arc::new(move |jid: &str| -> Result<()> { + let (u, d) = jid.split_once('@').context("bare JID required")?; + crate::storage::update(&rt.data, d, Some(u), "accounts", |a| { + a.set("disabled", crate::storage::LuaValue::Bool(true)); + Ok(()) + })?; + disconnect(jid)?; + crate::account_modules::remove_account(&rt.data, d, u) + }) + }; + let hosts = BTreeMap::from([(domain.clone(), runtime.hash.clone())]); + let modules = c + .hosts + .keys() + .map(|d| Ok((d.clone(), c.modules(d)?.into_iter().collect()))) + .collect::>>()?; + let server = crate::admin::AdminServer::bind( + &path, + crate::admin::AdminBackend { + data: runtime.data.clone(), + hosts, + modules, + snapshot, + delete_user: Some(delete), + disconnect_user: Some(disconnect), + }, + )?; + tasks.spawn(async move { server.serve().await }); + } + let (federated_tx, mut federated_rx) = + mpsc::channel::(1024); + if let Some(federation) = &runtime.federation { + for interface in interfaces { + let ip: std::net::IpAddr = interface.as_str().context("interface")?.parse()?; + for port in ports(&c, &domain, "s2s_ports") { + let listener = bind_listener(std::net::SocketAddr::new(ip, port))?; + logger.event( + Level::Info, + "s2s", + None, + "listener-ready", + &[("address", &listener.local_addr()?.to_string())], + ); + let fed = federation.clone(); + let tx = federated_tx.clone(); + let log = logger.clone(); + tasks.spawn(async move { + loop { + let (socket, peer) = listener.accept().await?; + let fed = fed.clone(); + let tx = tx.clone(); + let log = log.clone(); + tokio::spawn(async move { + let id = uuid::Uuid::new_v4().to_string(); + log.event( + Level::Info, + "s2s", + Some(&id), + "connection-open", + &[("peer", &peer.to_string())], + ); + let result = fed.accept(socket, tx).await; + log.event( + if result.is_ok() { + Level::Info + } else { + Level::Warn + }, + "s2s", + Some(&id), + "connection-closed", + &[( + "reason", + if result.is_ok() { + "stream-complete" + } else { + "federation-transport-or-auth-error" + }, + )], + ); + }); + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); + } + } + } + for (key, secure) in [("http_ports", false), ("https_ports", true)] { + for port in ports(&c, &domain, key) { + let handler: crate::http_transport::ContextConnectionHandler = { + let rt = runtime.clone(); + Arc::new(move |stream, context| { + let rt = rt.clone(); + Box::pin(web_connection_context(stream, rt, context)) + }) + }; + let http_config = crate::http_config::HttpConfig::for_host(&c, &domain)?; + let fallback = if let Some((name, service)) = runtime.uploads.iter().next() { + let config = crate::http_config::HttpConfig::for_host(&c, name)?; + service + .router_at(&config.file_share_path)? + .layer(axum::middleware::from_fn( + move |req: axum::extract::Request, next: axum::middleware::Next| { + let accepted = req + .headers() + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .is_some_and(|h| config.accepts_host(h)); + async move { + if accepted { + next.run(req).await + } else { + axum::response::IntoResponse::into_response( + axum::http::StatusCode::NOT_FOUND, + ) + } + } + }, + )) + } else { + axum::Router::new() + }; + let app = crate::http_transport::configured_router_with_fallback( + handler, + http_config, + runtime.enabled("websocket"), + runtime.enabled("bosh"), + secure, + fallback, + )?; + let app = if runtime.enabled("http_altconnect") { + let mut preferred = None; + for (key, secure) in [("https_ports", true), ("http_ports", false)] { + if !http_interfaces(&c, &domain, secure)?.is_empty() + && let Some(port) = ports(&c, &domain, key).first() + { + preferred = Some((secure, *port)); + break; + } + } + let (advertised_secure, advertised_port) = preferred.unwrap_or((secure, port)); + crate::http_altconnect::with_altconnect( + app, + &c, + &domain, + advertised_secure, + advertised_port, + )? + } else { + app + }; + for ip in http_interfaces(&c, &domain, secure)? { + let listener = bind_listener(std::net::SocketAddr::new(ip, port))?; + logger.event( + Level::Info, + "http", + None, + "listener-ready", + &[ + ("address", &listener.local_addr()?.to_string()), + ("transport", if secure { "https" } else { "http" }), + ( + "websocket", + if runtime.enabled("websocket") { + "enabled" + } else { + "disabled" + }, + ), + ( + "bosh", + if runtime.enabled("bosh") { + "enabled" + } else { + "disabled" + }, + ), + ], + ); + let tls = secure.then(|| tls.clone()); + let app = app.clone(); + let log = logger.clone(); + let proxy = crate::http_proxy::ProxyConfig::from_options(&c.global)?; + tasks.spawn(async move { + crate::http_service::serve_router_configured( + listener, + tls, + app, + Some(log), + proxy, + ) + .await + }); + } + } + } + ensure!(!tasks.is_empty(), "no listeners configured"); + logger.event( + Level::Info, + "server", + None, + "ready", + &[ + ("host", &domain), + ("version", env!("CARGO_PKG_VERSION")), + ("storage", &runtime.data.display().to_string()), + ("account_hash", &runtime.hash), + ], + ); + let mut maintenance = tokio::time::interval(Duration::from_secs(300)); + let mut maintenance_task: Option> = None; + let mut tick = tokio::time::interval(Duration::from_secs(1)); + 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 { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + _ = terminate.recv() => break, + _ = hangup.recv() => { + match logger.reopen() { + Ok(()) => logger.event(Level::Info, "logging", None, "logs-reopened", &[]), + Err(_) => logger.event(Level::Error, "logging", None, "log-reopen-failed", &[("reason", "cannot-open-log-destination")]), + } + }, + _ = maintenance.tick() => { + if maintenance_task.as_ref().is_none_or(|t| t.is_finished()) { + let rt = runtime.clone(); + let log = logger.clone(); + maintenance_task = Some(tokio::spawn(async move { + if !matches!(tokio::task::spawn_blocking(move || maintenance_run(&rt)).await, Ok(Ok(()))) { + log.event(Level::Error, "maintenance", None, "maintenance-failed", &[("reason", "storage-or-worker-error")]); + } + })); + } + }, + _ = tick.tick() => { + for jid in runtime.hub.expired() { + logger.event(Level::Info, "smacks", None, "session-expired", &[("jid", &jid)]); + runtime.disconnected(&jid); + } + }, + stanza = federated_rx.recv() => { + if let Some(stanza) = stanza { + let rt = runtime.clone(); + let log = logger.clone(); + tokio::spawn(async move { + if incoming_federated(rt, stanza).await.is_err() { + log.event(Level::Warn, "s2s", None, "inbound-routing-failed", &[("reason", "routing-or-storage-error")]); + } + }); + } + }, + result = tasks.join_next() => { + match result { Some(r) => r??, None => break } + } + } + } + logger.event(Level::Info, "server", None, "shutdown-requested", &[]); + runtime + .stopping + .store(true, std::sync::atomic::Ordering::Release); + for (domain, muc) in runtime.mucs.lock().unwrap().iter_mut() { + muc.checkpoint_live()?; + logger.event( + Level::Info, + "muc", + None, + "occupant-checkpoint-saved", + &[("component", domain)], + ); + } + tasks.abort_all(); + Ok(()) +} +fn bind_listener(addr: std::net::SocketAddr) -> Result { + let socket = socket2::Socket::new( + if addr.is_ipv6() { + socket2::Domain::IPV6 + } else { + socket2::Domain::IPV4 + }, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + socket.set_reuse_address(true)?; + if addr.is_ipv6() { + socket.set_only_v6(true)?; + } + socket.set_nonblocking(true)?; + socket.bind(&addr.into())?; + socket.listen(1024)?; + Ok(TcpListener::from_std(socket.into())?) +} +async fn send(s: &mut S, xml: &str) -> Result<()> { + timeout(Duration::from_secs(15), s.write_all(xml.as_bytes())).await??; + Ok(()) +} +async fn csi_send( + stream: &mut S, + session: Option<&SharedSession>, + xml: &str, +) -> Result<()> { + 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()) + } else { + vec![xml.into()] + }; + for xml in output { + send(stream, &xml).await?; + } + Ok(()) +} +async fn csi_activity( + stream: &mut S, + session: Option<&SharedSession>, +) -> Result<()> { + let output = session + .map(|s| s.lock().unwrap().csi.activity(Instant::now())) + .unwrap_or_default(); + for xml in output { + send(stream, &xml).await?; + } + Ok(()) +} +async fn emit( + s: &mut S, + session: Option<&SharedSession>, + xml: &str, +) -> 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()); + } + } + csi_send(s, session, xml).await?; + if session.is_some_and(|s| s.lock().unwrap().request_ack()) { + csi_send(s, session, "").await?; + } + Ok(()) +} +fn wrap(xml: &str) -> String { + format!("{xml}") +} +#[derive(Debug)] +struct PeerStreamClosed; +impl std::fmt::Display for PeerStreamClosed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("peer closed XMPP stream") + } +} +impl std::error::Error for PeerStreamClosed {} +async fn frame(f: &mut Framer, s: &mut S) -> Result { + let text = timeout(Duration::from_secs(60), f.next(s)).await??; + if f.is_stream_end(&text) { + send(s, "
").await?; + return Err(PeerStreamClosed.into()); + } + Ok(text) +} +async fn open( + s: &mut S, + f: &mut Framer, + domain: &str, + features: &str, +) -> Result<()> { + let mut text = frame(f, s).await?; + if text.starts_with("{features}",escape(domain),uuid::Uuid::new_v4())).await +} +async fn connection(tcp: TcpStream, rt: Runtime) -> Result<()> { + let peer = tcp + .peer_addr() + .map(|p| p.to_string()) + .unwrap_or_else(|_| "unknown".into()); + let mut ctx = ConnectionLog::new(&rt, "tcp", &peer); + let result = connection_inner(tcp, rt, &mut ctx).await; + ctx.finish(&result); + result +} +async fn connection_inner(mut tcp: TcpStream, rt: Runtime, ctx: &mut ConnectionLog) -> Result<()> { + tcp.set_nodelay(true)?; + let mut f = Framer::default(); + open( + &mut tcp, + &mut f, + &rt.domain, + &format!(""), + ) + .await?; + let text = frame(&mut f, &mut tcp).await?; + { + let w = f.wrap(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d.root_element().first_element_child().context("starttls")?; + ensure!( + n.tag_name().namespace() == Some(TLS) && n.tag_name().name() == "starttls", + "TLS required" + ); + } + ensure!(f.buffer.is_empty(), "pipelining before TLS"); + send(&mut tcp, &format!("")).await?; + ctx.phase = "tls-handshake"; + ctx.event(Level::Debug, "tls-handshake-start", &[]); + let mut stream = timeout(Duration::from_secs(15), rt.tls.accept(tcp)).await??; + let version = format!("{:?}", stream.get_ref().1.protocol_version()); + let cipher = format!( + "{:?}", + stream + .get_ref() + .1 + .negotiated_cipher_suite() + .map(|s| s.suite()) + ); + ctx.event( + Level::Info, + "tls-established", + &[("version", &version), ("cipher", &cipher)], + ); + let mut cb: Vec<_> = rt.tls_endpoint.iter().cloned().collect(); + // Exporter is selected by Android clients on TLS 1.3. On TLS 1.2, + // certificate endpoint binding supplies their interoperable fallback. + if stream.get_ref().1.protocol_version() == Some(tokio_rustls::rustls::ProtocolVersion::TLSv1_3) + && let Ok(data) = stream.get_ref().1.export_keying_material( + vec![0u8; 32], + b"EXPORTER-Channel-Binding", + Some(b""), + ) + { + cb.push(sasl::ChannelBinding { + name: "tls-exporter".into(), + data, + }); + } + authenticated_transport(&mut stream, rt, cb, ctx).await +} +struct ResumeRequest { + id: String, + h: u32, +} +fn inline_resume_request( + n: roxmltree::Node<'_, '_>, + rt: &Runtime, +) -> Result> { + // A cached SASL2 Quickstart may carry optional extensions which this + // configuration no longer advertises. Leave authentication independent. + if !rt.enabled("smacks") { + return Ok(None); + } + let resumes: Vec<_> = n + .children() + .filter(|c| c.has_tag_name(("urn:xmpp:sm:3", "resume"))) + .collect(); + ensure!(resumes.len() <= 1, "duplicate resume request"); + let Some(resume) = resumes.first() else { + return Ok(None); + }; + ensure!(rt.enabled("smacks"), "SM is disabled"); + ensure!( + !resume.children().any(|c| c.is_element()), + "nested resume option" + ); + ensure!( + resume + .attributes() + .all(|a| a.namespace().is_none() && matches!(a.name(), "previd" | "h")), + "invalid resume attribute" + ); + let id = resume + .attribute("previd") + .filter(|id| !id.is_empty()) + .context("missing resume id")? + .to_owned(); + let h = resume + .attribute("h") + .context("missing resume counter")? + .parse::()?; + Ok(Some(ResumeRequest { id, h })) +} +#[derive(Default)] +struct Bind2Request { + resource: String, + carbons: bool, + inactive: bool, + sm: Option, +} +fn bind2_request(n: roxmltree::Node<'_, '_>, rt: &Runtime) -> Result> { + if !(rt.enabled("sasl2_bind2") || rt.enabled("bind2")) { + return Ok(None); + } + let binds: Vec<_> = n + .children() + .filter(|c| c.has_tag_name((BIND2, "bind"))) + .collect(); + ensure!(binds.len() <= 1, "duplicate bind request"); + let Some(bind) = binds.first() else { + return Ok(None); + }; + ensure!( + rt.enabled("sasl2_bind2") || rt.enabled("bind2"), + "bind2 is disabled" + ); + ensure!(bind.attributes().len() == 0, "invalid bind attributes"); + let mut request = Bind2Request::default(); + let mut tag = None; + let mut csi = false; + for child in bind.children().filter(|c| c.is_element()) { + ensure!( + !child.children().any(|c| c.is_element()), + "nested bind option" + ); + match (child.tag_name().namespace(), child.tag_name().name()) { + (Some(BIND2), "tag") => { + ensure!( + tag.is_none() && child.attributes().len() == 0, + "duplicate or invalid tag" + ); + let prepared = stringprep::resourceprep(child.text().unwrap_or("")) + .map_err(|_| anyhow::anyhow!("invalid bind tag"))?; + ensure!( + !prepared.is_empty() && prepared.len() <= 986, + "invalid bind tag length" + ); + tag = Some(prepared.into_owned()); + } + (Some("urn:xmpp:carbons:2"), "enable") if rt.enabled("carbons") => { + ensure!( + !request.carbons && child.attributes().len() == 0, + "invalid carbons option" + ); + request.carbons = true; + } + (Some("urn:xmpp:csi:0"), "active" | "inactive") if rt.enabled("csi_simple") => { + ensure!(!csi && child.attributes().len() == 0, "invalid CSI option"); + csi = true; + request.inactive = child.tag_name().name() == "inactive"; + } + (Some("urn:xmpp:sm:3"), "enable") if rt.enabled("smacks") => { + ensure!(request.sm.is_none(), "duplicate SM option"); + ensure!( + child + .attributes() + .all(|a| a.name() == "resume" && a.namespace().is_none()), + "invalid SM attribute" + ); + request.sm = Some(match child.attribute("resume") { + None | Some("false" | "0") => false, + Some("true" | "1") => true, + _ => bail!("invalid SM resume"), + }); + } + _ => bail!("unsupported bind option"), + } + } + // UUID v4 supplies independent entropy: never expose the stable user-agent id. + let random = uuid::Uuid::new_v4().to_string(); + request.resource = tag.map(|tag| format!("{tag}/{random}")).unwrap_or(random); + Ok(Some(request)) +} +async fn authenticate( + stream: &mut S, + f: &mut Framer, + rt: &Runtime, + cb: Vec, + ctx: &mut ConnectionLog, +) -> Result<( + String, + bool, + Option, + Option, + Option, + String, +)> { + let mut cb = cb; + let mut mechanisms = vec!["PLAIN".to_owned(), format!("SCRAM-{}", rt.hash)]; + if !cb.is_empty() { + mechanisms.push(format!("SCRAM-{}-PLUS", rt.hash)); + } + if let Some(disabled) = rt + .config + .option(&rt.domain, "disable_sasl_mechanisms") + .and_then(|v| v.as_array()) + { + mechanisms.retain(|m| !disabled.iter().any(|d| d.as_str() == Some(m))); + } + // XEP-0440 forbids announcing binding types without a usable PLUS + // mechanism. Disabled mechanisms must also disable downgrade detection. + if !mechanisms.iter().any(|m| m.ends_with("-PLUS")) { + cb.clear(); + } + let list = mechanisms + .iter() + .map(|m| format!("{m}")) + .collect::(); + let mut features = format!("{list}"); + if rt.enabled("sasl2") { + let mut inline = if rt.enabled("sasl2_bind2") || rt.enabled("bind2") { + let extensions = [ + ("carbons", "urn:xmpp:carbons:2"), + ("csi_simple", "urn:xmpp:csi:0"), + ("smacks", "urn:xmpp:sm:3"), + ] + .iter() + .filter(|(module, _)| rt.enabled(module)) + .map(|(_, ns)| format!("")) + .collect::(); + format!("{extensions}") + } else { + String::new() + }; + if rt.enabled("smacks") { + inline.push_str(""); + } + features.push_str(&format!( + "{list}{inline}" + )); + } + if rt.enabled("register") && boolean(&rt.config, &rt.domain, "allow_registration", false) { + features.push_str(""); + } + if !cb.is_empty() { + features.push_str(""); + for binding in &cb { + features.push_str(&format!( + "", + escape(&binding.name) + )); + } + features.push_str(""); + } + ctx.phase = "sasl-initial"; + let offered = mechanisms.join(","); + let bindings = cb + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(","); + ctx.event( + Level::Info, + "sasl-features", + &[ + ("mechanisms", &offered), + ("channel_bindings", &bindings), + ("account_hash", &rt.hash), + ( + "ssdp", + if rt.enabled("sasl_ssdp") { + "enabled" + } else { + "disabled" + }, + ), + ], + ); + open(stream, f, &rt.domain, &features).await?; + let mut pending_auth = None; + for attempt in 1..=3 { + ctx.phase = "sasl-initial"; + let text = match pending_auth.take() { + Some(text) => text, + None => frame(f, stream).await?, + }; + let w = f.wrap(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d.root_element().first_element_child().context("auth")?; + if n.tag_name().namespace() == Some(CLIENT) + && n.tag_name().name() == "iq" + && let Some(q) = n + .first_element_child() + .filter(|q| q.tag_name().namespace() == Some("jabber:iq:register")) + { + if rt.enabled("register") + && boolean(&rt.config, &rt.domain, "allow_registration", false) + { + if n.attribute("type") == Some("get") { + send(stream,&protocol::iq_result(n,"Choose username and password")).await?; + continue; + } + let user = q + .children() + .find(|c| c.is_element() && c.tag_name().name() == "username") + .and_then(|c| c.text()); + let password = q + .children() + .find(|c| c.is_element() && c.tag_name().name() == "password") + .and_then(|c| c.text()); + let result = if let (Some(u), Some(p)) = (user, password) { + auth::create(&rt.data, &rt.domain, u, p, &rt.hash) + } else { + Err(anyhow::anyhow!("missing fields")) + }; + let reply = if result.is_ok() { + protocol::iq_result(n, "") + } else { + protocol::iq_error(n, "conflict") + }; + send(stream, &reply).await?; + continue; + } + send(stream, &protocol::iq_error(n, "forbidden")).await?; + continue; + } + let sasl2 = n.tag_name().namespace() == Some(SASL2) + && n.tag_name().name() == "authenticate" + && rt.enabled("sasl2"); + let client_id = if sasl2 { + n.children() + .find(|c| c.has_tag_name((SASL2, "user-agent"))) + .and_then(|c| c.attribute("id")) + .filter(|id| !id.is_empty() && id.len() <= 1024) + .map(str::to_owned) + } else { + None + }; + let ns = if sasl2 { SASL2 } else { SASL }; + if !(sasl2 || (n.tag_name().namespace() == Some(SASL) && n.tag_name().name() == "auth")) { + send( + stream, + &format!(""), + ) + .await?; + continue; + } + let mechanism = n.attribute("mechanism").unwrap_or(""); + let mechanism_label = mechanism.chars().take(64).collect::(); + ctx.event( + Level::Info, + "sasl-attempt", + &[ + ("attempt", &attempt.to_string()), + ("mechanism", &mechanism_label), + ("protocol", if sasl2 { "sasl2" } else { "sasl1" }), + ], + ); + if sasl2 { + let cached_bind = n.children().any(|c| c.has_tag_name((BIND2, "bind"))); + let cached_resume = n + .children() + .any(|c| c.has_tag_name(("urn:xmpp:sm:3", "resume"))); + let binding_enabled = rt.enabled("sasl2_bind2") || rt.enabled("bind2"); + ctx.event( + Level::Debug, + "sasl-inline-options", + &[ + ("bind_requested", if cached_bind { "true" } else { "false" }), + ( + "bind_available", + if binding_enabled { "true" } else { "false" }, + ), + ( + "resume_requested", + if cached_resume { "true" } else { "false" }, + ), + ( + "sm_available", + if rt.enabled("smacks") { + "true" + } else { + "false" + }, + ), + ], + ); + for (requested, available, reason) in [ + (cached_bind, binding_enabled, "bind2-disabled"), + (cached_resume, rt.enabled("smacks"), "sm-disabled"), + ] { + if requested && !available { + ctx.event( + Level::Info, + "inline-request-ignored", + &[ + ("reason", reason), + ("fallback", "post-auth-stream-features"), + ], + ); + } + } + } + let (bind2, resume) = if sasl2 { + match bind2_request(n, rt).and_then(|bind| Ok((bind, inline_resume_request(n, rt)?))) { + Ok(request) => request, + Err(e) => { + let detail = match e.to_string().as_str() { + "unsupported bind option" => "unsupported-bind-option", + "bind2 is disabled" => "bind2-disabled", + "duplicate bind request" => "duplicate-bind-request", + "duplicate resume request" => "duplicate-resume-request", + "SM is disabled" => "sm-disabled", + "invalid SM resume" => "invalid-sm-resume", + "invalid SM attribute" => "invalid-sm-attribute", + "invalid bind attributes" => "invalid-bind-attributes", + "duplicate or invalid tag" => "duplicate-or-invalid-bind-tag", + "invalid bind tag" | "invalid bind tag length" => "invalid-bind-tag", + "nested bind option" => "nested-bind-option", + "invalid carbons option" => "invalid-carbons-option", + "invalid CSI option" => "invalid-csi-option", + "duplicate SM option" => "duplicate-sm-option", + "invalid resume attribute" => "invalid-resume-attribute", + "missing resume id" => "missing-resume-id", + "missing resume counter" => "missing-resume-counter", + _ => "invalid-inline-fields", + }; + ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", "inline-request-malformed"), ("detail", detail)], + ); + send( + stream, + &format!( + "" + ), + ) + .await?; + continue; + } + } + } else { + (None, None) + }; + if !mechanisms.iter().any(|m| m == mechanism) { + ctx.event( + Level::Warn, + "sasl-rejected", + &[ + ("reason", "mechanism-not-offered"), + ("mechanism", &mechanism_label), + ], + ); + send( + stream, + &format!(""), + ) + .await?; + continue; + } + let initial = if sasl2 { + n.children() + .find(|c| c.has_tag_name((SASL2, "initial-response"))) + .and_then(|c| c.text()) + .unwrap_or("") + } else { + n.text().unwrap_or("") + }; + let raw = match STANDARD.decode(initial) { + Ok(v) => v, + Err(_) => { + ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", "incorrect-encoding"), ("mechanism", mechanism)], + ); + send( + stream, + &format!( + "" + ), + ) + .await?; + continue; + } + }; + let mut username = None; + let mut final_data = String::new(); + if mechanism == "PLAIN" { + let fields: Vec<_> = raw.split(|&b| b == 0).collect(); + if fields.len() == 3 + && let (Ok(authz), Ok(user), Ok(pass)) = ( + std::str::from_utf8(fields[0]), + std::str::from_utf8(fields[1]), + std::str::from_utf8(fields[2]), + ) + && let Ok(user) = auth::prepared_auth_username(user) + { + let authz_matches = authz.is_empty() + || authz.split_once('@').is_some_and(|(name, host)| { + auth::prepared_auth_username(name).is_ok_and(|name| name == user) + && stringprep::nameprep(host).is_ok_and(|host| host == rt.domain) + }); + ctx.event(Level::Info, "auth-identity", &[("user", &user)]); + if authz_matches { + let (root, domain, hash, u, p) = ( + rt.data.clone(), + rt.domain.clone(), + rt.hash.clone(), + user.clone(), + pass.to_owned(), + ); + let valid = tokio::task::spawn_blocking(move || { + auth::verify(&root, &domain, &u, &p, &hash) + }) + .await?; + match valid { + Ok(true) => username = Some(user), + Ok(false) => {} + Err(e) => ctx.event( + Level::Warn, + "account-lookup", + &[("user", &user), ("reason", sasl_reason(&e))], + ), + } + } else { + ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", "invalid-authzid"), ("mechanism", mechanism)], + ); + } + } + } else { + let opts = sasl::Options { + // Match the type requested in GS2; SSDP still covers every + // advertised type. Non-PLUS retains support for downgrade checks. + channel_binding: if mechanism.ends_with("-PLUS") { + std::str::from_utf8(&raw) + .ok() + .and_then(|s| s.split(',').next()) + .and_then(|s| s.strip_prefix("p=")) + .and_then(|name| cb.iter().find(|c| c.name == name)) + .cloned() + } else { + cb.first().cloned() + }, + ssdp_mechanisms: rt.enabled("sasl_ssdp").then(|| mechanisms.clone()), + ssdp_channel_bindings: (rt.enabled("sasl_ssdp") && !cb.is_empty()) + .then(|| cb.iter().map(|c| c.name.clone()).collect()), + }; + let result = sasl::Scram::start( + mechanism, + &raw, + &rt.domain, + |u, h| { + let result = auth::load_scram(&rt.data, &rt.domain, u, h); + match &result { + Ok(Some(c)) => ctx.event( + Level::Debug, + "account-lookup", + &[ + ("user", u), + ("hash", h), + ("account_state", "usable"), + ("iterations", &c.iterations.to_string()), + ("salt_bytes", &c.salt.len().to_string()), + ], + ), + Ok(None) => ctx.event( + Level::Warn, + "account-lookup", + &[ + ("user", u), + ("hash", h), + ("account_state", "missing-or-disabled"), + ], + ), + Err(e) => ctx.event( + Level::Warn, + "account-lookup", + &[("user", u), ("hash", h), ("reason", sasl_reason(e))], + ), + } + result + }, + opts, + ); + match result { + Ok((state, challenge)) => { + ctx.phase = "sasl-client-final"; + ctx.event(Level::Info, "sasl-challenge", &[("mechanism", mechanism)]); + send( + stream, + &format!( + "{}", + STANDARD.encode(challenge) + ), + ) + .await?; + let reply = frame(f, stream).await?; + let w = f.wrap(&reply); + let d = roxmltree::Document::parse(&w)?; + let response = d.root_element().first_element_child(); + if response.is_some_and(|n| n.has_tag_name((ns, "abort"))) { + ctx.event( + Level::Warn, + "sasl-rejected", + &[ + ("reason", "client-aborted-after-challenge"), + ("mechanism", mechanism), + ], + ); + send( + stream, + &format!(""), + ) + .await?; + continue; + } else if response.is_some_and(|n| { + n.has_tag_name((SASL, "auth")) + || (rt.enabled("sasl2") && n.has_tag_name((SASL2, "authenticate"))) + }) { + ctx.event( + Level::Info, + "sasl-restarted", + &[("previous_mechanism", mechanism)], + ); + pending_auth = Some(reply); + continue; + } else if let Some(n) = response.filter(|n| n.has_tag_name((ns, "response"))) { + match STANDARD.decode(n.text().unwrap_or("")) { + Ok(bytes) => match state.finish(&bytes) { + Ok(success) => { + username = Some(success.username); + final_data = success.server_final; + } + Err(e) => ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", sasl_reason(&e)), ("mechanism", mechanism)], + ), + }, + Err(_) => ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", "incorrect-encoding"), ("mechanism", mechanism)], + ), + } + } else { + ctx.event( + Level::Warn, + "sasl-rejected", + &[ + ("reason", "unexpected-client-final-element"), + ("mechanism", mechanism), + ], + ); + } + } + Err(e) => ctx.event( + Level::Warn, + "sasl-rejected", + &[("reason", sasl_reason(&e)), ("mechanism", mechanism)], + ), + } + } + if let Some(user) = username { + ctx.phase = "sasl-success"; + ctx.event( + Level::Info, + "auth-success", + &[ + ("user", &user), + ("mechanism", mechanism), + ("protocol", if sasl2 { "sasl2" } else { "sasl1" }), + ], + ); + let extra = if final_data.is_empty() { + String::new() + } else { + STANDARD.encode(final_data) + }; + let success = if sasl2 { + format!( + "{extra}{}@{}", + escape(&user), + escape(&rt.domain) + ) + } else if extra.is_empty() { + format!("") + } else { + format!("{extra}") + }; + return Ok((user, sasl2, client_id, bind2, resume, success)); + } + if mechanism == "PLAIN" { + ctx.event( + Level::Warn, + "sasl-rejected", + &[ + ("reason", "invalid-plain-credentials-or-authzid"), + ("mechanism", mechanism), + ], + ); + } + tokio::time::sleep(Duration::from_millis(300)).await; + send( + stream, + &format!(""), + ) + .await?; + } + bail!("authentication failed") +} +pub async fn web_connection(stream: tokio::io::DuplexStream, rt: Runtime) { + web_connection_context(stream, rt, None).await; +} +async fn web_connection_context( + mut stream: tokio::io::DuplexStream, + rt: Runtime, + context: Option, +) { + let peer = context + .as_ref() + .map(|c| c.peer_ip.to_string()) + .unwrap_or_else(|| "not-forwarded".into()); + let mut ctx = ConnectionLog::new(&rt, "http-adapter", &peer); + if let Some(context) = context { + ctx.event( + Level::Info, + "http-connection-context", + &[ + ("socket_peer", &context.socket_peer.to_string()), + ("secure", if context.secure { "true" } else { "false" }), + ], + ); + } + let result = authenticated_transport(&mut stream, rt, Vec::new(), &mut ctx).await; + ctx.finish(&result); +} + +async fn apply( + stream: &mut S, + session: Option<&SharedSession>, + rt: &Runtime, + result: ModuleResult, +) -> Result<()> { + for xml in result.replies { + emit(stream, session, &xml).await?; + } + for (to, xml) in result.deliveries { + if let Ok(doc) = roxmltree::Document::parse(&xml) + && let Some(fields) = crate::pep_diagnostics::event_fields(doc.root_element()) + { + let fields: Vec<_> = fields + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + rt.logger + .event(Level::Info, "pep", None, "notification-generated", &fields); + } + route_delivery(rt, &to, &xml).await?; + } + Ok(()) +} +fn upload_allowed( + data: &Path, + config: &Config, + local_domain: &str, + component: &str, + jid: &str, +) -> Result { + let bare = protocol::bare(jid); + let domain = bare.rsplit_once('@').map(|(_, d)| d).unwrap_or(bare); + if domain == local_domain + && let Some((user, _)) = bare.split_once('@') + && crate::storage::load(data, local_domain, Some(user), "accounts")? + .is_some_and(|a| a.get("disabled") != Some(&crate::storage::LuaValue::Bool(true))) + { + return Ok(true); + } + Ok(config + .option(component, "http_file_share_access") + .and_then(|v| v.as_array()) + .is_some_and(|values| { + values + .iter() + .any(|v| v.as_str().is_some_and(|v| v == bare || v == domain)) + })) +} + +// Summarize only protocol metadata. Never log payloads, item IDs, node suffixes, +// IQ IDs or arbitrary XML attribute values (OMEMO bundles and avatar data are private). +fn pep_iq_metadata( + iq: roxmltree::Node<'_, '_>, + result: &ModuleResult, + domain: &str, + jid: &str, +) -> Option> { + let payload = iq.first_element_child()?; + let ns = payload.tag_name().namespace().unwrap_or(""); + let discovery = ns == "http://jabber.org/protocol/disco#info"; + if !discovery + && !matches!( + ns, + "http://jabber.org/protocol/pubsub" | "http://jabber.org/protocol/pubsub#owner" + ) + { + return None; + } + let action = if discovery { + "disco-info" + } else { + match payload.first_element_child().map(|n| n.tag_name().name()) { + Some("publish") => "publish", + Some("items") => "items", + Some("configure") => "configure", + Some("create") => "create", + Some("subscribe") => "subscribe", + Some("unsubscribe") => "unsubscribe", + Some("retract") => "retract", + Some("purge") => "purge", + Some("delete") => "delete", + Some("affiliations") => "affiliations", + Some("subscriptions") => "subscriptions", + Some("default") => "default", + _ => "other", + } + }; + let node = if discovery { + payload.attribute("node") + } else { + payload + .first_element_child() + .and_then(|n| n.attribute("node")) + }; + let node_kind = match node { + None => "none", + Some("urn:xmpp:avatar:metadata") => "avatar-metadata", + Some("urn:xmpp:avatar:data") => "avatar-data", + Some("eu.siacs.conversations.axolotl.devicelist" | "urn:xmpp:omemo:2:devices") => { + "omemo-devices" + } + Some(v) + if v.starts_with("eu.siacs.conversations.axolotl.bundles:") + || v.starts_with("urn:xmpp:omemo:2:bundles") => + { + "omemo-bundle" + } + Some("urn:xmpp:bookmarks:1") => "bookmarks", + Some(_) => "custom", + }; + let target_scope = match iq.attribute("to") { + None => "implicit", + Some(t) if t == domain => "server", + Some(t) if t.contains('/') => "resource", + Some(t) if t == protocol::bare(jid) => "self-account", + Some(t) if t.contains('@') => "other-account", + Some(_) => "host-or-component", + }; + let mut device_fields = Vec::new(); + let mut item_count = 0; + let mut first_payload = "none"; + let mut first_devices = 0; + let mut status = "no-reply"; + let mut condition = "none"; + let mut pep = false; + let mut persistent = false; + let mut options = false; + let mut precondition = false; + for xml in &result.replies { + if let Ok(doc) = roxmltree::Document::parse(xml) { + let reply = doc.root_element(); + status = match reply.attribute("type") { + Some("result") => "result", + Some("error") => "error", + _ => "other", + }; + if action == "items" + && reply.attribute("type") == Some("result") + && let Some(items) = reply + .children() + .find(|n| n.has_tag_name(("http://jabber.org/protocol/pubsub", "pubsub"))) + .and_then(|n| { + n.children().find(|n| { + n.has_tag_name(("http://jabber.org/protocol/pubsub", "items")) + }) + }) + { + device_fields = crate::pep_diagnostics::item_fields(items); + item_count = items + .children() + .filter(|n| n.has_tag_name(("http://jabber.org/protocol/pubsub", "item"))) + .count(); + if let Some(payload) = items + .children() + .find(|n| n.has_tag_name(("http://jabber.org/protocol/pubsub", "item"))) + .and_then(|n| n.first_element_child()) + { + first_payload = + match (payload.tag_name().namespace(), payload.tag_name().name()) { + (Some("eu.siacs.conversations.axolotl"), "list") + | (Some("urn:xmpp:omemo:2"), "devices") => "omemo-devices", + ( + Some("eu.siacs.conversations.axolotl" | "urn:xmpp:omemo:2"), + "bundle", + ) => "omemo-bundle", + (Some("urn:xmpp:avatar:metadata"), "metadata") => "avatar-metadata", + (Some("urn:xmpp:avatar:data"), "data") => "avatar-data", + (Some("http://jabber.org/protocol/pubsub"), "item") => "nested-item", + _ => "other", + }; + if first_payload == "omemo-devices" { + first_devices = payload + .children() + .filter(|n| { + n.tag_name().name() == "device" + && n.tag_name().namespace() == payload.tag_name().namespace() + }) + .count(); + } + } + } + for n in reply + .children() + .filter(|n| { + n.has_tag_name((CLIENT, "error")) + || n.has_tag_name(("http://jabber.org/protocol/disco#info", "query")) + }) + .flat_map(|n| n.descendants()) + .filter(|n| n.is_element()) + { + precondition |= n.has_tag_name(( + "http://jabber.org/protocol/pubsub#errors", + "precondition-not-met", + )); + if n.tag_name().namespace() == Some("urn:ietf:params:xml:ns:xmpp-stanzas") { + condition = match n.tag_name().name() { + "bad-request" => "bad-request", + "forbidden" => "forbidden", + "item-not-found" => "item-not-found", + "conflict" => "conflict", + "not-acceptable" => "not-acceptable", + "not-allowed" => "not-allowed", + "service-unavailable" => "service-unavailable", + "internal-server-error" => "internal-server-error", + "feature-not-implemented" => "feature-not-implemented", + "resource-constraint" => "resource-constraint", + _ => "other", + }; + } + pep |= n.has_tag_name(("http://jabber.org/protocol/disco#info", "identity")) + && n.attribute("category") == Some("pubsub") + && n.attribute("type") == Some("pep"); + if n.has_tag_name(("http://jabber.org/protocol/disco#info", "feature")) { + persistent |= n.attribute("var") + == Some("http://jabber.org/protocol/pubsub#persistent-items"); + options |= n.attribute("var") + == Some("http://jabber.org/protocol/pubsub#publish-options"); + } + } + } + } + let mut fields = vec![ + ("action", action.to_owned()), + ("target_scope", target_scope.to_owned()), + ("node_kind", node_kind.to_owned()), + ("status", status.to_owned()), + ("condition", condition.to_owned()), + ("precondition_not_met", precondition.to_string()), + ("deliveries", result.deliveries.len().to_string()), + ]; + if action == "items" && status == "result" { + fields.extend([ + ("items_count", item_count.to_string()), + ("first_content_kind", first_payload.to_owned()), + ("first_device_count", first_devices.to_string()), + ]); + } + fields.extend(device_fields); + if discovery { + fields.extend([ + ("pep_identity", pep.to_string()), + ("persistent_items", persistent.to_string()), + ("publish_options", options.to_string()), + ]); + } + Some(fields) +} + +fn dispatch_iq( + rt: &Runtime, + jid: &str, + iq: roxmltree::Node<'_, '_>, +) -> Result> { + let Some(q) = iq.first_element_child() else { + return Ok(None); + }; + let ns = q.tag_name().namespace().unwrap_or(""); + let name = q.tag_name().name(); + let typ = iq.attribute("type").unwrap_or(""); + let target = iq.attribute("to").unwrap_or(&rt.domain); + let target_bare = protocol::bare(target); + { + let mut rooms = rt.mucs.lock().unwrap(); + for muc in rooms.values_mut() { + if let Some(result) = muc.handle(jid, iq)? { + return Ok(Some(result)); + } + } + } + if let Some(upload) = rt.uploads.get(target_bare) { + let payload = if typ == "get" && ns == "urn:xmpp:http:upload:0" && name == "request" { + if !upload_allowed(&rt.data, &rt.config, &rt.domain, target_bare, jid)? { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + } + if q.attribute("size") + .and_then(|s| s.parse::().ok()) + .is_none() + || q.attribute("filename").is_none_or(str::is_empty) + { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "bad-request", + )))); + } + match upload.request_slot( + protocol::bare(jid), + q.attribute("filename").unwrap_or(""), + q.attribute("size").unwrap_or("0").parse().unwrap_or(0), + q.attribute("content-type") + .unwrap_or("application/octet-stream"), + ) { + Ok(slot) => slot.xml(), + Err(_) => { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "not-acceptable", + )))); + } + } + } else if typ == "get" && ns == "http://jabber.org/protocol/disco#info" { + format!("{}", upload.disco_info()) + } else { + return Ok(None); + }; + return Ok(Some(ModuleResult::reply(protocol::iq_result(iq, &payload)))); + } + if rt + .config + .hosts + .get(target_bare) + .and_then(|h| h.component.as_deref()) + == Some("pubsub") + { + let admins: Vec = rt + .config + .global + .get("admins") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + return crate::pubsub::handle_component_with_options( + &rt.data, + target_bare, + jid, + &admins, + iq, + &crate::pubsub::Options { + max_items: number(&rt.config, target_bare, "pubsub_max_items", 256) as usize, + restrict_node_creation: boolean( + &rt.config, + target_bare, + "restrict_node_creation", + true, + ), + autocreate_on_publish: false, + }, + ); + } + if rt.enabled("pep") + && let Some(mut reply) = crate::pubsub::handle_with_options( + &rt.data, + &rt.domain, + jid, + iq, + &crate::pubsub::Options { + max_items: number(&rt.config, &rt.domain, "pep_max_items", 256) as usize, + restrict_node_creation: false, + autocreate_on_publish: true, + }, + )? + { + if typ == "get" + && ns == "http://jabber.org/protocol/disco#info" + && q.attribute("node").is_none() + && target == target_bare + && target_bare == protocol::bare(jid) + { + for xml in &mut reply.replies { + if let Some(at) = xml.rfind("
") { + xml.insert_str(at, &account_features(rt)); + } + } + } + return Ok(Some(reply)); + } + if rt.enabled("roster") + && let Some(result) = crate::preauth::handle_iq(&rt.data, &rt.domain, jid, iq)? + { + return Ok(Some(result)); + } + // Bare-account discovery must work independently of the PEP module. + if typ == "get" + && name == "query" + && ns == "http://jabber.org/protocol/disco#info" + && target == target_bare + && rt.owner(target).is_some() + { + let user = rt.owner(target).unwrap(); + if q.attribute("node").is_some() + || crate::storage::load(&rt.data, &rt.domain, Some(&user), "accounts")?.is_none() + { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "item-not-found", + )))); + } + let own_features = if target_bare == protocol::bare(jid) { + account_features(rt) + } else { + String::new() + }; + let payload = format!( + "{own_features}" + ); + return Ok(Some(ModuleResult::reply(protocol::iq_result(iq, &payload)))); + } + if (rt.enabled("vcard_legacy") || rt.enabled("bookmarks")) + && let Some(result) = crate::account_modules::handle_legacy(&rt.data, &rt.domain, jid, iq)? + { + return Ok(Some(result)); + } + let own = target == rt.domain || target == jid || target_bare == protocol::bare(jid); + // vCards may be publicly requested; account handler enforces its per-namespace access rules. + if [ + "jabber:iq:roster", + "jabber:iq:private", + "vcard-temp", + "urn:xmpp:blocking", + ] + .contains(&ns) + { + let module = match ns { + "jabber:iq:roster" => "roster", + "jabber:iq:private" => "private", + "vcard-temp" => "vcard_legacy", + _ => "blocklist", + }; + if rt.enabled(module) { + return crate::account_modules::handle(&rt.data, &rt.domain, jid, iq); + } + } + if ns == "urn:xmpp:mam:2" && rt.enabled("mam") { + return crate::mam::handle_with_options( + &rt.data, + &rt.domain, + jid, + iq, + archive_policy(rt), + boolean(&rt.config, &rt.domain, "mam_smart_enable", false), + ); + } + if !own { + return Ok(None); + } + if rt.enabled("cloud_notify") { + let session = rt.hub.get(jid); + let client_id = session + .as_ref() + .and_then(|s| s.lock().unwrap().client_id.clone()); + let result = rt.push.lock().unwrap().handle_with_client( + &rt.data, + &rt.domain, + jid, + iq, + number(&rt.config, &rt.domain, "push_max_devices", 5) as usize, + client_id.as_deref(), + )?; + if result.is_some() { + if let Some(session) = session { + refresh_push_session(rt, &session)?; + } + return Ok(result); + } + } + if let Some(reply) = crate::extras::handle(&rt.config, &rt.domain, iq, rt.started)? { + return Ok(Some(reply)); + } + if ns == "jabber:iq:register" && rt.enabled("register") { + let user = rt.owner(jid).context("local account")?; + if typ == "get" { + return Ok(Some(ModuleResult::reply(protocol::iq_result( + iq, + &format!( + "{}", + escape(&user) + ), + )))); + } + if typ == "set" { + let target_user = q + .children() + .find(|n| n.is_element() && n.tag_name().name() == "username") + .and_then(|n| n.text()) + .unwrap_or(&user); + if target_user != user { + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "forbidden", + )))); + } + if q.children() + .any(|n| n.is_element() && n.tag_name().name() == "remove") + { + crate::storage::update(&rt.data, &rt.domain, Some(&user), "accounts", |a| { + a.set("disabled", crate::storage::LuaValue::Bool(true)); + Ok(()) + })?; + let resources = rt + .hub + .all() + .iter() + .filter_map(|s| { + let s = s.lock().unwrap(); + (s.owner == protocol::bare(jid)).then(|| s.jid.clone()) + }) + .collect::>(); + rt.hub.kick_owner(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 password = q + .children() + .find(|n| n.is_element() && n.tag_name().name() == "password") + .and_then(|n| n.text()); + if let Some(password) = password { + auth::set_password(&rt.data, &rt.domain, &user, password, &rt.hash)?; + return Ok(Some(ModuleResult::reply(protocol::iq_result(iq, "")))); + } + return Ok(Some(ModuleResult::reply(protocol::iq_error( + iq, + "feature-not-implemented", + )))); + } + } + if typ == "get" + && name == "query" + && ns.starts_with("http://jabber.org/protocol/disco#") + && let Some(node) = q.attribute("node") + { + let commands = "http://jabber.org/protocol/commands"; + let invite = crate::preauth::INVITE_COMMAND; + let local_actor = rt.owner(jid).is_some(); + let payload = if rt.enabled("roster") + && local_actor + && target == rt.domain + && node == commands + && ns.ends_with("#items") + { + Some(format!( + "", + escape(&rt.domain) + )) + } else if rt.enabled("roster") + && local_actor + && target == rt.domain + && node == invite + && ns.ends_with("#info") + { + Some(format!( + "" + )) + } else if rt.enabled("roster") + && local_actor + && target == rt.domain + && node == commands + && ns.ends_with("#info") + { + Some(format!( + "" + )) + } else { + None + }; + return Ok(Some(ModuleResult::reply(match payload { + Some(payload) => protocol::iq_result(iq, &payload), + None => protocol::iq_error(iq, "item-not-found"), + }))); + } + let payload = match (typ, name, ns) { + ("get", "ping", "urn:xmpp:ping") => Some(String::new()), + ("set", "session", "urn:ietf:params:xml:ns:xmpp-session") => Some(String::new()), + ("get", "query", "jabber:iq:version") => Some(format!( + "Rust XMPP{}", + env!("CARGO_PKG_VERSION") + )), + ("get", "query", "http://jabber.org/protocol/disco#items") => { + let mut x = format!(""); + for (d, h) in &rt.config.hosts { + if h.component.is_some() { + x.push_str(&format!("", escape(d))); + } + } + x.push_str(""); + Some(x) + } + ("get", "query", "http://jabber.org/protocol/disco#info") => { + let mut x = format!( + "" + ); + for feature in [ + "http://jabber.org/protocol/disco#info", + "http://jabber.org/protocol/disco#items", + "urn:xmpp:ping", + "jabber:iq:version", + ] { + x.push_str(&format!("")); + } + for (module, feature) in [ + ("roster", "jabber:iq:roster"), + ("private", "jabber:iq:private"), + ("vcard_legacy", "vcard-temp"), + ("mam", "urn:xmpp:mam:2"), + ("blocklist", "urn:xmpp:blocking"), + ("carbons", "urn:xmpp:carbons:2"), + ("time", "urn:xmpp:time"), + ("uptime", "jabber:iq:last"), + ("turn_external", "urn:xmpp:extdisco:2"), + ("register", "jabber:iq:register"), + ("seclabels", "urn:xmpp:sec-label:0"), + ("seclabels", "urn:xmpp:sec-label:catalog:2"), + ("seclabels", "urn:xmpp:sec-label:catalog:0"), + ] { + if rt.enabled(module) { + x.push_str(&format!("")); + } + } + if rt.enabled("roster") { + x.push_str(""); + } + x.push_str(""); + Some(x) + } + _ => None, + }; + Ok(payload.map(|p| ModuleResult::reply(protocol::iq_result(iq, &p)))) +} +fn account_features(rt: &Runtime) -> String { + let mut features = String::new(); + for (module, namespace) in [ + ("roster", "jabber:iq:roster"), + ("private", "jabber:iq:private"), + ("vcard_legacy", "vcard-temp"), + ("mam", "urn:xmpp:mam:2"), + ("mam", "urn:xmpp:mam:2#extended"), + ("blocklist", "urn:xmpp:blocking"), + ("carbons", "urn:xmpp:carbons:2"), + ("cloud_notify", "urn:xmpp:push:0"), + ("register", "jabber:iq:register"), + ("seclabels", "urn:xmpp:sec-label:0"), + ("seclabels", "urn:xmpp:sec-label:catalog:2"), + ] { + if rt.enabled(module) { + features.push_str(&format!("")); + } + } + if rt.enabled("pep") && rt.enabled("bookmarks") { + for namespace in [ + "urn:xmpp:bookmarks:1#compat", + "urn:xmpp:bookmarks:1#compat-pep", + "urn:xmpp:bookmarks-conversion:0", + ] { + features.push_str(&format!("")); + } + } + if rt.enabled("pep") && rt.enabled("vcard_legacy") { + features.push_str(""); + } + features +} +fn archive_policy(rt: &Runtime) -> &str { + match rt.config.option(&rt.domain, "default_archive_policy") { + Some(serde_json::Value::Bool(false)) => "never", + Some(serde_json::Value::String(s)) if s == "roster" => "roster", + Some(serde_json::Value::String(s)) if s == "never" => "never", + _ => "always", + } +} +fn routed(n: roxmltree::Node<'_, '_>, from: &str, to: &str) -> String { + protocol::address(n, from, to) +} +struct ActivityReader<'a, S> { + inner: &'a mut S, + activity: Arc, +} +impl AsyncRead for ActivityReader<'_, S> { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let before = buf.filled().len(); + let result = std::pin::Pin::new(&mut *self.inner).poll_read(cx, buf); + if buf.filled().len() > before { + self.activity.notify_one(); + } + result + } +} +fn configure_bound_session( + rt: &Runtime, + s: &SharedSession, + client_id: Option, + csi_inactive: bool, +) { + s.lock().unwrap().ack_limit = + number(&rt.config, &rt.domain, "smacks_max_unacked", 100) as usize; + s.lock().unwrap().client_id = client_id.clone(); + if rt.enabled("csi_simple") { + let limit = number(&rt.config, &rt.domain, "csi_queue_size", 256) as usize; + let delay = period( + rt.config.option(&rt.domain, "csi_resume_inactive_delay"), + Some(5.0), + ) + .unwrap_or(5.0) + .max(0.0); + let payloads = rt + .config + .option(&rt.domain, "csi_important_payloads") + .and_then(|v| v.as_array()) + .map(|v| { + v.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + s.lock() + .unwrap() + .csi + .configure(limit, Duration::from_secs_f64(delay), payloads); + } + s.lock().unwrap().csi.set_inactive(csi_inactive); +} +fn bind2_client_uuid(id: &str) -> Option { + uuid::Uuid::parse_str(id) + .ok() + .filter(|id| id.get_version_num() == 4 && id.get_variant() == uuid::Variant::RFC4122) +} +fn retire_bind2_client_sessions( + rt: &Runtime, + owner: &str, + full: &str, + token: &str, + client_id: Option<&str>, +) { + let Some(id) = client_id.and_then(bind2_client_uuid) else { + return; + }; + for previous in rt.hub.all() { + let connection = { + let mut previous = previous.lock().unwrap(); + if previous.owner != owner + || previous.jid == full + || previous.connection == token + || previous.client_id.as_deref().and_then(bind2_client_uuid) != Some(id) + { + continue; + } + previous.closed = true; + previous.sender = None; + previous.cancel.notify_one(); + previous.connection.clone() + }; + // The old connection's eventual Cleanup sees no sessions; the new token is excluded. + for jid in rt.hub.disconnect(&connection, true) { + rt.disconnected(&jid); + } + } +} +async fn close_retired_transport( + stream: &mut S, + session: Option<&SharedSession>, + token: &str, +) -> Result { + let reason = session.and_then(|s| { + let state = s.lock().unwrap(); + if state.connection != token { + Some(Some("conflict".to_owned())) + } else if state.closed { + Some(state.close_condition.clone()) + } else { + None + } + }); + if let Some(condition) = reason { + if let Some(condition) = condition { + send(stream, &format!("<{} xmlns='urn:ietf:params:xml:ns:xmpp-streams'/>", escape(&condition))).await?; + } + send(stream, "").await?; + return Ok(true); + } + Ok(false) +} +async fn authenticated_transport( + stream: &mut S, + rt: Runtime, + cb: Vec, + ctx: &mut ConnectionLog, +) -> Result<()> { + let mut f = Framer::default(); + let (user, sasl2, client_id, bind2, resume, mut success) = + match authenticate(stream, &mut f, &rt, cb, ctx).await { + Err(e) if e.is::() => return Ok(()), + result => result?, + }; + let owner = format!("{user}@{}", rt.domain); + ctx.phase = "binding"; + let token = ctx.id.clone(); + let mut cleanup = Cleanup { + runtime: rt.clone(), + token: token.clone(), + // A failure before success must remove even a resumable provisional session. + graceful: true, + }; + let (tx, mut rx) = mpsc::channel::(256); + let mut session: Option = None; + let mut jid = None::; + let mut csi_inactive = false; + let mut replay = Vec::new(); + let mut resumed_inline = false; + if let Some(request) = resume { + match rt + .hub + .resume(&owner, &request.id, request.h, &token, tx.clone()) + { + Ok((resumed, mut pending)) => { + let full = resumed.lock().unwrap().jid.clone(); + // Only the SM response belongs inside SASL2 success. Pending stanzas + // follow the completed negotiation, as in mod_sasl2_sm's finish hook. + let response = pending.remove(0); + success = success.replace("", &format!("{response}")); + success = success.replace( + &format!("{}", escape(&owner)), + &format!("{}", escape(&full)), + ); + replay = pending; + jid = Some(full); + session = Some(resumed); + resumed_inline = true; + ctx.phase = "session"; + ctx.event(Level::Info, "session-resumed", &[("mode", "sasl2-inline")]); + // Failed writes after takeover must leave this SM session resumable. + cleanup.graceful = false; + } + Err(_) => { + success = success.replace("", ""); + } + } + } + if let Some(request) = bind2.filter(|_| !resumed_inline) { + let current = crate::storage::load(&rt.data, &rt.domain, Some(&user), "accounts")?; + ensure!( + current + .as_ref() + .is_some_and(|a| a.get("disabled") != Some(&crate::storage::LuaValue::Bool(true))), + "account unavailable" + ); + let full = format!("{owner}/{}", request.resource); + let s = rt.bind_session(&full, &token, tx.clone())?; + let full = s.lock().unwrap().jid.clone(); + ctx.event( + Level::Info, + "resource-bound", + &[("jid", &full), ("mode", "bind2")], + ); + configure_bound_session(&rt, &s, client_id.clone(), request.inactive); + csi_inactive = request.inactive; + let mut bound = String::new(); + { + let mut session = s.lock().unwrap(); + session.carbons = request.carbons; + if request.carbons { + bound.push_str(""); + } + if let Some(resume) = request.sm { + bound.push_str(&session.enable(resume)?); + } + } + if rt.enabled("mam") { + let mut first = None; + let mut last = None; + crate::archive::scan_store(&rt.data, &rt.domain, Some(&user), "archive", |entry| { + if first.is_none() { + first = Some((entry.id.clone(), entry.when)); + } + last = Some((entry.id, entry.when)); + Ok(()) + })?; + bound.push_str(""); + for (element, edge) in [("start", first), ("end", last)] { + if let Some((id, when)) = edge { + bound.push_str(&format!( + "<{element} id='{}' timestamp='{}'/>", + escape(&id), + crate::archive::timestamp(when)? + )); + } + } + bound.push_str(""); + } + // Bind2 clients synchronise the archive rather than receive offline replay. + let pending = crate::account_modules::offline_pending(&rt.data, &rt.domain, &user)?; + let ids: Vec<_> = pending.into_iter().map(|(id, _)| id).collect(); + if !ids.is_empty() { + crate::archive::delete_ids(&rt.data, &rt.domain, Some(&user), "offline", &ids)?; + } + refresh_push_session(&rt, &s)?; + success = success.replace( + &format!("{}", escape(&owner)), + &format!("{}", escape(&full)), + ); + success = success.replace( + "", + &format!("{bound}"), + ); + jid = Some(full); + session = Some(s); + } + send(stream, &success).await?; + ctx.event(Level::Debug, "sasl-success-sent", &[]); + if session.is_some() { + ctx.phase = "session"; + } + cleanup.graceful = false; + if let Some(full) = &jid + && !resumed_inline + { + retire_bind2_client_sessions(&rt, &owner, full, &token, client_id.as_deref()); + } + for xml in replay { + send(stream, &xml).await?; + } + let mut features = if session.is_none() { + format!("") + } else { + String::new() + }; + if rt.enabled("smacks") && !session.as_ref().is_some_and(|s| s.lock().unwrap().sm) { + features.push_str(""); + } + if rt.enabled("csi_simple") { + features.push_str(""); + } + if rt.enabled("roster") { + features.push_str(""); + } + if sasl2 { + send( + stream, + &format!("{features}"), + ) + .await?; + } else { + open(stream, &mut f, &rt.domain, &features).await?; + } + if let Some(full) = &jid + && !resumed_inline + && (rt.enabled("vcard_legacy") || rt.enabled("bookmarks")) + { + let result = crate::account_modules::legacy_on_bind(&rt.data, &rt.domain, full)?; + apply(stream, session.as_ref(), &rt, result).await?; + } + let input_activity = Arc::new(tokio::sync::Notify::new()); + loop { + // Capture the notifier before checking ownership. A concurrent resume then + // either fails the ownership check or leaves a permit on this old notifier. + let cancellation = session.as_ref().map(|s| s.lock().unwrap().cancel.clone()); + if close_retired_transport(stream, session.as_ref(), &token).await? { + cleanup.graceful = true; + break; + } + let mut reader = ActivityReader { + inner: &mut *stream, + activity: input_activity.clone(), + }; + let text = tokio::select! {biased; _=async{if let Some(n)=cancellation{n.notified().await}else{std::future::pending::<()>().await}}=>{close_retired_transport(stream,session.as_ref(),&token).await?;cleanup.graceful=true;break;},input=timeout(Duration::from_secs(600),f.next(&mut reader))=>input??,_=input_activity.notified()=>{if close_retired_transport(stream,session.as_ref(),&token).await?{cleanup.graceful=true;break;}csi_activity(stream,session.as_ref()).await?;continue;},output=rx.recv()=>{if let Some(xml)=output{if close_retired_transport(stream,session.as_ref(),&token).await?{cleanup.graceful=true;break;}csi_send(stream,session.as_ref(),&xml).await?;if session.as_ref().is_some_and(|s|s.lock().unwrap().request_ack()){csi_send(stream,session.as_ref(),"").await?;}}else{break;}continue;}}; + if close_retired_transport(stream, session.as_ref(), &token).await? { + cleanup.graceful = true; + break; + } + csi_activity(stream, session.as_ref()).await?; + if f.is_stream_end(&text) { + cleanup.graceful = true; + send(stream, "").await?; + break; + } + let w = f.wrap(&text); + let d = roxmltree::Document::parse(&w)?; + let n = d + .root_element() + .first_element_child() + .context("stanza missing")?; + let ns = n.tag_name().namespace().unwrap_or(""); + let kind = n.tag_name().name(); + if ns == "urn:xmpp:sm:3" && rt.enabled("smacks") { + if kind == "resume" && session.is_none() { + let h = n.attribute("h").unwrap_or("").parse::(); + let id = n.attribute("previd").unwrap_or(""); + if let Ok(h) = h + && let Ok((resumed, replay)) = rt.hub.resume(&owner, id, h, &token, tx.clone()) + { + jid = Some(resumed.lock().unwrap().jid.clone()); + for xml in replay { + send(stream, &xml).await?; + } + session = Some(resumed); + ctx.phase = "session"; + ctx.event(Level::Info, "session-resumed", &[("mode", "sm")]); + continue; + } + send(stream,"").await?; + continue; + } + if let Some(s) = &session { + let answer = { + let mut s = s.lock().unwrap(); + match kind { + "enable" => { + s.enable(matches!(n.attribute("resume"), Some("true" | "1")))? + } + "r" if s.sm => format!("", s.handled), + "a" if s.sm => { + s.acknowledge(n.attribute("h").context("SM h")?.parse()?)?; + String::new() + } + _ => bail!("invalid stream management state"), + } + }; + if !answer.is_empty() { + send(stream, &answer).await?; + } + continue; + } + send(stream,"").await?; + continue; + } + if ns == "urn:xmpp:csi:0" + && rt.enabled("csi_simple") + && 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(); + for xml in output { + send(stream, &xml).await?; + } + continue; + } + ensure!( + ns == CLIENT && matches!(kind, "iq" | "message" | "presence"), + "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); + } + } + if kind == "iq" { + let typ = n.attribute("type").unwrap_or(""); + if matches!(typ, "result" | "error") { + let full = jid.as_deref().context("binding required")?; + if rt.enabled("pep") + && let Some(result) = + crate::pubsub::on_disco_result(&rt.data, &rt.domain, full, n)? + { + apply(stream, session.as_ref(), &rt, result).await?; + continue; + } + if let Some(to) = n.attribute("to") + && !blocked(&rt, &user, full, to)? + { + let _ = route_delivery(&rt, to, &routed(n, full, to)).await; + } + continue; + } + let q = n.first_element_child().context("IQ payload missing")?; + let ns = q.tag_name().namespace().unwrap_or(""); + if session.is_none() && typ == "set" && ns == BIND && q.tag_name().name() == "bind" { + let resource = q + .children() + .find(|v| v.is_element() && v.tag_name().name() == "resource") + .and_then(|v| v.text()) + .map(str::to_owned) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let resource = stringprep::resourceprep(&resource) + .map_err(|_| anyhow::anyhow!("invalid resource"))?; + ensure!( + !resource.is_empty() && resource.len() <= 1023, + "invalid resource" + ); + let current = crate::storage::load(&rt.data, &rt.domain, Some(&user), "accounts")?; + ensure!( + current.as_ref().is_some_and( + |a| a.get("disabled") != Some(&crate::storage::LuaValue::Bool(true)) + ), + "account unavailable" + ); + let full = format!("{owner}/{resource}"); + match rt.bind_session(&full, &token, tx.clone()) { + Ok(s) => { + let full = s.lock().unwrap().jid.clone(); + ctx.phase = "session"; + ctx.event( + Level::Info, + "resource-bound", + &[("jid", &full), ("mode", "legacy")], + ); + emit( + stream, + None, + &protocol::iq_result( + n, + &format!( + "{}", + escape(&full) + ), + ), + ) + .await?; + configure_bound_session(&rt, &s, client_id.clone(), csi_inactive); + refresh_push_session(&rt, &s)?; + jid = Some(full); + session = Some(s); + if rt.enabled("vcard_legacy") || rt.enabled("bookmarks") { + let result = crate::account_modules::legacy_on_bind( + &rt.data, + &rt.domain, + jid.as_deref().unwrap(), + )?; + apply(stream, session.as_ref(), &rt, result).await?; + } + } + Err(_) => { + ctx.event( + Level::Warn, + "bind-rejected", + &[("reason", "resource-conflict"), ("jid", &full)], + ); + emit(stream, None, &protocol::iq_error(n, "conflict")).await?; + } + } + continue; + } + let full = jid.as_deref().context("binding required")?; + if let Some(to) = n.attribute("to") { + if blocked(&rt, &user, full, to)? { + emit( + stream, + session.as_ref(), + &protocol::iq_error(n, "service-unavailable"), + ) + .await?; + continue; + } + if to.contains('/') && to != full { + if rt.hub.get(to).is_some() { + rt.hub.deliver(to, &routed(n, full, to)); + continue; + } else if rt.owner(to).is_some() { + emit( + stream, + session.as_ref(), + &protocol::iq_error(n, "recipient-unavailable"), + ) + .await?; + continue; + } + } + } + if ns == "urn:xmpp:carbons:2" + && rt.enabled("carbons") + && typ == "set" + && matches!(q.tag_name().name(), "enable" | "disable") + { + session.as_ref().unwrap().lock().unwrap().carbons = q.tag_name().name() == "enable"; + emit(stream, session.as_ref(), &protocol::iq_result(n, "")).await?; + continue; + } + if ns == "jabber:iq:roster" && typ == "get" { + session.as_ref().unwrap().lock().unwrap().roster_interested = true; + } + if n.attribute("to") + .and_then(|to| protocol::bare(to).rsplit_once('@').map(|(_, d)| d)) + .is_some_and(|d| d != rt.domain && !rt.config.hosts.contains_key(d)) + { + let to = n.attribute("to").unwrap(); + if route_delivery(&rt, to, &routed(n, full, to)).await.is_err() { + emit( + stream, + session.as_ref(), + &protocol::iq_error(n, "remote-server-not-found"), + ) + .await?; + } + continue; + } + let result = match dispatch_iq(&rt, full, n) { + Ok(Some(r)) => r, + Ok(None) => ModuleResult::reply(protocol::iq_error(n, "service-unavailable")), + Err(error) => { + let io_kind = error + .downcast_ref::() + .map(|e| format!("{:?}", e.kind())) + .unwrap_or_else(|| "none".into()); + ctx.event(Level::Warn, "iq-dispatch-failed", &[("io_kind", &io_kind)]); + ModuleResult::reply(protocol::iq_error(n, "internal-server-error")) + } + }; + if let Some(fields) = pep_iq_metadata(n, &result, &rt.domain, full) { + let values: Vec<_> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect(); + ctx.event(Level::Info, "pubsub-iq-result", &values); + } + apply(stream, session.as_ref(), &rt, result).await?; + } else { + let full = jid.as_deref().context("binding required")?; + let muc_result = { + let mut rooms = rt.mucs.lock().unwrap(); + let mut found = None; + for muc in rooms.values_mut() { + if let Some(result) = muc.handle(full, n)? { + found = Some(result); + break; + } + } + found + }; + if let Some(result) = muc_result { + apply(stream, session.as_ref(), &rt, result).await?; + continue; + } + if kind == "presence" { + if rt.enabled("roster") + && let Some(result) = + crate::account_modules::subscription(&rt.data, &rt.domain, full, n)? + { + apply(stream, session.as_ref(), &rt, result).await?; + continue; + } + if n.attribute("to").is_none() { + let avatar_xml = if rt.enabled("vcard_legacy") { + crate::account_modules::avatar_presence(&rt.data, &rt.domain, full, n)? + } else { + protocol::xml(n) + }; + let available = n.attribute("type").is_none(); + let priority = n + .children() + .find(|v| v.is_element() && v.tag_name().name() == "priority") + .and_then(|v| v.text()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + { + let mut s = session.as_ref().unwrap().lock().unwrap(); + s.available = available; + s.priority = priority; + s.presence = available.then(|| avatar_xml.clone()); + } + if available { + let recovered = { + let mut mucs = rt.mucs.lock().unwrap(); + mucs.values_mut() + .map(|muc| muc.restore_for_session(full)) + .collect::>>()? + }; + for result in recovered { + if !result.replies.is_empty() { + ctx.event(Level::Info, "muc-membership-restored", &[("jid", full)]); + } + apply(stream, session.as_ref(), &rt, result).await?; + } + } + if rt.enabled("roster") { + for contact in + crate::account_modules::subscribers(&rt.data, &rt.domain, &user)? + { + let d = roxmltree::Document::parse(&avatar_xml)?; + let _ = route_delivery( + &rt, + &contact, + &routed(d.root_element(), full, &contact), + ) + .await; + } + if available { + for contact in + crate::account_modules::subscriptions(&rt.data, &rt.domain, &user)? + { + for s in rt.hub.all() { + let snapshot = { + let s = s.lock().unwrap(); + (s.owner == contact && s.available) + .then(|| (s.jid.clone(), s.presence.clone())) + .and_then(|(j, p)| p.map(|p| (j, p))) + }; + if let Some((sender, p)) = snapshot { + let w = wrap(&p); + let d = roxmltree::Document::parse(&w)?; + if let Some(n) = d.root_element().first_element_child() { + rt.hub.deliver(full, &routed(n, &sender, full)); + } + } + } + } + } + } + if rt.enabled("pep") { + let result = crate::pubsub::on_presence(&rt.data, &rt.domain, full, n)?; + apply(stream, session.as_ref(), &rt, result).await?; + } + if available { + for request in crate::account_modules::pending_subscriptions( + &rt.data, &rt.domain, &user, + )? { + emit(stream, session.as_ref(), &request).await?; + } + } + if available && priority >= 0 { + let pending = + crate::account_modules::offline_pending(&rt.data, &rt.domain, &user)?; + let mut delivered = vec![]; + for (id, xml) in pending { + emit(stream, session.as_ref(), &xml).await?; + delivered.push(id); + } + if !delivered.is_empty() { + crate::archive::delete_ids( + &rt.data, + &rt.domain, + Some(&user), + "offline", + &delivered, + )?; + } + } + continue; + } + } + let Some(to) = n.attribute("to") else { + continue; + }; + let local = rt.owner(to); + if crate::account_modules::is_blocked(&rt.data, &rt.domain, &user, to)? { + continue; + } + if let Some(target_user) = &local + && crate::account_modules::is_blocked(&rt.data, &rt.domain, target_user, full)? + { + continue; + } + let mut xml = routed(n, full, to); + let mut sent_xml = xml.clone(); + if kind == "presence" { + let _ = route_delivery(&rt, to, &xml).await; + continue; + } + let remote = local.is_none() + && !rt.config.hosts.contains_key( + protocol::bare(to) + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or(to), + ); + let typ = n.attribute("type").unwrap_or("normal"); + if rt.enabled("mam") { + xml = crate::mam::strip_owner_stanza_ids(&xml, &rt.domain, &user)?; + let now = chrono::Utc::now().timestamp_millis() as f64 / 1000.0; + if crate::mam::should_store(n) + && crate::mam::should_archive_with_options( + &rt.data, + &rt.domain, + &user, + protocol::bare(to), + archive_policy(&rt), + boolean(&rt.config, &rt.domain, "mam_smart_enable", false), + )? + { + let (_, stamped) = crate::mam::archive_for_user( + &rt.data, + &rt.domain, + &user, + protocol::bare(to), + &xml, + now, + )?; + sent_xml = stamped; + } else { + sent_xml = xml.clone(); + } + if let Some(u) = &local { + xml = crate::mam::strip_owner_stanza_ids(&xml, &rt.domain, u)?; + if u == &user { + xml = sent_xml.clone(); + } else if crate::storage::load(&rt.data, &rt.domain, Some(u), "accounts")? + .is_some() + && crate::mam::should_store(n) + && crate::mam::should_archive_with_options( + &rt.data, + &rt.domain, + u, + &owner, + archive_policy(&rt), + boolean(&rt.config, &rt.domain, "mam_smart_enable", false), + )? + { + let (_, stamped) = crate::mam::archive_for_user( + &rt.data, &rt.domain, u, &owner, &xml, now, + )?; + xml = stamped; + } + } + } + let remote_delivered = remote && route_delivery(&rt, to, &xml).await.is_ok(); + let targets = if local.is_some() { + rt.hub.message_targets_for_type(to, typ) + } else { + vec![] + }; + let mut delivered = usize::from(remote_delivered); + let mut target_jids = vec![]; + for target in targets { + let mut target = target.lock().unwrap(); + if target.transmit(xml.clone()).is_ok() { + delivered += 1; + target_jids.push(target.jid.clone()); + } + } + if rt.enabled("carbons") + && matches!(typ, "chat" | "normal") + && !n.children().any(|n| { + n.tag_name().namespace() == Some("urn:xmpp:carbons:2") + && n.tag_name().name() == "private" + }) + { + for s in rt.hub.all() { + let mut s = s.lock().unwrap(); + if !s.carbons || s.jid == full || target_jids.contains(&s.jid) { + continue; + } + let direction = if s.owner == owner { + Some("sent") + } else if s.owner == protocol::bare(to) && delivered > 0 { + Some("received") + } else { + None + }; + if let Some(direction) = direction { + let carbon_xml = if direction == "sent" { &sent_xml } else { &xml }; + let carbon = format!( + "<{direction} xmlns='urn:xmpp:carbons:2'>{carbon_xml}", + escape(&s.owner), + escape(&s.jid) + ); + let _ = s.transmit(carbon); + } + } + } + if rt.enabled("cloud_notify") + && typ != "error" + && let Some(u) = &local + { + let doc = roxmltree::Document::parse(&xml)?; + let pushes = message_pushes(&rt, u, to, delivered, doc.root_element())?; + for (to, payload) in pushes { + let _ = route_delivery(&rt, &to, &payload).await; + } + } + + if delivered == 0 && !matches!(typ, "error" | "headline") { + let queued = if let Some(u) = local { + crate::account_modules::offline_enqueue(&rt.data, &rt.domain, &u, &xml)? + } else { + false + }; + if !queued { + emit(stream,session.as_ref(),&format!("",escape(n.attribute("id").unwrap_or("")),escape(to),escape(full))).await?; + } + } + } + } + Ok(()) +} +fn period(value: Option<&serde_json::Value>, default: Option) -> Option { + match value { + Some(serde_json::Value::Number(n)) => n.as_f64(), + Some(serde_json::Value::String(s)) => { + if s == "never" { + return None; + } + for (unit, scale) in [ + ("s", 1.0), + ("m", 60.0), + ("h", 3600.0), + ("d", 86400.0), + ("w", 604800.0), + ] { + if let Some(n) = s + .strip_suffix(unit) + .and_then(|n| n.trim().parse::().ok()) + { + return Some(n * scale); + } + } + default + } + _ => default, + } +} +async fn route_delivery(rt: &Runtime, to: &str, xml: &str) -> Result { + let domain = protocol::bare(to) + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or(protocol::bare(to)); + if rt.config.hosts.contains_key(domain) { + return Ok(rt.hub.deliver(to, xml)); + } + let fed = rt.federation.as_ref().context("federation disabled")?; + let d = roxmltree::Document::parse(xml)?; + let from = d + .root_element() + .attribute("from") + .context("federation requires from")?; + let from_domain = protocol::bare(from) + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or(from); + fed.route(from_domain, to, xml).await?; + Ok(1) +} +async fn incoming_federated(rt: Runtime, stanza: crate::federation::FederatedStanza) -> Result<()> { + let d = roxmltree::Document::parse(&stanza.xml)?; + let xml = protocol::xml_namespace(d.root_element(), Some(("jabber:server", CLIENT))); + let d = roxmltree::Document::parse(&xml)?; + let n = d.root_element(); + let from = n.attribute("from").context("federated source")?; + let to = n.attribute("to").context("federated target")?; + let from_domain = protocol::bare(from) + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or(from); + ensure!(from_domain == stanza.from_domain, "unverified source"); + let dest_domain = protocol::bare(to) + .rsplit_once('@') + .map(|(_, d)| d) + .unwrap_or(to); + ensure!( + rt.config.hosts.contains_key(dest_domain), + "not a local destination" + ); + if let Some(user) = rt.owner(to) + && crate::account_modules::is_blocked(&rt.data, &rt.domain, &user, from)? + { + return Ok(()); + } + if n.tag_name().name() == "iq" && matches!(n.attribute("type"), Some("result" | "error")) { + if rt.enabled("pep") + && let Some(result) = crate::pubsub::on_disco_result(&rt.data, &rt.domain, from, n)? + { + for (to, xml) in result.deliveries { + route_delivery(&rt, &to, &xml).await?; + } + return Ok(()); + } + if rt.enabled("cloud_notify") { + let consumed = rt.push.lock().unwrap().result( + &rt.data, + &rt.domain, + n, + number(&rt.config, &rt.domain, "push_max_errors", 16), + )?; + remove_push_sessions(&rt); + if consumed { + return Ok(()); + } + } + rt.hub.deliver(to, &xml); + return Ok(()); + } + if n.tag_name().name() == "iq" { + if rt.hub.get(to).is_some() { + rt.hub.deliver(to, &xml); + return Ok(()); + } + let q = n.first_element_child().context("IQ payload")?; + if matches!( + q.tag_name().namespace(), + Some( + "urn:xmpp:extdisco:1" + | "urn:xmpp:extdisco:2" + | "jabber:iq:register" + | "urn:xmpp:push:0" + ) + ) { + let error = protocol::iq_error(n, "forbidden"); + let d = roxmltree::Document::parse(&error)?; + let reply = routed(d.root_element(), to, from); + route_delivery(&rt, from, &reply).await?; + return Ok(()); + } + let result = dispatch_iq(&rt, from, n)? + .unwrap_or_else(|| ModuleResult::reply(protocol::iq_error(n, "service-unavailable"))); + for reply in result.replies { + let d = roxmltree::Document::parse(&reply)?; + route_delivery(&rt, from, &routed(d.root_element(), to, from)).await?; + } + for (to, xml) in result.deliveries { + route_delivery(&rt, &to, &xml).await?; + } + return Ok(()); + } + let muc_result = { + let mut rooms = rt.mucs.lock().unwrap(); + let mut out = None; + for room in rooms.values_mut() { + if let Some(result) = room.handle(from, n)? { + out = Some(result); + break; + } + } + out + }; + if let Some(result) = muc_result { + for reply in result.replies { + let d = roxmltree::Document::parse(&reply)?; + let addressed = routed(d.root_element(), to, from); + route_delivery(&rt, from, &addressed).await?; + } + for (to, xml) in result.deliveries { + route_delivery(&rt, &to, &xml).await?; + } + return Ok(()); + } + if n.tag_name().name() == "presence" { + if let Some(result) = + crate::account_modules::inbound_subscription(&rt.data, &rt.domain, from, n)? + { + for (to, xml) in result.deliveries { + route_delivery(&rt, &to, &xml).await?; + } + return Ok(()); + } + if rt.enabled("pep") { + let result = crate::pubsub::on_remote_presence(&rt.data, &rt.domain, from, n)?; + for (to, xml) in result.deliveries { + route_delivery(&rt, &to, &xml).await?; + } + } + rt.hub.deliver(to, &xml); + return Ok(()); + } + if n.tag_name().name() == "message" { + let Some(user) = rt.owner(to) else { + return Ok(()); + }; + let typ = n.attribute("type").unwrap_or("normal"); + let mut delivery_xml = xml.clone(); + if rt.enabled("mam") { + delivery_xml = crate::mam::strip_owner_stanza_ids(&delivery_xml, &rt.domain, &user)?; + } + if rt.enabled("mam") + && crate::mam::should_store(n) + && crate::mam::should_archive_with_options( + &rt.data, + &rt.domain, + &user, + protocol::bare(from), + archive_policy(&rt), + boolean(&rt.config, &rt.domain, "mam_smart_enable", false), + )? + { + delivery_xml = crate::mam::archive_for_user( + &rt.data, + &rt.domain, + &user, + protocol::bare(from), + &delivery_xml, + chrono::Utc::now().timestamp_millis() as f64 / 1000.0, + )? + .1; + } + let targets = rt.hub.message_targets_for_type(to, typ); + let mut delivered = 0; + for s in targets { + if s.lock().unwrap().transmit(delivery_xml.clone()).is_ok() { + delivered += 1; + } + } + if delivered == 0 && typ != "error" { + crate::account_modules::offline_enqueue(&rt.data, &rt.domain, &user, &delivery_xml)?; + } + let notifications = message_pushes(&rt, &user, to, delivered, n)?; + for (to, xml) in notifications { + let _ = route_delivery(&rt, &to, &xml).await; + } + } + Ok(()) +} +fn blocked(rt: &Runtime, user: &str, from: &str, to: &str) -> Result { + if crate::account_modules::is_blocked(&rt.data, &rt.domain, user, to)? { + return Ok(true); + } + if let Some(u) = rt.owner(to) + && crate::account_modules::is_blocked(&rt.data, &rt.domain, &u, from)? + { + return Ok(true); + } + Ok(false) +} +pub fn admin_path(c: &Config, domain: &str, data: &Path) -> PathBuf { + let value = c + .option(domain, "admin_socket") + .and_then(|v| v.as_str()) + .unwrap_or("prosody.sock"); + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + data.join(path) + } +} +fn maintenance_run(rt: &Runtime) -> Result<()> { + for upload in rt.uploads.values() { + upload.prune()?; + } + let now = chrono::Utc::now().timestamp_millis() as f64 / 1000.0; + for muc in rt.mucs.lock().unwrap().values_mut() { + muc.maintenance(now)?; + } + for (host, h) in &rt.config.hosts { + let (store, retention) = if h.component.is_none() { + ( + "archive", + period( + rt.config.option(host, "archive_expires_after"), + Some(604800.0), + ), + ) + } else { + continue; + }; + let Some(age) = retention else { continue }; + let dir = rt.data.join(crate::storage::encode_name(host)).join(store); + if !dir.exists() { + continue; + } + for entry in fs::read_dir(dir)? { + let e = entry?; + if !e.file_type()?.is_file() + || e.path().extension().and_then(|s| s.to_str()) != Some("list") + { + continue; + } + let Some(encoded) = e + .path() + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_owned) + else { + continue; + }; + let mut bytes = vec![]; + let mut i = 0; + while i < encoded.len() { + if encoded.as_bytes()[i] == b'%' && i + 2 < encoded.len() { + bytes.push(u8::from_str_radix(&encoded[i + 1..i + 3], 16)?); + i += 3; + } else { + bytes.push(encoded.as_bytes()[i]); + i += 1; + } + } + let user = String::from_utf8(bytes)?; + crate::archive::expire_before(&rt.data, host, Some(&user), store, now - age)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod authorization_tests { + use super::*; + #[test] + fn http_interfaces_follow_secure_override_wildcards_and_global_fallback() { + let config: Config = serde_json::from_value(serde_json::json!({"global":{"interfaces":["127.0.0.1"],"https_interfaces":["*","0.0.0.0"],"http_interfaces":["::1"]},"hosts":{"example.test":{"options":{}}}})).unwrap(); + assert_eq!( + http_interfaces(&config, "example.test", true).unwrap(), + vec![ + "0.0.0.0".parse::().unwrap(), + "::".parse().unwrap() + ] + ); + assert_eq!( + http_interfaces(&config, "example.test", false).unwrap(), + vec!["::1".parse::().unwrap()] + ); + let config: Config = serde_json::from_value(serde_json::json!({"global":{"interfaces":["127.0.0.1"]},"hosts":{"example.test":{"options":{}}}})).unwrap(); + assert_eq!( + http_interfaces(&config, "example.test", true).unwrap(), + vec!["127.0.0.1".parse::().unwrap()] + ); + } + #[test] + fn pep_diagnostics_exclude_payloads_and_identifiers() { + let request = roxmltree::Document::parse("PRIVATE-KEY").unwrap(); + let result = ModuleResult::reply(protocol::iq_error(request.root_element(), "forbidden")); + let fields = pep_iq_metadata( + request.root_element(), + &result, + "example.test", + "alice@example.test/phone", + ) + .unwrap(); + assert!(fields.contains(&("node_kind", "omemo-bundle".into()))); + assert!(fields.contains(&("status", "error".into()))); + assert!(fields.contains(&("condition", "forbidden".into()))); + assert!(!format!("{fields:?}").contains("PRIVATE")); + let query = roxmltree::Document::parse( + "", + ) + .unwrap(); + let reply = ModuleResult::reply("".into()); + let fields = pep_iq_metadata( + query.root_element(), + &reply, + "example.test", + "alice@example.test/phone", + ) + .unwrap(); + assert!(fields.contains(&("pep_identity", "true".into()))); + assert!(fields.contains(&("persistent_items", "true".into()))); + assert!(fields.contains(&("publish_options", "false".into()))); + } + #[test] + fn pep_diagnostics_count_only_first_item_devices_without_logging_ids() { + let query = roxmltree::Document::parse("").unwrap(); + let reply = ModuleResult::reply("".into()); + let fields = pep_iq_metadata( + query.root_element(), + &reply, + "example.test", + "alice@example.test/phone", + ) + .unwrap(); + assert!(fields.contains(&("items_count", "2".into()))); + assert!(fields.contains(&("first_content_kind", "omemo-devices".into()))); + assert!(fields.contains(&("first_device_count", "1".into()))); + assert!(fields.contains(&("target_scope", "self-account".into()))); + assert!(!format!("{fields:?}").contains("PRIVATE")); + } + #[test] + fn upload_requires_registered_local_account_or_explicit_remote_access() { + let root = tempfile::tempdir().unwrap(); + let config: Config = serde_json::from_value(serde_json::json!({"global":{},"hosts":{"local.test":{"options":{}},"upload.local.test":{"component":"http_file_share","options":{"http_file_share_access":["trusted.test","friend@remote.test"]}}}})).unwrap(); + auth::create(root.path(), "local.test", "alice", "secret", "SHA-256").unwrap(); + let allowed = |jid| { + upload_allowed(root.path(), &config, "local.test", "upload.local.test", jid).unwrap() + }; + assert!(allowed("alice@local.test/mobile")); + assert!(!allowed("unknown@local.test/mobile")); + assert!(!allowed("alice@evil.test/mobile")); + assert!(allowed("user@trusted.test/mobile")); + assert!(allowed("friend@remote.test/mobile")); + assert!(!allowed("stranger@remote.test/mobile")); + } +} diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..cd347ed --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,478 @@ +use crate::protocol::bare; +use anyhow::{Result, ensure}; +use std::{ + collections::{BTreeMap, VecDeque}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc; + +#[derive(Clone, Debug)] +pub struct Session { + pub jid: String, + pub owner: String, + pub client_id: Option, + pub push_identifier: Option, + pub first_push_sent: bool, + pub connection: String, + pub sender: Option>, + pub available: bool, + pub priority: i8, + pub presence: Option, + pub carbons: bool, + pub csi: crate::csi::Buffer, + pub roster_interested: bool, + pub sm: bool, + pub resume_id: Option, + pub handled: u32, + pub sent: u32, + pub acked: u32, + pub ack_limit: usize, + pub ack_requested: bool, + pub pending: VecDeque, + pub expires: Option, + pub timeout: Duration, + pub closed: bool, + pub close_condition: Option, + pub cancel: Arc, +} +impl Session { + 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; + self.expires = Some(Instant::now() + normal_timeout); + } + } + 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 { + 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()); + } + 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); + } + Ok(()) + } + pub fn request_ack(&mut self) -> bool { + if self.sm && self.pending.len() >= self.ack_limit.max(1) && !self.ack_requested { + self.ack_requested = true; + true + } else { + false + } + } + pub fn acknowledge(&mut self, h: u32) -> Result<()> { + ensure!(!self.closed, "session closed"); + let n = h.wrapping_sub(self.acked) as usize; + ensure!(n <= self.pending.len(), "invalid stream acknowledgement"); + self.pending.drain(..n); + self.acked = h; + self.ack_requested = false; + Ok(()) + } + pub fn enable(&mut self, resume: bool) -> Result { + ensure!(!self.closed, "session closed"); + ensure!(!self.sm, "stream management already enabled"); + self.sm = true; + if resume { + self.resume_id = Some(uuid::Uuid::new_v4().to_string()); + } + Ok(match &self.resume_id { + Some(id) => format!( + "", + self.timeout.as_secs() + ), + None => "".into(), + }) + } +} +pub type SharedSession = Arc>; +/// Prosody's conflict_resolve policy; unspecified values use kick_old. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConflictPolicy { + KickOld, + KickNew, + Increment, + Random, +} +impl ConflictPolicy { + pub fn from_config(value: Option<&str>) -> Self { + match value { + Some("kick_new") => Self::KickNew, + Some("increment") => Self::Increment, + Some("random") => Self::Random, + _ => Self::KickOld, + } + } +} +#[derive(Clone, Default)] +pub struct Hub { + sessions: Arc>>, +} +impl Hub { + pub fn bind( + &self, + jid: &str, + connection: &str, + sender: mpsc::Sender, + timeout: Duration, + ) -> Result { + self.bind_resolving(jid, connection, sender, timeout, ConflictPolicy::KickNew) + .map(|(session, _)| session) + } + /// Choose and reserve the resource under the same lock as replacement. + pub fn bind_resolving( + &self, + jid: &str, + connection: &str, + sender: mpsc::Sender, + timeout: Duration, + policy: ConflictPolicy, + ) -> Result<(SharedSession, Option)> { + let mut map = self.sessions.lock().unwrap(); + let mut jid = jid.to_owned(); + let mut replaced = None; + if map.contains_key(&jid) { + match policy { + ConflictPolicy::KickNew => anyhow::bail!("resource conflict"), + ConflictPolicy::KickOld => { + let old = map.remove(&jid).unwrap(); + { + let mut state = old.lock().unwrap(); + state.closed = true; + state.close_condition = Some("conflict".into()); + state.sender = None; + state.resume_id = None; + state.expires = None; + state.cancel.notify_one(); + } + replaced = Some(old); + } + ConflictPolicy::Increment => { + let base = jid.clone(); + let mut count = 1; + while map.contains_key(&format!("{base}#{count}")) { + count += 1; + } + jid = format!("{base}#{count}"); + } + ConflictPolicy::Random => { + let owner = bare(&jid).to_owned(); + loop { + jid = format!("{owner}/{}", uuid::Uuid::new_v4()); + if !map.contains_key(&jid) { + break; + } + } + } + } + } + let s = Arc::new(Mutex::new(Session { + jid: jid.clone(), + owner: bare(&jid).into(), + client_id: None, + push_identifier: None, + first_push_sent: false, + connection: connection.into(), + sender: Some(sender), + available: false, + priority: 0, + presence: None, + carbons: false, + csi: Default::default(), + roster_interested: false, + sm: false, + resume_id: None, + handled: 0, + sent: 0, + acked: 0, + ack_limit: 100, + ack_requested: false, + pending: VecDeque::new(), + expires: None, + timeout, + closed: false, + close_condition: None, + cancel: Default::default(), + })); + map.insert(jid, s.clone()); + Ok((s, replaced)) + } + 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 + } + }); + } + pub fn get(&self, jid: &str) -> Option { + self.sessions.lock().unwrap().get(jid).cloned() + } + pub fn all(&self) -> Vec { + self.sessions.lock().unwrap().values().cloned().collect() + } + pub fn deliver(&self, to: &str, xml: &str) -> usize { + let roster_push = roxmltree::Document::parse(xml).ok().is_some_and(|d| { + d.root_element() + .attribute("id") + .is_some_and(|id| id.starts_with("roster-")) + }); + self.all() + .into_iter() + .filter(|s| { + let mut s = s.lock().unwrap(); + let matches = if to.contains('/') { + s.jid == to + } else { + s.owner == to + }; + matches && (!roster_push || s.roster_interested) && s.transmit(xml.into()).is_ok() + }) + .count() + } + pub fn message_targets(&self, to: &str) -> Vec { + self.message_targets_for_type(to, "normal") + } + /// Native mod_message first tries the exact full JID, then applies the + /// message-type-specific bare-JID policy when that resource is absent. + pub fn message_targets_for_type(&self, to: &str, kind: &str) -> Vec { + if to.contains('/') { + if let Some(session) = self.get(to) { + return vec![session]; + } + if matches!(kind, "headline" | "error" | "groupchat") { + return vec![]; + } + } else if matches!(kind, "error" | "groupchat") { + return vec![]; + } + let bare = to.split_once('/').map(|(bare, _)| bare).unwrap_or(to); + let mut max = None; + let mut out = vec![]; + for s in self.all() { + let p = { + let state = s.lock().unwrap(); + if state.owner != bare || !state.available || state.priority < 0 { + continue; + } + state.priority + }; + if kind == "headline" { + out.push(s); + continue; + } + if max.is_none_or(|v| p > v) { + max = Some(p); + out.clear(); + } + if max == Some(p) { + out.push(s); + } + } + out + } + pub fn disconnect(&self, connection: &str, graceful: bool) -> Vec { + let mut removed = vec![]; + self.sessions.lock().unwrap().retain(|jid, s| { + let mut s = s.lock().unwrap(); + if s.connection != connection { + return true; + } + if !graceful && s.sm && s.resume_id.is_some() { + s.csi.reset(); + s.sender = None; + s.expires = Some(Instant::now() + s.timeout); + true + } else { + removed.push(jid.clone()); + false + } + }); + removed + } + pub fn expired(&self) -> Vec { + let mut out = vec![]; + self.sessions.lock().unwrap().retain(|jid, s| { + if s.lock() + .unwrap() + .expires + .is_some_and(|t| Instant::now() >= t) + { + out.push(jid.clone()); + false + } else { + true + } + }); + out + } + pub fn resume( + &self, + owner: &str, + id: &str, + h: u32, + connection: &str, + sender: mpsc::Sender, + ) -> Result<(SharedSession, Vec)> { + self.expired(); + let mut map = self.sessions.lock().unwrap(); + let candidate = map + .values() + .find(|session| { + let state = session.lock().unwrap(); + !state.closed && state.owner == owner && state.resume_id.as_deref() == Some(id) + }) + .cloned(); + if let Some(previous) = candidate { + let mut old = previous.lock().unwrap(); + // Validate before changing the old transport or any session counters. + let count = h.wrapping_sub(old.acked) as usize; + ensure!(count <= old.pending.len(), "invalid stream acknowledgement"); + // 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.cancel = Default::default(); + state.csi.reset(); + state.first_push_sent = false; + state.sender = Some(sender); + state.connection = connection.into(); + state.expires = None; + let mut replay = vec![format!( + "", + state.handled + )]; + replay.extend(state.pending.iter().cloned()); + old.closed = true; + old.close_condition = Some("conflict".into()); + old.sender = None; + old.resume_id = None; + old.expires = None; + old.pending.clear(); + old.csi.reset(); + old.cancel.notify_one(); + let jid = state.jid.clone(); + let resumed = Arc::new(Mutex::new(state)); + map.insert(jid, resumed.clone()); + return Ok((resumed, replay)); + } + anyhow::bail!("resume session not found") + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn resume_replays_only_unacked_and_checks_owner() { + let h = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let s = h.bind("a@x/r", "c1", tx, Duration::from_secs(30)).unwrap(); + let id; + { + let mut s = s.lock().unwrap(); + s.client_id = Some("stable-device".into()); + s.push_identifier = Some("push.test".into(), "a@x", Instant::now()) + .is_empty() + ); + } + h.disconnect("c1", false); + let (tx, _rx) = mpsc::channel(8); + assert!(h.resume("b@x", &id, 1, "c2", tx.clone()).is_err()); + let (s, replay) = h.resume("a@x", &id, 1, "c2", tx).unwrap(); + assert_eq!( + s.lock().unwrap().client_id.as_deref(), + Some("stable-device") + ); + assert_eq!( + s.lock().unwrap().push_identifier.as_deref(), + Some("push.test".into(), "a@x", Instant::now()) + .len(), + 1 + ); + assert_eq!(replay.len(), 2); + assert_eq!(replay[1], "two"); + assert!(replay[0].contains("h='7'")); + assert!(s.lock().unwrap().acknowledge(3).is_err()); + } + #[test] + fn graceful_disconnect_is_not_resumable() { + let h = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let s = h.bind("a@x/r", "c", tx, Duration::from_secs(30)).unwrap(); + s.lock().unwrap().enable(true).unwrap(); + assert_eq!(h.disconnect("c", true), vec!["a@x/r"]); + assert!(h.get("a@x/r").is_none()); + } + #[test] + fn acknowledgement_wraps() { + let h = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let s = h.bind("a@x/r", "c", tx, Duration::from_secs(30)).unwrap(); + let mut s = s.lock().unwrap(); + s.sm = true; + s.sent = u32::MAX; + s.acked = u32::MAX; + s.transmit("x".into()).unwrap(); + s.acknowledge(0).unwrap(); + assert!(s.pending.is_empty()); + } +} + +#[cfg(test)] +mod push_timeout_tests { + use super::*; + #[test] + fn first_important_push_shortens_extended_hibernation_once() { + let hub = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let session = hub + .bind("a@x/r", "c", tx, Duration::from_secs(72 * 3600)) + .unwrap(); + session.lock().unwrap().enable(true).unwrap(); + hub.disconnect("c", false); + let mut s = session.lock().unwrap(); + let extended = s.expires.unwrap(); + s.first_important_push(Duration::from_secs(300)); + let first = s.expires.unwrap(); + assert!(first < extended); + assert!(s.first_push_sent); + s.first_important_push(Duration::from_secs(900)); + assert_eq!(s.expires.unwrap(), first); + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..e140ce2 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,484 @@ +//! Lossless, bounded Prosody internal key/value persistence. +use anyhow::{Result, bail, ensure}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::Mutex, +}; +static TRANSACTION: Mutex<()> = Mutex::new(()); +const LIMIT: usize = 16 * 1024 * 1024; +#[derive(Clone, Debug)] +pub enum LuaValue { + Nil, + Bool(bool), + Integer(i64), + Number(f64), + String(Vec), + Table(Vec<(LuaValue, LuaValue)>), +} +impl PartialEq for LuaValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Nil, Self::Nil) => true, + (Self::Bool(a), Self::Bool(b)) => a == b, + (Self::Integer(a), Self::Integer(b)) => a == b, + (Self::Number(a), Self::Number(b)) => a == b, + (Self::Integer(a), Self::Number(b)) | (Self::Number(b), Self::Integer(a)) => { + b.is_finite() + && b.fract() == 0.0 + && *b >= i64::MIN as f64 + && *b < (i64::MAX as f64) + && *a == *b as i64 + } + (Self::String(a), Self::String(b)) => a == b, + (Self::Table(a), Self::Table(b)) => { + a.len() == b.len() + && a.iter() + .all(|(k, v)| b.iter().any(|(k2, v2)| k == k2 && v == v2)) + } + _ => false, + } + } +} +impl Default for LuaValue { + fn default() -> Self { + Self::Table(vec![]) + } +} +impl From<&str> for LuaValue { + fn from(v: &str) -> Self { + Self::String(v.as_bytes().to_vec()) + } +} +impl From for LuaValue { + fn from(v: String) -> Self { + Self::String(v.into_bytes()) + } +} +impl LuaValue { + pub fn table() -> Self { + Self::default() + } + pub fn get_key(&self, k: &Self) -> Option<&Self> { + if let Self::Table(t) = self { + t.iter().find(|(a, _)| a == k).map(|(_, v)| v) + } else { + None + } + } + pub fn get(&self, k: &str) -> Option<&Self> { + self.get_key(&k.into()) + } + pub fn set_key(&mut self, k: Self, v: Self) { + if let Self::Table(t) = self { + t.retain(|(a, _)| a != &k); + if v != Self::Nil { + t.push((k, v)); + } + } + } + pub fn set(&mut self, k: &str, v: Self) { + self.set_key(k.into(), v) + } + pub fn as_str(&self) -> Option<&str> { + if let Self::String(s) = self { + std::str::from_utf8(s).ok() + } else { + None + } + } + pub fn entries(&self) -> &[(Self, Self)] { + if let Self::Table(t) = self { t } else { &[] } + } + pub fn as_i64(&self) -> Option { + if let Self::Integer(v) = self { + Some(*v) + } else { + None + } + } +} +pub fn encode_name(name: &str) -> String { + encode(name, false) +} +fn encode(name: &str, store: bool) -> String { + let mut out = String::new(); + for b in name.bytes() { + if b.is_ascii_alphanumeric() || (store && b == b'_') { + out.push(b as char) + } else { + out.push_str(&format!("%{b:02x}")) + } + } + out +} +pub fn path(root: &Path, host: &str, user: Option<&str>, store: &str) -> PathBuf { + let d = root.join(encode_name(host)); + match user { + Some(u) => d + .join(encode(store, true)) + .join(format!("{}.dat", encode_name(u))), + None => d.join(format!("{}.dat", encode(store, true))), + } +} +pub fn parse(input: &[u8]) -> Result { + ensure!(input.len() <= LIMIT, "storage limit exceeded"); + let lua = crate::lua::bounded_vm().map_err(|e| anyhow::anyhow!(e.to_string()))?; + let env = lua + .create_table() + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let v = lua + .load(input) + .set_mode(mlua::chunk::ChunkMode::Text) + .set_environment(env) + .eval::() + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + from_lua(v) +} +pub fn from_lua(v: mlua::Value) -> Result { + fn convert(v: mlua::Value, depth: usize, count: &mut usize) -> Result { + *count += 1; + ensure!(depth < 64 && *count <= 200_000, "storage complexity limit"); + Ok(match v { + mlua::Value::Nil => LuaValue::Nil, + mlua::Value::Boolean(v) => LuaValue::Bool(v), + mlua::Value::Integer(v) => LuaValue::Integer(v), + mlua::Value::Number(v) => { + ensure!(v.is_finite(), "non-finite storage number"); + LuaValue::Number(v) + } + mlua::Value::String(v) => LuaValue::String(v.as_bytes().to_vec()), + mlua::Value::Table(t) => { + let mut o = vec![]; + for p in t.pairs::() { + let (k, v) = p.map_err(|e| anyhow::anyhow!(e.to_string()))?; + ensure!( + matches!( + k, + mlua::Value::Boolean(_) + | mlua::Value::String(_) + | mlua::Value::Integer(_) + | mlua::Value::Number(_) + ), + "unsupported table key" + ); + o.push((convert(k, depth + 1, count)?, convert(v, depth + 1, count)?)); + } + LuaValue::Table(o) + } + _ => bail!("unsupported storage value"), + }) + } + convert(v, 0, &mut 0) +} +pub fn serialize(value: &LuaValue) -> Result { + fn put(v: &LuaValue, s: &mut String, d: usize) -> Result<()> { + ensure!(d < 64 && s.len() <= LIMIT, "storage complexity limit"); + match v { + LuaValue::Nil => s.push_str("nil"), + LuaValue::Bool(v) => s.push_str(if *v { "true" } else { "false" }), + LuaValue::Integer(v) => s.push_str(&v.to_string()), + LuaValue::Number(v) => { + ensure!(v.is_finite(), "non-finite storage number"); + s.push_str(&format!("{v:?}")) + } + LuaValue::String(v) => { + s.push('"'); + for b in v { + match b { + b'"' => s.push_str("\\\""), + b'\\' => s.push_str("\\\\"), + 32..=126 => s.push(*b as char), + _ => s.push_str(&format!("\\{b:03}")), + } + } + s.push('"') + } + LuaValue::Table(t) => { + s.push('{'); + for (k, v) in t { + ensure!( + matches!( + k, + LuaValue::Bool(_) + | LuaValue::Integer(_) + | LuaValue::Number(_) + | LuaValue::String(_) + ), + "invalid key" + ); + s.push('['); + put(k, s, d + 1)?; + s.push_str("]="); + put(v, s, d + 1)?; + s.push(';') + } + s.push('}') + } + } + Ok(()) + } + let mut s = "return ".to_string(); + put(value, &mut s, 0)?; + s.push_str(";\n"); + ensure!(s.len() <= LIMIT, "storage limit exceeded"); + Ok(s) +} +pub fn load(root: &Path, host: &str, user: Option<&str>, store: &str) -> Result> { + let p = path(root, host, user, store); + let f = match fs::File::open(&p) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut b = vec![]; + f.take((LIMIT + 1) as u64).read_to_end(&mut b)?; + Ok(Some(parse(&b)?)) +} +pub fn atomic_write(p: &Path, data: &[u8]) -> Result<()> { + let parent = p + .parent() + .ok_or_else(|| anyhow::anyhow!("missing parent"))?; + fs::create_dir_all(parent)?; + let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut opts = OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&tmp)?; + f.write_all(data)?; + f.sync_all()?; + fs::rename(&tmp, p)?; + fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(tmp); + } + result +} +pub fn save( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + value: &LuaValue, +) -> Result<()> { + if matches!(value, LuaValue::Nil) || matches!(value,LuaValue::Table(v) if v.is_empty()) { + let p = path(root, host, user, store); + match fs::remove_file(&p) { + Ok(()) => { + if let Some(parent) = p.parent() { + fs::File::open(parent)?.sync_all()?; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + return Ok(()); + } + let s = serialize(value)?; + atomic_write(&path(root, host, user, store), s.as_bytes()) +} +/// Process-local serialized read/modify/write. Never run two servers on one data directory. +pub fn update Result<()>>( + root: &Path, + host: &str, + user: Option<&str>, + store: &str, + f: F, +) -> Result { + let _g = TRANSACTION + .lock() + .map_err(|_| anyhow::anyhow!("storage lock poisoned"))?; + let mut v = load(root, host, user, store)?.unwrap_or_default(); + ensure!(matches!(v, LuaValue::Table(_)), "store is not a table"); + f(&mut v)?; + save(root, host, user, store, &v)?; + Ok(v) +} +/// util.stanza.preserialize-compatible representation (mixed numeric/string keys). +pub fn stanza_value(n: roxmltree::Node<'_, '_>) -> LuaValue { + if n.is_text() { + return n.text().unwrap_or("").into(); + } + let mut t = LuaValue::table(); + t.set("name", n.tag_name().name().into()); + let mut attr = LuaValue::table(); + attr.set("xmlns", n.tag_name().namespace().unwrap_or("").into()); + for a in n.attributes() { + let key = match a.namespace() { + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", a.name()), + Some(ns) => format!("{ns}\u{1}{}", a.name()), + None => a.name().into(), + }; + attr.set(&key, a.value().into()); + } + t.set("attr", attr); + for (i, c) in n + .children() + .filter(|x| x.is_element() || x.is_text()) + .enumerate() + { + t.set_key(LuaValue::Integer(i as i64 + 1), stanza_value(c)); + } + t +} +pub fn stanza_xml(v: &LuaValue) -> Result { + use crate::wire::escape; + if let LuaValue::String(s) = v { + return Ok(escape(std::str::from_utf8(s)?)); + } + let name = v + .get("name") + .and_then(LuaValue::as_str) + .ok_or_else(|| anyhow::anyhow!("missing stanza name"))?; + ensure!( + !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || "_-.".contains(c)), + "invalid stanza name" + ); + let mut s = format!("<{name}"); + let mut namespace_index = 0; + if let Some(a) = v.get("attr") { + for (k, val) in a.entries() { + let key = k + .as_str() + .ok_or_else(|| anyhow::anyhow!("invalid attribute key"))?; + let val = val + .as_str() + .ok_or_else(|| anyhow::anyhow!("invalid attribute value"))?; + let split = key.split_once('\u{1}').or_else(|| key.split_once('|')); + if let Some((ns, local)) = split { + ensure!( + !local.is_empty() + && local + .chars() + .all(|c| c.is_alphanumeric() || "_-.".contains(c)), + "invalid attribute" + ); + if ns == "http://www.w3.org/XML/1998/namespace" { + s.push_str(&format!(" xml:{local}='{}'", escape(val))) + } else { + namespace_index += 1; + s.push_str(&format!( + " xmlns:a{namespace_index}='{}' a{namespace_index}:{local}='{}'", + escape(ns), + escape(val) + )) + } + } else { + ensure!( + !key.is_empty() + && key + .chars() + .all(|c| c.is_alphanumeric() || "_-.:".contains(c)), + "invalid attribute" + ); + s.push_str(&format!(" {key}='{}'", escape(val))) + } + } + } + s.push('>'); + let mut i = 1; + while let Some(child) = v.get_key(&LuaValue::Integer(i)) { + s.push_str(&stanza_xml(child)?); + i += 1; + } + s.push_str(&format!("")); + Ok(s) +} +/// Prosody datamanager.purge semantics. Caller must stop this user's sessions first. +/// Unknown stores are included, but only exact per-user storage files are removed. +pub fn purge_user(root: &Path, host: &str, user: &str) -> Result<()> { + let _guard = TRANSACTION + .lock() + .map_err(|_| anyhow::anyhow!("storage lock poisoned"))?; + ensure!( + !host.is_empty() && !user.is_empty(), + "host and user required" + ); + let dir = root.join(encode_name(host)); + let meta = match fs::symlink_metadata(&dir) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + ensure!( + meta.is_dir() && !meta.file_type().is_symlink(), + "refusing symlinked data host" + ); + let user = encode_name(user); + let mut files = vec![]; + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let ty = entry.file_type()?; + ensure!( + !ty.is_symlink(), + "refusing symlinked host store during account purge" + ); + if !ty.is_dir() { + continue; + } + for extension in ["dat", "list", "lidx", "rappend"] { + let p = entry.path().join(format!("{user}.{extension}")); + match fs::symlink_metadata(&p) { + Ok(m) => { + ensure!( + m.is_file() || m.file_type().is_symlink(), + "account store path is not a file" + ); + files.push(p) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + } + // Revoke login before deleting the remainder; failures are returned, never hidden. + files.sort_by_key(|p| { + if p.parent() + .and_then(|p| p.file_name()) + .is_some_and(|s| s == "accounts") + { + 0 + } else { + 1 + } + }); + let mut errors = vec![]; + for p in files { + match fs::remove_file(&p) { + Ok(()) => { + if let Some(parent) = p.parent() + && let Err(e) = fs::File::open(parent).and_then(|f| f.sync_all()) + { + errors.push(e.to_string()) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => errors.push(e.to_string()), + } + } + ensure!( + errors.is_empty(), + "account purge failed: {}", + errors.join("; ") + ); + Ok(()) +} +/// Coordinate account creation with key/value updates and user purges. +/// The callback must not call `update`, `purge_user`, or this function recursively. +pub fn with_exclusive_access(f: impl FnOnce() -> Result) -> Result { + let _guard = TRANSACTION + .lock() + .map_err(|_| anyhow::anyhow!("storage lock poisoned"))?; + f() +} diff --git a/src/tls_binding.rs b/src/tls_binding.rs new file mode 100644 index 0000000..ba43dea --- /dev/null +++ b/src/tls_binding.rs @@ -0,0 +1,222 @@ +//! RFC 5929 `tls-server-end-point` for an already validated leaf certificate. +//! +//! This only reads the outer Certificate/AlgorithmIdentifier envelope, not X.509 +//! names, extensions, keys, or signatures. Certificate validation belongs to TLS. +//! RFC 9266 adds `tls-exporter`; it does not redefine this certificate digest. + +use anyhow::{Result, bail, ensure}; +use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hash { + Md5, + Sha1, + Sha224, + Sha256, + Sha384, + Sha512, +} + +/// Hash the exact DER leaf certificate according to its outer signature algorithm. +/// Unsupported algorithms (including EdDSA) and malformed envelopes return errors; +/// there is deliberately no fallback to an arbitrary digest. +pub fn server_end_point(cert_der: &[u8]) -> Result> { + let mut input = Der(cert_der); + let mut certificate = Der(input.take(0x30)?); + input.finish()?; + certificate.take(0x30)?; // TBSCertificate is opaque to this envelope parser. + let algorithm = certificate.take(0x30)?; + let signature = certificate.take(0x03)?; + ensure!( + signature.len() > 1 && signature[0] == 0, + "invalid certificate signature BIT STRING" + ); + certificate.finish()?; + let (oid, params) = algorithm_identifier(algorithm)?; + let hash = if oid == b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0a" { + pss_hash(params)? + } else { + // RSA PKCS#1 v1.5 signature identifiers. + let hash = match oid { + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x04" => Hash::Md5, + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x05" | b"\x2b\x0e\x03\x02\x1d" => Hash::Sha1, + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0e" => Hash::Sha224, + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0b" => Hash::Sha256, + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0c" => Hash::Sha384, + b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0d" => Hash::Sha512, + // DSA and ECDSA signature identifiers. + b"\x2a\x86\x48\xce\x38\x04\x03" | b"\x2a\x86\x48\xce\x3d\x04\x01" => Hash::Sha1, + b"\x60\x86\x48\x01\x65\x03\x04\x03\x01" | b"\x2a\x86\x48\xce\x3d\x04\x03\x01" => { + Hash::Sha224 + } + b"\x60\x86\x48\x01\x65\x03\x04\x03\x02" | b"\x2a\x86\x48\xce\x3d\x04\x03\x02" => { + Hash::Sha256 + } + b"\x60\x86\x48\x01\x65\x03\x04\x03\x03" | b"\x2a\x86\x48\xce\x3d\x04\x03\x03" => { + Hash::Sha384 + } + b"\x60\x86\x48\x01\x65\x03\x04\x03\x04" | b"\x2a\x86\x48\xce\x3d\x04\x03\x04" => { + Hash::Sha512 + } + _ => bail!("unsupported certificate signature algorithm for tls-server-end-point"), + }; + null_or_absent(params)?; + hash + }; + Ok(match hash { + Hash::Md5 | Hash::Sha1 | Hash::Sha256 => Sha256::digest(cert_der).to_vec(), + Hash::Sha224 => Sha224::digest(cert_der).to_vec(), + Hash::Sha384 => Sha384::digest(cert_der).to_vec(), + Hash::Sha512 => Sha512::digest(cert_der).to_vec(), + }) +} + +type AlgorithmIdentifier<'a> = (&'a [u8], Option<(u8, &'a [u8])>); + +fn algorithm_identifier(bytes: &[u8]) -> Result> { + let mut der = Der(bytes); + let oid = der.take(0x06)?; + ensure!(!oid.is_empty(), "empty algorithm OID"); + // Reject noncanonical/truncated base-128 OID components. + let mut start = true; + for &byte in oid { + ensure!(!start || byte != 0x80, "noncanonical algorithm OID"); + start = byte & 0x80 == 0; + } + ensure!(start, "truncated algorithm OID"); + let params = if der.0.is_empty() { + None + } else { + Some(der.any()?) + }; + der.finish()?; + Ok((oid, params)) +} + +fn null_or_absent(params: Option<(u8, &[u8])>) -> Result<()> { + ensure!( + params.is_none() || params == Some((0x05, &[])), + "invalid algorithm parameters" + ); + Ok(()) +} + +fn hash_algorithm(bytes: &[u8]) -> Result { + let (oid, params) = algorithm_identifier(bytes)?; + null_or_absent(params)?; + Ok(match oid { + b"\x2b\x0e\x03\x02\x1a" => Hash::Sha1, + b"\x60\x86\x48\x01\x65\x03\x04\x02\x04" => Hash::Sha224, + b"\x60\x86\x48\x01\x65\x03\x04\x02\x01" => Hash::Sha256, + b"\x60\x86\x48\x01\x65\x03\x04\x02\x02" => Hash::Sha384, + b"\x60\x86\x48\x01\x65\x03\x04\x02\x03" => Hash::Sha512, + _ => bail!("unsupported RSA-PSS hash algorithm"), + }) +} + +fn explicit_sequence(bytes: &[u8]) -> Result<&[u8]> { + let mut der = Der(bytes); + let value = der.take(0x30)?; + der.finish()?; + Ok(value) +} + +fn pss_hash(params: Option<(u8, &[u8])>) -> Result { + let Some((0x30, bytes)) = params else { + bail!("missing or invalid RSA-PSS parameters") + }; + let mut der = Der(bytes); + let mut hash = Hash::Sha1; + let mut mgf_hash = Hash::Sha1; + let mut previous = 0x9f; + while !der.0.is_empty() { + let (tag, value) = der.any()?; + ensure!( + (0xa0..=0xa3).contains(&tag) && tag > previous, + "invalid RSA-PSS parameter order" + ); + previous = tag; + match tag { + 0xa0 => hash = hash_algorithm(explicit_sequence(value)?)?, + 0xa1 => { + let (oid, params) = algorithm_identifier(explicit_sequence(value)?)?; + ensure!( + oid == b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x08", + "unsupported RSA-PSS mask generation algorithm" + ); + let Some((0x30, hash_params)) = params else { + bail!("invalid MGF1 parameters") + }; + mgf_hash = hash_algorithm(hash_params)?; + } + 0xa2 | 0xa3 => { + let mut integer = Der(value); + let bytes = integer.take(0x02)?; + integer.finish()?; + ensure!( + !bytes.is_empty() && bytes[0] & 0x80 == 0, + "invalid RSA-PSS integer" + ); + ensure!( + bytes.len() == 1 || bytes[0] != 0 || bytes[1] & 0x80 != 0, + "noncanonical RSA-PSS integer" + ); + if tag == 0xa3 { + ensure!(bytes == [1], "unsupported RSA-PSS trailer field"); + } + } + _ => unreachable!(), + } + } + // RFC 5929 leaves signatures using multiple hash functions undefined. + ensure!(hash == mgf_hash, "RSA-PSS uses multiple hash functions"); + Ok(hash) +} + +/// Nonrecursive, zero-copy DER TLV reader. Length arithmetic is bounded by input. +struct Der<'a>(&'a [u8]); +impl<'a> Der<'a> { + fn any(&mut self) -> Result<(u8, &'a [u8])> { + ensure!(self.0.len() >= 2, "truncated DER header"); + let tag = self.0[0]; + ensure!(tag & 0x1f != 0x1f && tag != 0, "unsupported DER tag"); + let first = self.0[1]; + let mut offset = 2; + let length = if first & 0x80 == 0 { + usize::from(first) + } else { + let count = usize::from(first & 0x7f); + ensure!( + count > 0 && count <= std::mem::size_of::(), + "invalid DER length" + ); + ensure!( + count <= self.0.len() - offset && self.0[offset] != 0, + "truncated or noncanonical DER length" + ); + let mut length = 0usize; + for &byte in &self.0[offset..offset + count] { + length = length + .checked_mul(256) + .and_then(|n| n.checked_add(usize::from(byte))) + .ok_or_else(|| anyhow::anyhow!("DER length overflow"))?; + } + offset += count; + ensure!(length >= 128, "noncanonical DER length"); + length + }; + ensure!(length <= self.0.len() - offset, "truncated DER value"); + let (value, rest) = self.0[offset..].split_at(length); + self.0 = rest; + Ok((tag, value)) + } + fn take(&mut self, expected: u8) -> Result<&'a [u8]> { + let (tag, value) = self.any()?; + ensure!(tag == expected, "unexpected DER tag"); + Ok(value) + } + fn finish(&self) -> Result<()> { + ensure!(self.0.is_empty(), "trailing DER data"); + Ok(()) + } +} diff --git a/src/wire.rs b/src/wire.rs new file mode 100644 index 0000000..40b9b20 --- /dev/null +++ b/src/wire.rs @@ -0,0 +1,267 @@ +use anyhow::{Result, bail, ensure}; +use std::collections::BTreeMap; +use tokio::io::{AsyncRead, AsyncReadExt}; +const MAX: usize = 256 * 1024; +pub const STREAM_NAMESPACE: &str = "http://etherx.jabber.org/streams"; +#[derive(Clone, Debug)] +pub struct StreamHeader { + pub name: String, + pub to: Option, + pub from: Option, + pub version: Option, + /// Empty key denotes the default namespace. + pub namespaces: BTreeMap, +} +impl StreamHeader { + pub fn parse(text: &str) -> Result { + let text = text.trim(); + ensure!( + text.starts_with('<') && !text.starts_with(""), + "expected open stream header" + ); + let name = + tag_name(text.as_bytes()).ok_or_else(|| anyhow::anyhow!("missing stream name"))?; + ensure!( + name.rsplit(':').next() == Some("stream"), + "expected stream element" + ); + let document = format!("{text}"); + let doc = roxmltree::Document::parse(&document)?; + let root = doc.root_element(); + ensure!( + root.tag_name().namespace() == Some(STREAM_NAMESPACE) + && root.tag_name().name() == "stream", + "invalid stream namespace" + ); + Ok(Self { + name: name.into(), + to: root.attribute("to").map(str::to_owned), + from: root.attribute("from").map(str::to_owned), + version: root.attribute("version").map(str::to_owned), + namespaces: root + .namespaces() + .map(|n| (n.name().unwrap_or("").into(), n.uri().into())) + .collect(), + }) + } + pub fn default_namespace(&self) -> Option<&str> { + self.namespaces.get("").map(String::as_str) + } +} +/// Incremental framing. Every emitted stanza must still undergo full XML validation. +#[derive(Default)] +pub struct Framer { + pub buffer: Vec, + pub header: Option, +} +impl Framer { + pub fn wrap(&self, xml: &str) -> String { + let mut out = "'); + out.push_str(xml); + out.push_str(""); + out + } + pub fn is_stream_end(&self, text: &str) -> bool { + self.header + .as_ref() + .is_some_and(|h| text.trim() == format!("", h.name)) + } + pub async fn next(&mut self, s: &mut S) -> Result { + loop { + if let Some(n) = boundary(&self.buffer)? { + let bytes: Vec<_> = self.buffer.drain(..n).collect(); + let text = String::from_utf8(bytes)?.trim().to_owned(); + if text.starts_with("") { + i = i + 4 + p + 3; + if depth == 0 { + return Ok(Some(i)); + } + continue; + } + return Ok(None); + } + if b[i..].starts_with(b" 0, "CDATA outside stanza"); + if let Some(p) = b[i + 9..].windows(3).position(|w| w == b"]]>") { + i = i + 9 + p + 3; + continue; + } + return Ok(None); + } + if b[i..].starts_with(b"' { + break; + } + i += 1 + } + if i == b.len() { + return Ok(None); + } + let end = i + 1; + let tag = &b[start..end]; + if tag.starts_with(b""), "invalid XML declaration"); + return Ok(Some(end)); + } + ensure!(!tag.starts_with(b" 0, "unexpected closing tag"); + depth -= 1 + } else if !tag.ends_with(b"/>") { + depth += 1; + ensure!(depth <= 32, "XML nesting limit") + } + if depth == 0 { + return Ok(Some(end)); + } + i = end; + } + Ok(None) +} +pub fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fragmented_and_pipelined() { + assert_eq!(boundary(b"hi").unwrap(), None); + assert_eq!( + boundary(b"hi").unwrap(), + Some(34) + ); + } + #[test] + fn declarations_rejected() { + assert!(boundary(b"").is_err()); + assert!(boundary(b"").is_err()); + } + #[test] + fn cdata_and_comments_frame_without_counting_brackets() { + let xml = b" & ] x]]>"; + assert_eq!(boundary(xml).unwrap(), Some(xml.len())); + for i in 0..xml.len() { + assert_eq!(boundary(&xml[..i]).unwrap(), None, "{i}"); + } + } + #[tokio::test] + async fn arbitrary_prefix_and_inherited_namespaces() { + let xml = ""; + let mut input = xml.as_bytes(); + let mut f = Framer::default(); + assert!(f.next(&mut input).await.unwrap().starts_with("".as_bytes(); + assert!(f.next(&mut input).await.is_err()); + let mut input = "".as_bytes(); + assert!(Framer::default().next(&mut input).await.is_err()); + } +} diff --git a/tests/_wire_client.py b/tests/_wire_client.py new file mode 100644 index 0000000..172ae7a --- /dev/null +++ b/tests/_wire_client.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Independent TCP integration tests for integrated Rust XMPP modules (isolated data).""" +import base64, json, signal, socket, ssl, subprocess, tempfile, time +from pathlib import Path +import xml.etree.ElementTree as ET +ROOT=Path(__file__).resolve().parents[1] +BIN=ROOT/'target/release/rust-xmpp' +C='jabber:client'; S='http://etherx.jabber.org/streams' +OPEN=f"" +def local(e):return e.tag.split('}')[-1] +class Client: + def __init__(self,port,cert,user,password,resource='main',bind=True): + self.sock=socket.create_connection(('127.0.0.1',port),timeout=5);self.buf=b'';self.queue=[];self.user=user;self.port=port;self.cert=cert;self.password=password + self.send(OPEN);self.until(b'');self.send("");self.until(b'/>') + self.sock=ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock,server_hostname='localhost');self.send(OPEN);self.until(b'') + payload=base64.b64encode(f'\0{user}\0{password}'.encode()).decode();self.send(f"{payload}");self.until(b'/>');self.send(OPEN);self.post_auth_features=self.until(b'') + self.jid=f'{user}@localhost/{resource}' + if bind:self.send(f"{resource}");self.iq('bind') + def send(self,s):self.sock.sendall(s.encode()) + def until(self,mark): + while mark not in self.buf: + chunk=self.sock.recv(65536) + if not chunk:raise RuntimeError('EOF '+repr(self.buf)) + self.buf+=chunk + end=self.buf.index(mark)+len(mark);out,self.buf=self.buf[:end],self.buf[end:];return out + def read(self): + parser=ET.XMLPullParser(events=('start','end'));parser.feed(f"");list(parser.read_events());depth=0 + while True: + if not self.buf: + self.buf=self.sock.recv(65536) + if not self.buf:raise RuntimeError('EOF') + byte,self.buf=self.buf[:1],self.buf[1:];parser.feed(byte) + # Expat reparse deferral may withhold a short end-tag when fed bytewise. + # Flush at markup boundaries so an empty IQ result needs no following stanza. + if byte==b'>' and hasattr(parser,'flush'):parser.flush() + for event,node in parser.read_events(): + depth+=1 if event=='start' else -1 + if event=='end' and depth==0:return node + def take(self,predicate): + for i,e in enumerate(self.queue): + if predicate(e):return self.queue.pop(i) + while True: + e=self.read() + if predicate(e):return e + self.queue.append(e) + def iq(self,id,error=False): + e=self.take(lambda e:local(e)=='iq' and e.get('id')==id and e.get('type') in ('result','error')) + assert e.get('type')==('error' if error else 'result'),ET.tostring(e) + return e + def sync(self):self.send("");self.iq('sync') + def available(self):self.send('');self.sync() + def body(self,text):return self.take(lambda e:local(e)=='message' and any(local(c)=='body' and c.text==text for c in e)) + def close(self):self.sock.close() + diff --git a/tests/admin_socket.rs b/tests/admin_socket.rs new file mode 100644 index 0000000..203ce20 --- /dev/null +++ b/tests/admin_socket.rs @@ -0,0 +1,103 @@ +use rust_xmpp::{ + admin::{AdminBackend, AdminServer, AdminSnapshot, query}, + auth, +}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; +#[tokio::test] +#[ignore = "requires AF_UNIX socket permission; run on target host with --ignored"] +async fn prosody_wire_commands_password_prompt_and_safe_errors() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("prosody.sock"); + let backend = AdminBackend { + data: dir.path().into(), + hosts: BTreeMap::from([("example.com".into(), "SHA-256".into())]), + modules: BTreeMap::from([( + "example.com".into(), + vec!["admin_shell".into(), "tls".into()], + )]), + snapshot: Arc::new(|| AdminSnapshot { + uptime: Duration::from_secs(123), + sessions: vec!["alice@example.com/r".into()], + }), + delete_user: None, + disconnect_user: None, + }; + let server = AdminServer::bind(&socket, backend.clone()).unwrap(); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(AdminServer::bind(&socket, backend).is_err()); + let task = tokio::spawn(server.serve()); + let result = query(&socket, "server:status()", None).await.unwrap(); + assert!(!result.error); + assert_eq!(result.message, "OK: running"); + let result = query(&socket, "{\"c2s\",\"count\"}", None).await.unwrap(); + assert_eq!(result.message, "OK: Total: 1 clients"); + { + use tokio::io::AsyncWriteExt; + let mut client = tokio::net::UnixStream::connect(&socket).await.unwrap(); + client.write_all(b"server:version()").await.unwrap(); + let mut framer = rust_xmpp::wire::Framer::default(); + framer.next(&mut client).await.unwrap(); + let response = framer.next(&mut client).await.unwrap(); + assert!(response.contains("rust-xmpp")); + } + let result = query(&socket, "c2s:show()", None).await.unwrap(); + assert_eq!(result.output, vec!["alice@example.com/r"]); + let result = query( + &socket, + "user:create('alice@example.com')", + Some("initial<&password"), + ) + .await + .unwrap(); + assert!(!result.error, "{}", result.message); + assert!( + auth::verify( + dir.path(), + "example.com", + "alice", + "initial<&password", + "SHA-256" + ) + .unwrap() + ); + let result = query( + &socket, + "user:password('alice@example.com','changed')", + None, + ) + .await + .unwrap(); + assert!(!result.error); + assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + let result = query(&socket, "user:disable('alice@example.com')", None) + .await + .unwrap(); + assert!(!result.error); + assert!(!auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + let result = query(&socket, "user:enable('alice@example.com')", None) + .await + .unwrap(); + assert!(!result.error); + assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + for command in [ + "module:reload('tls')", + "os.execute('echo unauthorized')", + "user:delete('alice@example.com')", + "user:create('bob@example.com',nil,'prosody:admin')", + ] { + let result = query(&socket, command, None).await.unwrap(); + assert!(result.error, "{command}"); + } + let result = query(&socket, "user:password('alice@example.com')", None) + .await + .unwrap(); + assert!(result.error); + assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap()); + task.abort(); + let _ = task.await; + assert!(!socket.exists()); +} diff --git a/tests/android_sasl_network.py b/tests/android_sasl_network.py new file mode 100644 index 0000000..f3038e4 --- /dev/null +++ b/tests/android_sasl_network.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Independent Android SCRAM compatibility regression over real STARTTLS. + +Usage: python3 tests/android_sasl_network.py BINARY [--expect-060-failure] +The selector mirrors Conversations ChannelBinding.best (Codeberg master, +retrieved 2026-10-20). TLS 1.3 network cases use the certificate binding; +the faithful Android selector's exporter preference is checked separately. +""" +import base64 +import hashlib +import hmac +import json +import itertools +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' +SASL2 = 'urn:xmpp:sasl:2' +CB = 'urn:xmpp:sasl-cb:0' +OPEN = "" + + +def android_binding(bindings, version): + if version == 'TLSv1.3' and 'tls-exporter' in bindings: + return 'tls-exporter' + if version in ('TLSv1', 'TLSv1.1', 'TLSv1.2') and 'tls-unique' in bindings: + return 'tls-unique' + if 'tls-server-end-point' in bindings: + return 'tls-server-end-point' + if not bindings: + return 'tls-exporter' if version == 'TLSv1.3' else 'tls-unique' + return None + + +def b64(data): + return base64.b64encode(data).decode() + + +class Connection: + def __init__(self, port, cert, version): + self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) + self.buffer = b'' + self.send(OPEN) + self.until(b'') + self.send("") + assert b'proceed' in self.until(b'/>') + context = ssl.create_default_context(cafile=str(cert)) + context.minimum_version = context.maximum_version = version + self.sock = context.wrap_socket(self.sock, server_hostname='localhost') + self.send(OPEN) + raw = self.until(b'') + raw = raw[raw.index(b'" + raw + b'') + + def send(self, value): + self.sock.sendall(value.encode()) + + def until(self, marker): + while marker not in self.buffer: + chunk = self.sock.recv(65536) + assert chunk, self.buffer + self.buffer += chunk + end = self.buffer.index(marker) + len(marker) + result, self.buffer = self.buffer[:end], self.buffer[end:] + return result + + def stanza(self): + # SASL stanzas have no unclosed stream prefix after features. + parser = ET.XMLPullParser(events=('start', 'end')) + depth = 0 + while True: + byte = self.until(b'>') + parser.feed(byte) + if hasattr(parser, 'flush'): + parser.flush() + for event, node in parser.read_events(): + depth += 1 if event == 'start' else -1 + if event == 'end' and depth == 0: + return node + + def auth(self, hash_name, sasl2, wrong_binding=False, expect_old=False, ssdp=True): + ns = SASL2 if sasl2 else SASL + parent = self.features.find('.//{' + ns + '}' + ('authentication' if sasl2 else 'mechanisms')) + mechanisms = [n.text for n in parent.findall('{' + ns + '}mechanism')] + bindings = [n.get('type') for n in self.features.findall('.//{' + CB + '}channel-binding')] + selected = android_binding(bindings, self.sock.version()) + if expect_old: + assert bindings == ['tls-exporter'] and selected is None, bindings + elif self.sock.version() == 'TLSv1.3': + assert selected == 'tls-exporter', bindings + # CPython does not expose TLS exporters. Exercise another real PLUS + # binding on TLS 1.3, while retaining the selector assertion above. + assert 'tls-server-end-point' in bindings + selected = 'tls-server-end-point' + else: + assert selected == 'tls-server-end-point', bindings + mechanism = 'SCRAM-' + hash_name + ('-PLUS' if selected else '') + assert mechanism in mechanisms + gs2 = ('p=' + selected + ',,') if selected else 'y,,' + bare = 'n=alice,r=android-independent-client-nonce' + initial = b64((gs2 + bare).encode()) + if sasl2: + self.send(f"{initial}") + else: + self.send(f"{initial}") + challenge = self.stanza() + if expect_old: + assert challenge.tag == '{' + ns + '}failure', ET.tostring(challenge) + assert challenge.find('{' + SASL + '}not-authorized') is not None + return + assert challenge.tag == '{' + ns + '}challenge', ET.tostring(challenge) + first = base64.b64decode(challenge.text).decode() + attrs = dict(item.split('=', 1) for item in first.split(',')) + digest = hash_name.lower().replace('-', '') + expected_ssdp = '\x1e'.join(sorted(mechanisms)) + '\x1f' + '\x1e'.join(sorted(bindings)) + if ssdp: + assert attrs['h'] == b64(hashlib.new(digest, expected_ssdp.encode()).digest()) + else: + assert 'h' not in attrs + assert attrs['r'].startswith(bare.split('r=', 1)[1]) + assert int(attrs['i']) >= 4096 + # openssl req creates a SHA256-signed certificate; RFC 5929 hashes DER. + binding = hashlib.sha256(self.sock.getpeercert(binary_form=True)).digest() + if wrong_binding: + binding = bytes([binding[0] ^ 1]) + binding[1:] + final = 'c=' + b64(gs2.encode() + binding) + ',r=' + attrs['r'] + salted = hashlib.pbkdf2_hmac(digest, b'secret', base64.b64decode(attrs['s']), int(attrs['i'])) + mac = lambda key, message: hmac.new(key, message, digest).digest() + client_key = mac(salted, b'Client Key') + auth = (bare + ',' + first + ',' + final).encode() + signature = mac(hashlib.new(digest, client_key).digest(), auth) + proof = bytes(a ^ b for a, b in zip(client_key, signature)) + self.send(f"{b64((final + ',p=' + b64(proof)).encode())}") + result = self.stanza() + if wrong_binding: + assert result.tag == '{' + ns + '}failure', ET.tostring(result) + assert result.find('{' + SASL + '}not-authorized') is not None + else: + assert result.tag == '{' + ns + '}success', ET.tostring(result) + payload = result.findtext('{' + ns + '}additional-data') if sasl2 else result.text + expected = 'v=' + b64(mac(mac(salted, b'Server Key'), auth)) + assert base64.b64decode(payload).decode() == expected + + +def run(binary, old=False): + assert android_binding(['tls-exporter'], 'TLSv1.2') is None + assert android_binding(['tls-exporter'], 'TLSv1.3') == 'tls-exporter' + for hash_name, ssdp in itertools.product(('SHA-1', 'SHA-256'), (True,) if old else (False, True)): + with tempfile.TemporaryDirectory(prefix='xmpp-android-sasl-') as tmp: + root = Path(tmp) + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-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] + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2'] + (['sasl_ssdp'] if ssdp else [])}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': hash_name, 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + config.write_text(json.dumps(cfg)) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) + with (root/'server.log').open('w+') as log: + process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert process.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + versions = [ssl.TLSVersion.TLSv1_2] if old else [ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3] + for version in versions: + for sasl2 in (False, True): + for wrong in ([False] if old else [False, True]): + connection = Connection(port, root/'cert.pem', version) + try: + connection.auth(hash_name, sasl2, wrong, old, ssdp) + finally: + connection.sock.close() + except BaseException: + log.flush(); log.seek(0); print(log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill(); process.wait() + print('PASS: reproduced 0.6.0 TLS1.2 Android y,, rejection' if old else 'PASS: Android TLS1.2 selector, TLS1.2/1.3 SCRAM SHA1/256 PLUS, SASL1/2, SSDP on/off and invalid binding rejection') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve()), '--expect-060-failure' in sys.argv[2:]) diff --git a/tests/archive_mam.rs b/tests/archive_mam.rs new file mode 100644 index 0000000..bc1ed4f --- /dev/null +++ b/tests/archive_mam.rs @@ -0,0 +1,591 @@ +use rust_xmpp::{archive, mam, storage}; +fn query(root: &std::path::Path, xml: &str) -> Vec { + let d = roxmltree::Document::parse(xml).unwrap(); + mam::handle( + root, + "example.org", + "alice@example.org/phone", + d.root_element(), + ) + .unwrap() + .unwrap() + .replies +} +#[test] +fn legacy_records_binary_cache_and_roundtrip() { + let t = tempfile::tempdir().unwrap(); + let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p,b"item({name='message';attr={xmlns='jabber:client';stamp='2026-01-01T00:00:00Z'};key='old';with='bob@example.org';{name='body';attr={};'hello'}});\n").unwrap(); + std::fs::write(p.with_extension("lidx"), b"binary cache").unwrap(); + let rows = archive::load(t.path(), "example.org", "alice").unwrap(); + assert_eq!(rows[0].id, "old"); + assert!(rows[0].xml.contains("hello")); + assert!(!rows[0].xml.contains("stamp=")); + archive::archive_message( + t.path(), + "example.org", + "alice", + "bob@example.org", + "Привет & test", + 1767225601., + ) + .unwrap(); + assert!(!p.with_extension("lidx").exists()); + let rows = archive::load(t.path(), "example.org", "alice").unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows[1].xml.contains("Привет")); + // Verify written syntax through actual Lua item callback, independent of Rust decoder. + let lua = mlua::Lua::new(); + let code = format!( + "local count=0; local function item(v) assert(v.name=='message'); assert(v.attr.stamp);count=count+1 end; {}; return count", + std::fs::read_to_string(p).unwrap() + ); + assert_eq!(lua.load(&code).eval::().unwrap(), 2); +} +#[test] +fn mam_paging_filters_and_forbidden() { + let t = tempfile::tempdir().unwrap(); + let mut ids = vec![]; + for i in 0..4 { + ids.push( + archive::archive_message( + t.path(), + "example.org", + "alice", + if i % 2 == 0 { + "bob@example.org" + } else { + "carol@example.org" + }, + "x", + 1767225600. + i as f64, + ) + .unwrap(), + ); + } + let r = query( + t.path(), + "2", + ); + assert_eq!(r.len(), 3); + assert!(r[0].contains(&ids[0])); + assert!(r[2].contains("complete='false'")); + assert!(r[2].contains("4")); + let r = query( + t.path(), + "1", + ); + assert!(r[0].contains(&ids[3])); + let r = query( + t.path(), + "urn:xmpp:mam:2bob@example.org", + ); + assert_eq!(r.len(), 3); + assert!(r[2].contains("2")); + assert!( + query( + t.path(), + "" + )[0] + .contains("forbidden") + ); + assert!(query(t.path(),"missing")[0].contains("item-not-found")); +} +#[test] +fn mam_preferences_persist() { + let t = tempfile::tempdir().unwrap(); + let r = query( + t.path(), + "bob@example.org", + ); + assert!(r[0].contains("default='never'")); + assert!( + mam::should_archive( + t.path(), + "example.org", + "alice", + "bob@example.org", + "always" + ) + .unwrap() + ); + assert!( + !mam::should_archive( + t.path(), + "example.org", + "alice", + "carol@example.org", + "always" + ) + .unwrap() + ); + let p = storage::load(t.path(), "example.org", Some("alice"), "archive_prefs") + .unwrap() + .unwrap(); + assert_eq!( + p.get_key(&storage::LuaValue::Bool(false)), + Some(&storage::LuaValue::Bool(false)) + ); +} +#[test] +fn namespaces_expiry_and_concurrent_append() { + let t = tempfile::tempdir().unwrap(); + let root = t.path().to_path_buf(); + std::thread::scope(|scope| { + for i in 0..12 { + let r = &root; + scope.spawn(move||{archive::archive_message(r,"example.org","alice","bob@example.org","test",1767225600.+i as f64).unwrap();}); + } + }); + let rows = archive::load(&root, "example.org", "alice").unwrap(); + assert_eq!(rows.len(), 12); + let doc = roxmltree::Document::parse(&rows[0].xml).unwrap(); + assert_eq!( + doc.root_element() + .attribute(("http://www.w3.org/XML/1998/namespace", "lang")), + Some("ru") + ); + assert_eq!( + archive::expire_before(&root, "example.org", Some("alice"), "archive", 1767225606.) + .unwrap(), + 6 + ); + assert_eq!( + archive::load(&root, "example.org", "alice").unwrap().len(), + 6 + ); +} +#[test] +fn zero_page_dates_and_invalid_forms() { + let t = tempfile::tempdir().unwrap(); + archive::archive_message( + t.path(), + "example.org", + "alice", + "bob@example.org", + "x", + 1767225600., + ) + .unwrap(); + let r = query( + t.path(), + "0", + ); + assert_eq!(r.len(), 1); + assert!(r[0].contains("1")); + let r = query( + t.path(), + "urn:xmpp:mam:22026-01-02T00:00:00Z", + ); + assert_eq!(r.len(), 1); + assert!(r[0].contains("0")); + assert!(query(t.path(),"2026-01-02T00:00:00Z")[0].contains("bad-request")); +} +#[test] +fn append_is_incremental_and_recovers_torn_tail() { + use std::io::Write; + let t = tempfile::tempdir().unwrap(); + let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + let original=b"item({\n name='message'; attr={xmlns='jabber:client'}; key='legacy'; when=1767225600; with='bob@example.org';\n {name='body';attr={};'original'}\n});\n"; + std::fs::write(&p, original).unwrap(); + #[cfg(unix)] + let inode = { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(&p).unwrap().ino() + }; + archive::archive_message( + t.path(), + "example.org", + "alice", + "bob@example.org", + "new", + 1767225601., + ) + .unwrap(); + let intact = std::fs::read(&p).unwrap(); + assert!(intact.starts_with(original)); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(inode, std::fs::metadata(&p).unwrap().ino()); + } + std::fs::write( + p.with_extension("rappend"), + (intact.len() as u64).to_le_bytes(), + ) + .unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&p) + .unwrap() + .write_all(b"item({bad torn write") + .unwrap(); + assert_eq!( + archive::load(t.path(), "example.org", "alice") + .unwrap() + .len(), + 2 + ); + assert_eq!(std::fs::read(&p).unwrap(), intact); + assert!(!p.with_extension("rappend").exists()); +} +#[test] +fn smart_enable_and_message_selection() { + let t = tempfile::tempdir().unwrap(); + assert!( + !mam::should_archive_with_options( + t.path(), + "example.org", + "alice", + "bob@example.org", + "always", + true + ) + .unwrap() + ); + let doc = + roxmltree::Document::parse("").unwrap(); + mam::handle_with_options( + t.path(), + "example.org", + "alice@example.org/r", + doc.root_element(), + "always", + true, + ) + .unwrap(); + assert!( + mam::should_archive_with_options( + t.path(), + "example.org", + "alice", + "bob@example.org", + "always", + true + ) + .unwrap() + ); + for (xml, expected) in [ + ("x", true), + ( + "", + true, + ), + ("x", false), + ( + "x", + false, + ), + ( + "", + false, + ), + ("", true), + ] { + let d = roxmltree::Document::parse(xml).unwrap(); + assert_eq!(mam::should_store(d.root_element()), expected, "{xml}"); + } +} +#[test] +#[ignore = "large-file scalability regression; run explicitly"] +fn archive_larger_than_128mib_streams() { + use std::io::Write; + let t = tempfile::tempdir().unwrap(); + let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + let mut f = std::fs::File::create(&p).unwrap(); + let padding = vec![b' '; 1024 * 1024]; + for i in 0..130 { + writeln!(f,"item({{name='message';attr={{xmlns='jabber:client'}};key='{i}';when=1767225600;with='bob@example.org';{{name='body';attr={{}};'x'}}}});").unwrap(); + f.write_all(&padding).unwrap(); + f.write_all(b"\n").unwrap(); + } + drop(f); + assert!(std::fs::metadata(&p).unwrap().len() > 128 * 1024 * 1024); + let mut count = 0; + archive::scan_store(t.path(), "example.org", Some("alice"), "archive", |_| { + count += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(count, 130); + let r = query( + t.path(), + "0", + ); + assert_eq!(r.len(), 1); + assert!(r[0].contains("130")); +} +#[test] +fn extended_ids_ranges_flip_and_metadata() { + let t = tempfile::tempdir().unwrap(); + for i in 1..=8 { + archive::append_new( + t.path(), + "example.org", + Some("alice"), + "archive", + archive::Entry { + id: format!("id{i}"), + when: 1767225600. + i as f64, + peer: "bob@example.org".into(), + xml: "x".into(), + }, + ) + .unwrap(); + } + let r = query( + t.path(), + "", + ); + let doc = roxmltree::Document::parse(&r[0]).unwrap(); + assert_eq!( + doc.descendants() + .find(|n| n.has_tag_name((mam::NS, "start"))) + .unwrap() + .attribute("id"), + Some("id1") + ); + assert_eq!( + doc.descendants() + .find(|n| n.has_tag_name((mam::NS, "end"))) + .unwrap() + .attribute("id"), + Some("id8") + ); + let r = query( + t.path(), + "urn:xmpp:mam:2id7id21", + ); + assert_eq!(r.len(), 2); + assert!(r[0].contains("id='id3'")); + assert!(r[1].contains("complete='false'")); + assert!(!r[1].contains("")); + assert!(!r[1].contains("index=")); + let r = query( + t.path(), + "urn:xmpp:mam:2id2id5id2", + ); + assert_eq!(r.len(), 3); + assert!(r[0].contains("id='id5'")); + assert!(r[1].contains("id='id2'")); + assert!(r[2].contains(">id2id5")); + let r = query( + t.path(), + "2id6", + ); + assert!(r[0].contains("id='id5'")); + assert!(r[1].contains("id='id4'")); + assert!(r[2].contains(">id4id5")); + let r = query( + t.path(), + "", + ); + assert!(r[0].contains("var='before-id'")); + assert!(r[0].contains("var='ids'")); +} +#[test] +fn real_archive_sid_and_sender_privacy() { + let t = tempfile::tempdir().unwrap(); + let input = "test"; + let (id, delivery) = mam::archive_for_user( + t.path(), + "example.org", + "alice", + "bob@example.org/r", + input, + 1767225600., + ) + .unwrap(); + assert!(!delivery.contains("forged")); + assert!(delivery.contains("remote")); + assert!(delivery.contains("origin")); + assert!(delivery.contains(&id)); + assert!(delivery.contains("chatstates")); + let saved = archive::load(t.path(), "example.org", "alice").unwrap(); + assert_eq!(saved[0].id, id); + assert_eq!(saved[0].peer, "bob@example.org"); + assert!(!saved[0].xml.contains(&id)); + assert!(!saved[0].xml.contains("chatstates")); + let stripped = mam::strip_owner_stanza_ids(&delivery, "example.org", "alice").unwrap(); + assert!(!stripped.contains(&id)); + assert!(stripped.contains("remote")); + assert!(stripped.contains("origin")); +} +#[test] +fn equal_cursors_and_normalized_query_jids() { + let t = tempfile::tempdir().unwrap(); + for i in 1..=3 { + archive::append_new( + t.path(), + "example.org", + Some("alice"), + "archive", + archive::Entry { + id: format!("id{i}"), + when: 1767225600. + i as f64, + peer: "bob@example.org".into(), + xml: "x".into(), + }, + ) + .unwrap(); + } + let r = query( + t.path(), + "id2id2", + ); + assert_eq!(r.len(), 1); + assert!(r[0].contains("type='result'")); + assert!(r[0].contains("complete='true'")); + let r = query( + t.path(), + "urn:xmpp:mam:2BOB@EXAMPLE.ORG/phone", + ); + assert_eq!(r.len(), 4); + assert!(r[3].contains("3")); + let r = query( + t.path(), + "urn:xmpp:mam:2bob@example.orgcarol@example.org", + ); + assert!(r[0].contains("bad-request")); + assert!(r[0].contains("type='modify'")); +} +#[test] +fn corrupt_archive_reports_server_error_and_namespace_stripping_is_precise() { + let t = tempfile::tempdir().unwrap(); + let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b"item({ broken").unwrap(); + let r = query( + t.path(), + "", + ); + assert!(r[0].contains("internal-server-error")); + assert!(!r[0].contains("bad-request")); + std::fs::remove_file(p).unwrap(); + let input = "x"; + let (id, delivery) = mam::archive_for_user_with_namespaces( + t.path(), + "example.org", + "alice", + "bob@example.org", + input, + 1767225600., + &["urn:custom:private"], + ) + .unwrap(); + assert!(delivery.contains("unrelated")); + assert!(delivery.contains("resource")); + assert!(!delivery.contains("id='fake'")); + assert!(delivery.contains("urn:custom:private")); + let stored = archive::load(t.path(), "example.org", "alice").unwrap(); + assert!(stored[0].xml.contains("chatstates")); + assert!(!stored[0].xml.contains("urn:custom:private")); + assert!(!stored[0].xml.contains(&id)); +} + +#[test] +fn muc_archive_filters_stanza_kind_instead_of_sender_jid() { + let t = tempfile::tempdir().unwrap(); + for (id, peer) in [ + ("group", "messagehello" + .into(), + }, + ) + .unwrap(); + } + for value in ["", "alice@example.org", "message<groupchat"] { + let xml = format!( + "urn:xmpp:mam:2{value}" + ); + let doc = roxmltree::Document::parse(&xml).unwrap(); + let iq = doc.root_element(); + let q = iq.children().find(|n| n.is_element()).unwrap(); + let replies = mam::handle_archive_query( + t.path(), + "rooms.example.org", + Some("room"), + "muc_log", + "room@rooms.example.org", + iq, + q, + ) + .unwrap() + .replies; + assert_eq!(replies.len(), 2); + assert!(replies[0].contains("id='group'") || replies[0].contains("id=\"group\"")); + assert!(!replies[0].contains("id='presence'")); + assert!(replies[1].contains("1")); + } +} + +#[test] +fn archive_roundtrip_preserves_default_namespace_resets() { + let temp = tempfile::tempdir().unwrap(); + let id = archive::archive_message( + temp.path(), + "example.org", + "alice", + "bob@example.org", + "saved", + 1767225600., + ).unwrap(); + // Retention rewrites records, so also check that namespace resets survive it. + archive::append_new( + temp.path(), + "example.org", + Some("alice"), + "archive", + archive::Entry { + id: "old".into(), + when: 1767225500., + peer: "bob@example.org".into(), + xml: "old".into(), + }, + ) + .unwrap(); + assert_eq!( + archive::expire_before( + temp.path(), + "example.org", + Some("alice"), + "archive", + 1767225550. + ) + .unwrap(), + 1 + ); + let rows = archive::load(temp.path(), "example.org", "alice").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, id); + let doc = roxmltree::Document::parse(&rows[0].xml).unwrap(); + for name in ["payload", "plain", "empty"] { + let node = doc + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == name) + .unwrap(); + assert_eq!(node.tag_name().namespace(), Some(""), "{name}"); + } + assert!( + doc.descendants() + .any(|n| n.has_tag_name(("urn:test", "nested"))) + ); + assert!( + doc.descendants() + .any(|n| n.has_tag_name(("jabber:client", "body"))) + ); +} diff --git a/tests/auth.rs b/tests/auth.rs new file mode 100644 index 0000000..8b8d45d --- /dev/null +++ b/tests/auth.rs @@ -0,0 +1,57 @@ +use rust_xmpp::auth; +#[test] +fn create_and_read_hashed_accounts() { + for hash in ["SHA-1", "SHA-256"] { + let dir = tempfile::tempdir().unwrap(); + auth::create(dir.path(), "test.local", "alice", "pässword", hash).unwrap(); + assert!(auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap()); + assert!(!auth::verify(dir.path(), "test.local", "alice", "incorrect", hash).unwrap()); + assert!(!auth::verify(dir.path(), "test.local", "missing", "pässword", hash).unwrap()); + assert!(auth::create(dir.path(), "test.local", "alice", "replacement", hash).is_err()); + assert!(auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap()); + let path = dir.path().join("test%2elocal/accounts/alice.dat"); + let bytes = std::fs::read_to_string(&path).unwrap(); + assert!(!bytes.contains("pässword")); + std::fs::write( + &path, + bytes.replace("return {", "return { disabled = true;"), + ) + .unwrap(); + assert!(!auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap()); + } +} +#[test] +fn traversal_and_malicious_account_records_rejected() { + let dir = tempfile::tempdir().unwrap(); + assert!(auth::create(dir.path(), "test.local", "../escape", "x", "SHA-1").is_err()); + auth::create(dir.path(), "test.local", "alice", "x", "SHA-1").unwrap(); + std::fs::write( + dir.path().join("test%2elocal/accounts/alice.dat"), + "return os.execute('false')", + ) + .unwrap(); + assert!(auth::verify(dir.path(), "test.local", "alice", "x", "SHA-1").is_err()); +} + +#[test] +fn authentication_prepares_names_without_relaxing_account_management() { + for hash in ["SHA-1", "SHA-256"] { + let dir = tempfile::tempdir().unwrap(); + auth::create(dir.path(), "test.local", "alice", "secret", hash).unwrap(); + for supplied in ["ALICE", "Alice", "al\u{00ad}ice"] { + assert_eq!(auth::prepared_auth_username(supplied).unwrap(), "alice"); + assert!(auth::verify(dir.path(), "test.local", supplied, "secret", hash).unwrap()); + assert!(!auth::verify(dir.path(), "test.local", supplied, "wrong", hash).unwrap()); + assert!( + auth::load_scram(dir.path(), "test.local", supplied, hash) + .unwrap() + .is_none() + ); + assert!(auth::create(dir.path(), "test.local", supplied, "secret", hash).is_err()); + } + for invalid in ["", "alice@example.com", "../escape", "alice\u{0007}"] { + assert!(auth::prepared_auth_username(invalid).is_err()); + assert!(!auth::verify(dir.path(), "test.local", invalid, "secret", hash).unwrap()); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..8124789 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,58 @@ +use std::process::Command; +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_rust-xmpp")) +} +#[test] +fn conversion_preserves_source_and_refuses_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("config.lua"); + let output = dir.path().join("config.yaml"); + let input = "VirtualHost 'test.local'\npassword='TEST-SECRET'"; + std::fs::write(&source, input).unwrap(); + let result = command() + .args([ + "convert", + source.to_str().unwrap(), + "yaml", + output.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(result.status.success()); + assert!(!String::from_utf8_lossy(&result.stdout).contains("TEST-SECRET")); + assert!( + std::fs::read_to_string(&output) + .unwrap() + .contains("TEST-SECRET") + ); + assert_eq!(std::fs::read_to_string(&source).unwrap(), input); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&output).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let bytes = std::fs::read(&output).unwrap(); + assert!( + !command() + .args([ + "convert", + source.to_str().unwrap(), + "yaml", + output.to_str().unwrap() + ]) + .output() + .unwrap() + .status + .success() + ); + assert_eq!(std::fs::read(&output).unwrap(), bytes); +} +#[test] +fn server_cannot_be_mistaken_for_running_service() { + let output = command().arg("serve").output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("not implemented")); +} diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..e0a8442 --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,214 @@ +use rust_xmpp::config::{self, Config}; +use serde_json::json; +use std::path::Path; + +fn lua(text: &str) -> anyhow::Result { + let dir = tempfile::tempdir()?; + let path = dir.path().join("config.lua"); + std::fs::write(&path, text)?; + config::load(&path) +} +#[test] +fn supplied_configuration_scopes_arithmetic_and_modules() { + let cfg = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap(); + assert_eq!(cfg.hosts.len(), 4); + assert_eq!( + cfg.option("wthome.ru", "authentication"), + Some(&json!("internal_hashed")) + ); + assert_eq!( + cfg.option("upload.wthome.ru", "http_file_share_size_limit"), + Some(&json!(10737418240_u64)) + ); + assert_eq!( + cfg.option("upload.wthome.ru", "http_file_share_global_quota"), + Some(&json!(107374182400_u64)) + ); + assert!(cfg.modules("wthome.ru").unwrap().contains("sasl2")); + assert_eq!( + cfg.modules("conference.wthome.ru") + .unwrap() + .into_iter() + .collect::>(), + vec!["muc", "muc_mam"] + ); + assert_eq!( + cfg.modules("upload.wthome.ru") + .unwrap() + .into_iter() + .collect::>(), + vec!["http_file_share"] + ); + assert!( + cfg.diagnostics() + .iter() + .any(|d| d.message.contains("security_labels")) + ); +} +#[test] +fn all_formats_round_trip_actual_config() { + let c = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap(); + let dir = tempfile::tempdir().unwrap(); + for (ext, text) in [ + ("json", serde_json::to_string(&c).unwrap()), + ("yaml", serde_yaml_ng::to_string(&c).unwrap()), + ("toml", toml::to_string(&c).unwrap()), + ] { + let p = dir.path().join(format!("c.{ext}")); + std::fs::write(&p, text).unwrap(); + assert_eq!( + serde_json::to_value(&c).unwrap(), + serde_json::to_value(config::load(&p).unwrap()).unwrap() + ); + } +} +#[test] +fn include_has_own_global_scope_and_parent_scope_is_restored() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("main.lua"), + "VirtualHost 'one.test'\nInclude 'child.lua'\nname='one'", + ) + .unwrap(); + std::fs::write( + dir.path().join("child.lua"), + "archive_expires_after='never'\nVirtualHost 'two.test'\nname='two'", + ) + .unwrap(); + let c = config::load(&dir.path().join("main.lua")).unwrap(); + assert_eq!(c.global["archive_expires_after"], json!("never")); + assert_eq!(c.hosts["one.test"].options["name"], json!("one")); + assert_eq!(c.hosts["two.test"].options["name"], json!("two")); +} +#[test] +fn include_cycles_rejected() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("c.lua"); + std::fs::write(&p, "Include 'c.lua'").unwrap(); + assert!(config::load(&p).unwrap_err().to_string().contains("cycle")); +} +#[test] +fn declarations_and_overrides() { + let c=lua("modules_enabled={'roster','mam'}\nVirtualHost 'ONE.TEST' {modules_disabled={'mam'}}\nComponent 'muc.test' 'muc' {name='Rooms'}\nallow_registration=false").unwrap(); + assert_eq!( + c.modules("one.test") + .unwrap() + .into_iter() + .collect::>(), + vec!["roster"] + ); + assert_eq!(c.global["allow_registration"], json!(false)); + assert_eq!(c.hosts["muc.test"].component.as_deref(), Some("muc")); +} +#[test] +fn runtime_capabilities_are_unavailable() { + for source in [ + "os.execute('true')", + "io.open('/etc/passwd')", + "require('socket')", + "RunScript 'x'", + "Lua.os.execute('true')", + ] { + assert!(lua(source).is_err(), "{source}"); + } +} +#[test] +fn loops_are_bounded() { + assert!( + lua("while true do end") + .unwrap_err() + .to_string() + .contains("budget") + ); +} +#[test] +fn malformed_values_rejected() { + for text in [ + "VirtualHost 'x.test'\nc2s_ports={0}", + "VirtualHost 'x.test'\nc2s_ports={65536}", + "VirtualHost 'x.test'\nc2s_ports={5222,5222}", + "VirtualHost 'x.test'\nmodules_enabled='mam'", + "VirtualHost 'x.test'\nallow_registration='false'", + "VirtualHost 'x.test'\nComponent 'x.test' 'muc'", + "VirtualHost '../x'", + "VirtualHost 'x.test'\nx={ [2]='hole' }", + "VirtualHost 'x.test'\nx={ 'item', key='value' }", + ] { + assert!(lua(text).is_err(), "accepted {text}"); + } +} +#[test] +fn tables_cannot_recurse_forever() { + assert!(lua("local t={}\nt.self=t\nVirtualHost 'x.test'\nx=t").is_err()); +} +#[test] +fn component_can_explicitly_enable_global_modules() { + let c = lua("modules_enabled={'ping'}\nComponent 'muc.test' 'muc'\nload_global_modules=true") + .unwrap(); + assert!(c.modules("muc.test").unwrap().contains("ping")); +} +#[test] +fn storage_paths_encode_like_prosody() { + use rust_xmpp::storage::encode_name; + assert_eq!(encode_name("wthome.ru"), "wthome%2eru"); + assert_eq!(encode_name("../x_y"), "%2e%2e%2fx%5fy"); + assert_eq!(encode_name("я"), "%d1%8f"); +} + +#[test] +fn supplied_config_matches_original_prosody_loader() { + // The original loader is run only as a reference in tests, with stubs for + // helpers unused by this fixture. This is not a runtime dependency. + let vm = mlua::Lua::new(); + vm.load( + r#" + package.preload['prosody.util.envload'] = function() + return { envload=function(s,n,e) return load(s,n,'t',e) end } + end + package.preload['prosody.util.dependencies'] = function() + return { softreq=function() return nil end } + end + package.preload['prosody.util.iterators'] = function() return {} end + package.preload['prosody.util.paths'] = function() + return { resolve_relative_path=function(p,f) return p..f end } + end + package.preload['prosody.util.debug'] = function() + return { get_traceback_table=function() return {} end } + end + package.preload['prosody.util.error'] = function() + return { is_error=function() return false end } + end + package.preload['prosody.util.logger'] = function() + return { init=function() return function() end end } + end + "#, + ) + .exec() + .unwrap(); + let reference: mlua::Table = vm + .load(include_str!("reference/configmanager.lua")) + .eval() + .unwrap(); + let load: mlua::Function = reference.get("load").unwrap(); + let (ok, _, _): (bool, mlua::Value, mlua::Value) = load + .call(("tests/fixtures/prosody.cfg.lua", "lua")) + .unwrap(); + assert!(ok); + let get: mlua::Function = reference.get("getconfig").unwrap(); + let table: mlua::Table = get.call(()).unwrap(); + let parsed = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap(); + let global: mlua::Value = table.get("*").unwrap(); + assert_eq!( + rust_xmpp::lua::json_value(global, 0).unwrap(), + json!(parsed.global) + ); + for (name, host) in &parsed.hosts { + let v: mlua::Value = table.get(name.as_str()).unwrap(); + let mut reference = rust_xmpp::lua::json_value(v, 0).unwrap(); + let object = reference.as_object_mut().unwrap(); + object.remove("defined"); + let component = object.remove("component_module"); + assert_eq!(component, host.component.as_ref().map(|v| json!(v))); + assert_eq!(reference, json!(host.options)); + } +} diff --git a/tests/csi.rs b/tests/csi.rs new file mode 100644 index 0000000..bd1a06e --- /dev/null +++ b/tests/csi.rs @@ -0,0 +1,64 @@ +use rust_xmpp::csi::{Buffer, important}; +use std::time::{Duration, Instant}; +#[test] +fn native_importance_rules() { + for xml in [ + "", + "", + "", + "", + "news", + ] { + assert!(!important(xml, &[]), "{xml}"); + } + for xml in [ + " ", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "hey", + ] { + assert!(important(xml, &[]), "{xml}"); + } + assert!(important( + "", + &["{example:ns}custom/part".into()] + )); +} +#[test] +fn bounded_queue_flush_delay_activity_and_grace() { + let mut b = Buffer::default(); + b.configure(2, Duration::from_secs(5), vec![]); + b.set_inactive(true); + let now = Instant::now(); + assert!(b.output("".into(), "a@example", now).is_empty()); + assert!(b.output("".into(), "a@example", now).is_empty()); + let output = b.output("".into(), "a@example", now); + assert_eq!(output.len(), 3); + assert!(output[0].contains("urn:xmpp:delay")); + assert!(output[0].contains("a@example")); + assert_eq!(b.output("".into(), "a@example", now).len(), 1); + assert!( + b.output( + "".into(), + "a@example", + now + Duration::from_secs(6) + ) + .is_empty() + ); + assert_eq!(b.activity(now + Duration::from_secs(6)).len(), 1); + b.set_inactive(true); + b.output("".into(), "a@example", now); + assert_eq!(b.set_inactive(false).len(), 1); + b.set_inactive(true); + b.output("".into(), "a@example", now); + b.reset(); + assert!(b.activity(now).is_empty()); + assert_eq!(b.output("".into(), "a@example", now).len(), 1); +} diff --git a/tests/csi_native.rs b/tests/csi_native.rs new file mode 100644 index 0000000..5c3d9b6 --- /dev/null +++ b/tests/csi_native.rs @@ -0,0 +1,104 @@ +//! Compare importance decisions with the supplied, unmodified mod_csi_simple. +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn native_prosody_csi_importance_matches() { + let cases = [ + ("", "st.presence()"), + ( + "", + "st.presence({type='unavailable'})", + ), + ( + "", + "st.presence({type='subscribe'})", + ), + ( + "", + "st.iq({type='result',id='a'})", + ), + ("hi", "st.message({},'hi')"), + ( + "hi", + "st.message({type='headline'},'hi')", + ), + ( + "", + "st.message():tag('composing',{xmlns='http://jabber.org/protocol/chatstates'}):up()", + ), + ( + "", + "st.message():tag('encryption',{xmlns='urn:xmpp:eme:0'}):up()", + ), + ( + "", + "st.message():tag('encrypted',{xmlns='eu.siacs.conversations.axolotl'}):up()", + ), + ( + "", + "st.message():tag('sent',{xmlns='urn:xmpp:carbons:2'}):up()", + ), + ( + "hi", + "st.message():tag('received',{xmlns='urn:xmpp:carbons:2'}):tag('forwarded',{xmlns='urn:xmpp:forward:0'}):add_child(st.message({xmlns='jabber:client'},'hi')):reset()", + ), + ( + "", + "st.message():tag('propose',{xmlns='urn:xmpp:jingle-message:0'}):up()", + ), + ( + "", + "st.message():tag('x',{xmlns='http://jabber.org/protocol/muc#user'}):tag('invite'):reset()", + ), + ( + "", + "st.message():tag('payload',{xmlns='urn:test'}):up()", + ), + ( + "", + "st.stanza('r',{xmlns='urn:xmpp:sm:3'})", + ), + ]; + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let mut script = format!( + "{bootstrap}\nconfig.set('example.org','csi_important_payloads',{{'{{urn:test}}payload'}});\nassert(mm.load('example.org','csi_simple'));\nlocal csi=assert(mm.get_module('example.org','csi_simple'));\n" + ); + for (index, (_, expression)) in cases.iter().enumerate() { + script.push_str(&format!( + "print('CSI_CASE_{index}='..tostring(not not csi.is_important({expression})));\n" + )); + } + let folder = tempfile::tempdir().unwrap(); + let path = folder.path().join("csi.lua"); + std::fs::write(&path, script).unwrap(); + let output = std::process::Command::new( + std::env::var("PROSODY_REFERENCE_LUA").unwrap_or_else(|_| "lua5.4".into()), + ) + .arg(path) + .arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap()) + .arg(folder.path()) + .arg("csi") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + for (index, (xml, _)) in cases.iter().enumerate() { + let expected = format!( + "CSI_CASE_{index}={}", + rust_xmpp::csi::important(xml, &["{urn:test}payload".into()]) + ); + assert!( + stdout.lines().any(|line| line == expected), + "{xml}: expected {expected}, native output {stdout}" + ); + } +} diff --git a/tests/data_lock.rs b/tests/data_lock.rs new file mode 100644 index 0000000..48fd245 --- /dev/null +++ b/tests/data_lock.rs @@ -0,0 +1,52 @@ +use rust_xmpp::data_lock::DataDirectoryGuard; +use std::process::Command; + +#[test] +fn child_process_probe() { + let Some(root) = std::env::var_os("XMPP_LOCK_TEST_DIRECTORY") else { + return; + }; + let acquired = DataDirectoryGuard::acquire(std::path::Path::new(&root)).is_ok(); + assert_eq!( + acquired, + std::env::var("XMPP_LOCK_TEST_AVAILABLE").unwrap() == "yes" + ); +} + +fn probe(root: &std::path::Path, available: bool) { + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "child_process_probe", "--nocapture"]) + .env("XMPP_LOCK_TEST_DIRECTORY", root) + .env( + "XMPP_LOCK_TEST_AVAILABLE", + if available { "yes" } else { "no" }, + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn excludes_another_process_and_releases_on_drop() { + let root = tempfile::tempdir().unwrap(); + let native = root.path().join("existing-prosody-data"); + std::fs::write(&native, b"unchanged").unwrap(); + let guard = DataDirectoryGuard::acquire(root.path()).unwrap(); + probe(root.path(), false); + #[cfg(unix)] + { + let aliases = tempfile::tempdir().unwrap(); + let alias = aliases.path().join("same-volume"); + std::os::unix::fs::symlink(root.path(), &alias).unwrap(); + probe(&alias, false); + } + drop(guard); + probe(root.path(), true); + assert_eq!(std::fs::read(&native).unwrap(), b"unchanged"); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); +} diff --git a/tests/debug_legacy.rs b/tests/debug_legacy.rs new file mode 100644 index 0000000..26c22d3 --- /dev/null +++ b/tests/debug_legacy.rs @@ -0,0 +1,20 @@ +#[test] +fn debug_empty_legacy_bind() { + let t = tempfile::tempdir().unwrap(); + rust_xmpp::auth::create(t.path(), "localhost", "alice", "secret", "SHA-256").unwrap(); + eprintln!("before legacy"); + let r = + rust_xmpp::account_modules::legacy_on_bind(t.path(), "localhost", "alice@localhost/main") + .unwrap(); + eprintln!("after legacy {} {}", r.replies.len(), r.deliveries.len()); + let d = roxmltree::Document::parse("").unwrap(); + eprintln!("before avatar"); + let _ = rust_xmpp::account_modules::avatar_presence( + t.path(), + "localhost", + "alice@localhost/main", + d.root_element(), + ) + .unwrap(); + eprintln!("after avatar"); +} diff --git a/tests/federation_tls.rs b/tests/federation_tls.rs new file mode 100644 index 0000000..5011500 --- /dev/null +++ b/tests/federation_tls.rs @@ -0,0 +1,582 @@ +use rust_xmpp::federation::Federation; +use std::{fs, io::BufReader, path::Path, process::Command}; +use tokio::{net::TcpListener, sync::mpsc}; +use tokio_rustls::rustls::{ + RootCertStore, + pki_types::{CertificateDer, PrivateKeyDer}, +}; +fn openssl(dir: &Path, args: &[&str]) { + let out = Command::new("openssl") + .current_dir(dir) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} +fn fixture(dir: &Path) { + openssl( + dir, + &[ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + "ca.key", + "-out", + "ca.pem", + "-days", + "1", + "-subj", + "/CN=Federation Test CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + ], + ); + for domain in ["alpha.test", "beta.test"] { + let key = format!("{domain}.key"); + let csr = format!("{domain}.csr"); + let cert = format!("{domain}.pem"); + let ext = format!("{domain}.ext"); + fs::write(dir.join(&ext),format!("basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:{domain}\n")).unwrap(); + openssl( + dir, + &[ + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + &key, + "-out", + &csr, + "-subj", + &format!("/CN={domain}"), + ], + ); + openssl( + dir, + &[ + "x509", + "-req", + "-in", + &csr, + "-CA", + "ca.pem", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-out", + &cert, + "-days", + "1", + "-extfile", + &ext, + ], + ); + } +} +fn material( + dir: &Path, + domain: &str, +) -> ( + Vec>, + PrivateKeyDer<'static>, + RootCertStore, +) { + let certs = rustls_pemfile::certs(&mut BufReader::new( + fs::File::open(dir.join(format!("{domain}.pem"))).unwrap(), + )) + .collect::, _>>() + .unwrap(); + let key = rustls_pemfile::private_key(&mut BufReader::new( + fs::File::open(dir.join(format!("{domain}.key"))).unwrap(), + )) + .unwrap() + .unwrap(); + let mut roots = RootCertStore::empty(); + for cert in rustls_pemfile::certs(&mut BufReader::new( + fs::File::open(dir.join("ca.pem")).unwrap(), + )) { + roots.add(cert.unwrap()).unwrap(); + } + (certs, key, roots) +} +#[tokio::test(flavor = "multi_thread")] +async fn mutual_tls_external_delivery_and_certificate_identity_rejection() { + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path()); + let (a_cert, a_key, a_roots) = material(temp.path(), "alpha.test"); + let mut alice = Federation::new("alpha.test".into(), a_cert, a_key, a_roots).unwrap(); + let (b_cert, b_key, b_roots) = material(temp.path(), "beta.test"); + let bob = Federation::new("beta.test".into(), b_cert, b_key, b_roots).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + alice + .set_target("beta.test".into(), "127.0.0.1".into(), port) + .unwrap(); + let (tx, mut rx) = mpsc::channel(8); + let task = tokio::spawn(async move { + loop { + let (tcp, _) = listener.accept().await.unwrap(); + let bob = bob.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let _ = bob.accept(tcp, tx).await; + }); + } + }); + let xml = "Helloopaque"; + alice + .route("alpha.test", "bob@beta.test", xml) + .await + .unwrap(); + let inbound = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(inbound.from_domain, "alpha.test"); + assert!(inbound.xml.contains("jabber:server")); + assert!(inbound.xml.contains("urn:xmpp:omemo:2")); + // An operator routing override changes only the TCP endpoint: beta's trusted + // certificate must never authenticate it as gamma.test. + alice + .set_target("gamma.test".into(), "127.0.0.1".into(), port) + .unwrap(); + assert!( + alice + .route( + "alpha.test", + "bob@gamma.test", + "", + ) + .await + .is_err() + ); + assert!(rx.try_recv().is_err()); + // Domain matching, target lookup and certificate authentication use the same + // case-insensitive DNS identity. + alice + .route( + "ALPHA.TEST", + "bob@BETA.TEST", + "", + ) + .await + .unwrap(); + let received = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(received.xml.contains("mixed-case")); + // Identity matching cannot compensate for an untrusted certificate chain. + let (certs, key, _) = material(temp.path(), "alpha.test"); + let mut unrelated_roots = RootCertStore::empty(); + unrelated_roots.add(certs[0].clone()).unwrap(); + let mut untrusted = Federation::new("alpha.test".into(), certs, key, unrelated_roots).unwrap(); + untrusted + .set_target("beta.test".into(), "127.0.0.1".into(), port) + .unwrap(); + assert!( + untrusted + .route("alpha.test", "bob@beta.test", xml) + .await + .is_err() + ); + assert!(rx.try_recv().is_err()); + // Owning a valid certificate for alpha.test never authorizes gamma.test. + alice.add_local_domain("gamma.test".into()).unwrap(); + assert!( + alice + .route( + "gamma.test", + "bob@beta.test", + "forged" + ) + .await + .is_err() + ); + assert!(rx.try_recv().is_err()); + assert!( + alice + .route( + "alpha.test", + "bob@beta.test", + "" + ) + .await + .is_err() + ); + // Source-domain authentication cannot be reused to deliver to a third domain. + assert!( + alice + .route( + "alpha.test", + "bob@beta.test", + "" + ) + .await + .is_err() + ); + task.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reuses_connections_serializes_writes_and_retires_idle_or_closed_peers() { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; + use std::time::Duration; + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path()); + let (c, k, r) = material(temp.path(), "alpha.test"); + let mut alice = Federation::new("alpha.test".into(), c, k, r) + .unwrap() + .with_outbound_idle_timeout(Duration::from_millis(300)) + .unwrap(); + let (c, k, r) = material(temp.path(), "beta.test"); + let bob = Federation::new("beta.test".into(), c, k, r).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + alice + .set_target( + "beta.test".into(), + "127.0.0.1".into(), + listener.local_addr().unwrap().port(), + ) + .unwrap(); + let accepted = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let peers = Arc::new(Mutex::new(Vec::::new())); + let (tx, mut rx) = mpsc::channel(64); + let server = { + let accepted = accepted.clone(); + let completed = completed.clone(); + let peers = peers.clone(); + tokio::spawn(async move { + loop { + let (tcp, _) = listener.accept().await.unwrap(); + accepted.fetch_add(1, Ordering::SeqCst); + let bob = bob.clone(); + let tx = tx.clone(); + let completed = completed.clone(); + let handle = tokio::spawn(async move { + let _ = bob.accept(tcp, tx).await; + completed.fetch_add(1, Ordering::SeqCst); + }); + peers.lock().unwrap().push(handle.abort_handle()); + } + }) + }; + let stanza = |id: usize| { + format!( + "payload-{id}" + ) + }; + for id in 0..3 { + alice + .route("alpha.test", "bob@beta.test", &stanza(id)) + .await + .unwrap(); + let received = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(received.xml.contains(&format!("message-{id}"))); + } + assert_eq!( + accepted.load(Ordering::SeqCst), + 1, + "sequential sends should reuse one authenticated TLS connection" + ); + assert!( + alice + .route( + "alpha.test", + "bob@beta.test", + "" + ) + .await + .is_err() + ); + assert_eq!( + accepted.load(Ordering::SeqCst), + 1, + "source checks apply before reuse" + ); + let mut jobs = vec![]; + for id in 3..23 { + let alice = alice.clone(); + let xml = stanza(id); + jobs.push(tokio::spawn(async move { + alice + .route("alpha.test", "bob@beta.test", &xml) + .await + .unwrap() + })); + } + for job in jobs { + job.await.unwrap(); + } + let mut ids = std::collections::BTreeSet::new(); + for _ in 3..23 { + let received = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + let d = roxmltree::Document::parse(&received.xml).unwrap(); + ids.insert(d.root_element().attribute("id").unwrap().to_owned()); + } + assert_eq!(ids.len(), 20); + assert_eq!( + accepted.load(Ordering::SeqCst), + 1, + "concurrent clones share a per-pair connection" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + completed.load(Ordering::SeqCst) >= 1, + "idle connection must close without another route call" + ); + alice + .route("alpha.test", "bob@beta.test", &stanza(23)) + .await + .unwrap(); + let received = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(received.xml.contains("message-23")); + assert_eq!(accepted.load(Ordering::SeqCst), 2); + peers.lock().unwrap().last().unwrap().abort(); + tokio::time::sleep(Duration::from_millis(100)).await; + alice + .route("alpha.test", "bob@beta.test", &stanza(24)) + .await + .unwrap(); + let received = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(received.xml.contains("message-24")); + assert_eq!(accepted.load(Ordering::SeqCst), 3); + assert!(rx.try_recv().is_err(), "no automatic duplicate deliveries"); + drop(alice); + server.abort(); + for peer in peers.lock().unwrap().iter() { + peer.abort(); + } +} + +/// Native interop test, deliberately opt-in: needs a built Prosody source tree, +/// Lua 5.4, LuaSocket, LuaSec, LuaExpat and LuaFileSystem. +/// +/// PROSODY_SOURCE=/path/to/prosody-cafd74b9c5ea PROSODY_LUA=/path/to/lua5.4 \ +/// cargo test --test federation_tls native_prosody_tls_external_bidirectional -- --ignored --nocapture +/// +/// Lua's LUA_PATH/LUA_CPATH and the platform library path are inherited. The +/// probe plugin overrides TCP discovery only; both peers still authenticate the +/// original alpha.test/beta.test identities and trust only the generated CA. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires native Prosody cafd74b9c5ea and Lua TLS dependencies"] +async fn native_prosody_tls_external_bidirectional() { + use std::process::{Child, Stdio}; + use std::time::Duration; + struct Native(Child); + impl Drop for Native { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + fn lua_path(path: &Path) -> String { + format!( + "'{}'", + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('\'', "\\'") + ) + } + let source = std::env::var_os("PROSODY_SOURCE") + .map(std::path::PathBuf::from) + .expect("set PROSODY_SOURCE to the built Prosody cafd74b9c5ea source tree"); + let source = source.canonicalize().unwrap(); + assert_eq!( + fs::read_to_string(source.join("prosody.version")) + .unwrap() + .trim(), + "cafd74b9c5ea" + ); + let lua = std::env::var_os("PROSODY_LUA").unwrap_or_else(|| "lua5.4".into()); + let temp = tempfile::tempdir().unwrap(); + fixture(temp.path()); + // OpenSSL's TLS client validation requires clientAuth as well as serverAuth + // for s2s certificates, unlike rustls's server-identity verifier. + let ext = temp.path().join("alpha.test.ext"); + let extensions = fs::read_to_string(&ext).unwrap().replace( + "extendedKeyUsage=serverAuth", + "extendedKeyUsage=serverAuth,clientAuth", + ); + fs::write(ext, extensions).unwrap(); + openssl( + temp.path(), + &[ + "x509", + "-req", + "-in", + "alpha.test.csr", + "-CA", + "ca.pem", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-out", + "alpha.test.pem", + "-days", + "1", + "-extfile", + "alpha.test.ext", + ], + ); + let (certs, key, roots) = material(temp.path(), "alpha.test"); + let mut rust = Federation::new("alpha.test".into(), certs, key, roots).unwrap(); + let rust_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rust_port = rust_listener.local_addr().unwrap().port(); + let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let prosody_port = reservation.local_addr().unwrap().port(); + drop(reservation); + rust.set_target("beta.test".into(), "127.0.0.1".into(), prosody_port) + .unwrap(); + let plugins = temp.path().join("plugins"); + fs::create_dir(&plugins).unwrap(); + fs::create_dir(temp.path().join("data")).unwrap(); + let received_marker = temp.path().join("received"); + fs::write(plugins.join("mod_native_probe.lua"), format!(r#" +local st = require "prosody.util.stanza"; +local basic = require "prosody.net.resolvers.basic"; +module:hook("s2sout-pre-connect", function(event) + assert(event.session.to_host == "alpha.test"); + event.resolver = basic.new("127.0.0.1", {rust_port}, "tcp"); +end); +module:hook("message/bare", function(event) + local stanza = event.stanza; + if stanza.attr.id ~= "rust-to-prosody" then return; end + assert(event.origin.secure and event.origin.type == "s2sin"); + assert(event.origin.external_auth == "succeeded"); + assert(event.origin.cert_chain_status == "valid"); + assert(event.origin.cert_identity_status == "valid"); + local marker = assert(io.open({}, "w")); + marker:write(stanza:get_child_text("body")); marker:close(); + module:send(st.message({{ from="bob@beta.test"; to="alice@alpha.test"; id="prosody-to-rust"; type="chat" }}):body("native-prosody-reply")); + return true; +end, 1000); +"#, lua_path(&received_marker))).unwrap(); + let config = temp.path().join("prosody.cfg.lua"); + fs::write( + &config, + format!( + r#" +daemonize = false; +run_as_root = true; +interfaces = {{ "127.0.0.1" }}; +c2s_ports = {{}}; +s2s_ports = {{ {prosody_port} }}; +http_ports = {{}}; +https_ports = {{}}; +plugin_paths = {{ {} }}; +data_path = {}; +modules_enabled = {{ "tls"; "saslauth"; "native_probe" }}; +modules_disabled = {{ "dialback"; "s2s_bidi"; "offline"; "c2s" }}; +s2s_secure_auth = true; +s2s_require_encryption = true; +log = "*console"; +ssl = {{ key = {}; certificate = {}; cafile = {}; capath = false; }}; +VirtualHost "beta.test" +authentication = "internal_hashed"; +"#, + lua_path(&plugins), + lua_path(&temp.path().join("data")), + lua_path(&temp.path().join("beta.test.key")), + lua_path(&temp.path().join("beta.test.pem")), + lua_path(&temp.path().join("ca.pem")) + ), + ) + .unwrap(); + let log_path = temp.path().join("prosody.log"); + let log = fs::File::create(&log_path).unwrap(); + let mut native = Native( + Command::new(lua) + .current_dir(&source) + .env("PROSODY_SRCDIR", &source) + .env("PROSODY_PLUGINDIR", source.join("plugins")) + .arg(source.join("prosody")) + .args(["-F", "--config"]) + .arg(&config) + .stdout(Stdio::from(log.try_clone().unwrap())) + .stderr(Stdio::from(log)) + .spawn() + .expect("start native Prosody"), + ); + let log_text = || fs::read_to_string(&log_path).unwrap_or_default(); + let startup = tokio::time::timeout(Duration::from_secs(10), async { + loop { + assert!( + native.0.try_wait().unwrap().is_none(), + "Prosody exited:\n{}", + log_text() + ); + if tokio::net::TcpStream::connect(("127.0.0.1", prosody_port)) + .await + .is_ok() + { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await; + assert!(startup.is_ok(), "Prosody did not listen:\n{}", log_text()); + let (tx, mut rx) = mpsc::channel(4); + let receiving = { + let rust = rust.clone(); + tokio::spawn(async move { + let (tcp, _) = rust_listener.accept().await.unwrap(); + rust.accept(tcp, tx).await + }) + }; + let outcome = rust.route("alpha.test", "bob@beta.test", "native-rust-probe").await; + assert!( + outcome.is_ok(), + "Rust -> Prosody: {outcome:?}\n{}", + log_text() + ); + let reply = tokio::time::timeout(Duration::from_secs(10), rx.recv()).await; + assert!( + matches!(&reply, Ok(Some(_))), + "Prosody -> Rust: {reply:?}\n{}", + log_text() + ); + let reply = reply.unwrap().unwrap(); + assert_eq!(reply.from_domain, "beta.test"); + assert!(reply.xml.contains("prosody-to-rust")); + assert!(reply.xml.contains("native-prosody-reply")); + assert_eq!( + fs::read_to_string(received_marker).unwrap(), + "native-rust-probe" + ); + let logs = log_text(); + assert!( + logs.contains("Accepting SASL EXTERNAL identity from alpha.test"), + "{logs}" + ); + assert!( + logs.contains("SASL EXTERNAL with alpha.test succeeded"), + "{logs}" + ); + receiving.abort(); +} diff --git a/tests/fixtures/prosody.cfg.lua b/tests/fixtures/prosody.cfg.lua new file mode 100644 index 0000000..4843d0a --- /dev/null +++ b/tests/fixtures/prosody.cfg.lua @@ -0,0 +1,252 @@ +-- ============================================================================ +-- БАЗОВАЯ КОНФИГУРАЦИЯ PROSODY для wthome.ru +-- ============================================================================ + +-- Данные сервера +data_path = "/var/lib/prosody" + +-- Администраторы +admins = { "wt@wthome.ru" } + +external_addresses = { "88.204.18.107" } + +-- Плагины (модули) +modules_enabled = { + -- Обязательные модули + "roster"; -- Список контактов + "saslauth"; -- Аутентификация + "tls"; -- Шифрование TLS + "disco"; -- Service Discovery (нужен для OMEMO) + --"posix"; -- POSIX функции + + -- OMEMO и современные клиенты + "pep_simple"; -- Personal Eventing Protocol (ОБЯЗАТЕЛЬНО для OMEMO!) + "private"; -- Приватное хранилище + -- "vcard"; -- Визитные карточки + "pep"; + "vcard_legacy"; + --"pep_vcard_avatar"; + --"pubsub"; -- Publish-Subscribe + + -- Синхронизация и история + "carbons"; -- Message Carbons (синхронизация между устройствами) + "mam"; -- Message Archive Management (история сообщений) + "smacks"; -- Stream Management (для мобильных клиентов) + "csi_simple"; -- Client State Indication (оптимизация для мобильных) + + -- Звонки (Jingle) + "turn_external"; -- Внешний TURN сервер для звонков + + -- Дополнительные возможности + "register"; -- Регистрация аккаунтов + "blocklist"; -- Блокировка контактов + "bookmarks"; -- Закладки для конференций + "ping"; -- Keep-alive + "time"; -- Время сервера + "version"; -- Версия сервера + "uptime"; -- Время работы сервера + + -- Web-клиенты + "websocket"; -- WebSocket для веб-клиентов + "bosh"; -- BOSH (устаревший, но для совместимости) + + -- Файлы и медиа + --"http_file_share"; -- HTTP File Sharing (XEP-0363) + --"http_upload"; -- HTTP Upload (альтернатива) + "admin_shell"; + "cloud_notify"; + -- "s2s_auth_dane"; + "s2s"; + "s2s_auth_certs"; + "seclabels"; + "sasl_ssdp"; + "sasl2"; + -- "admin_web"; +} + +-- ============================================================================ +-- ЛОГИРОВАНИЕ +-- ============================================================================ + +log = { + info = "*console"; + error = "*console"; + -- "*syslog"; -- Раскомментировать для syslog +} + +-- ============================================================================ +-- ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ БЕЗОПАСНОСТИ +-- ============================================================================ + +-- Rate limiting +limits = { + c2s = { + rate = "100kb/s"; + }; + s2sin = { + rate = "300kb/s"; + }; +} + +security_labels = { + default_policy = "default"; + policies = { + ["default"] = { + classifications = { + { name = "Unclassified", default = true }, + { name = "Confidential" }, + { name = "Secret" }, + { name = "Top Secret" } + }; + }; + }; +} + +vcard_avatar = { + sync_to_vcard = true; + sync_to_pep = true; +} + +-- Отключаем ненужное +--modules_disabled = { +-- "s2s"; -- Отключить федерацию (убрать если нужна связь с другими серверами) +--} + +smacks_max_unacked = 100 +smacks_hibernation_time = 300 +allow_unencrypted_plain_auth = false + +push_notification_with_body = true +push_notification_with_sender = true +push_notification_important_body = "New message" +push_max_devices = 50 +push_max_errors = 32 +push_max_hibernation_timeout = 259200 + +-- Аутентификация +allow_registration = true -- Отключить публичную регистрацию (безопасность) + +-- TLS настройки +c2s_require_encryption = true +s2s_require_encryption = true +s2s_secure_auth = true + +-- Порты +interfaces = { "0.0.0.0", "::" } +c2s_ports = { 5222 } +s2s_ports = { 5269 } +http_ports = { 5280 } +https_ports = { 5281 } + +-- HTTP интерфейсы для файлов и BOSH +http_interfaces = { "*" } +https_interfaces = { "*" } + +-- BOSH/WebSocket настройки +consider_bosh_secure = true +consider_websocket_secure = true +--cross_domain_bosh = true +--cross_domain_websocket = true + +http_cors_override = { + bosh = { + enabled = true; + origins = { + "https://wthome.ru"; + }; + }; + websocket = { + enabled = true; + origins = { + "https://wthome.ru"; + }; + }; +} + +-- ============================================================================ +-- НАСТРОЙКА ЗВОНКОВ (TURN/STUN) +-- ============================================================================ + +-- Настройка внешнего TURN сервера для Jingle звонков +turn_external_host = "turn.wthome.ru" -- или IP сервера +turn_external_port = 3478 +turn_external_secret = "TEST-ONLY-NOT-A-REAL-SECRET" +turn_external_ttl = 86400 + +-- Альтернативно: если coturn на том же сервере +-- turn_external_host = "wthome.ru" + +-- ============================================================================ +-- НАСТРОЙКА OMEMO (через PEP) +-- ============================================================================ + +-- PEP настройки для OMEMO +pep_max_items = 10000 + +-- OMEMO работает через PEP автоматически при включенном mod_pep +-- Никаких дополнительных настроек не требуется! + +-- ============================================================================ +-- MAM (История сообщений) +-- ============================================================================ + +archive_expires_after = "never" -- never delete history archive +default_archive_policy = true -- Включить архивирование по умолчанию + +-- ============================================================================ +-- HTTP File Share (отправка файлов) +-- ============================================================================ + +-- Настройка компонента для файлов (см. ниже в VirtualHost) +--http_file_share_expires_after = 7 * 24 * 60 * 60 -- 7 дней +--http_file_share_global_quota = 1024 * 1024 * 1024 * 10 -- 10 GB + +-- ============================================================================ +-- ВИРТУАЛЬНЫЙ ХОСТ +-- ============================================================================ + +VirtualHost "wthome.ru" + authentication = "internal_hashed" + -- SSL сертификаты (Let's Encrypt или самоподписанные) + ssl = { + key = "/etc/prosody/certs/privkey.pem"; + certificate = "/etc/prosody/certs/fullchain.pem"; + } + + -- Дополнительные модули для этого домена + modules_enabled = { + "mam"; -- Убедимся что MAM включен + } + +-- ============================================================================ +-- КОМПОНЕНТЫ +-- ============================================================================ + +-- Конференции (MUC) +Component "conference.wthome.ru" "muc" + name = "Conference Rooms" + restrict_room_creation = "local" -- Только локальные пользователи создают комнаты + max_history_messages = 100 + muc_tombstones = true + + -- MAM для конференций (групповой чат) + modules_enabled = { +-- "mam"; + "muc_mam"; + } + mam_muc_archive_expires_after = "1w" + +-- Обмен файлами +Component "upload.wthome.ru" "http_file_share" + name = "File Sharing" + http_file_share_expires_after = "never" -- 7 дней + http_file_share_global_quota = 1024 * 1024 * 1024 * 100 -- 100 GB + http_file_share_size_limit = 1024 * 1024 * 1024 * 10 -- 10 GB + +-- PubSub (для OMEMO и других фич) +Component "pubsub.wthome.ru" "pubsub" + name = "PubSub Service" + restrict_node_creation = false + +-- Отключаем устаревшие методы аутентификации +-- disable_sasl_mechanisms = { "DIGEST-MD5", "PLAIN" } -- , "SCRAM-SHA-1" } diff --git a/tests/gajim_discovery_network.py b/tests/gajim_discovery_network.py new file mode 100644 index 0000000..5f47c67 --- /dev/null +++ b/tests/gajim_discovery_network.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Isolated TLS regression for Gajim's XEP-0258/XEP-0077 predicates and operations. + +Usage: python3 tests/gajim_discovery_network.py [target/release/rust-xmpp] +Only temporary accounts, certificates, and server data are used. +""" +import base64 +import json +from pathlib import Path +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +from _wire_client import BIN, Client, OPEN + +DISCO = 'http://jabber.org/protocol/disco#info' +REGISTER = 'jabber:iq:register' +LABEL = 'urn:xmpp:sec-label:0' +CATALOGS = ('urn:xmpp:sec-label:catalog:2', 'urn:xmpp:sec-label:catalog:0') +STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas' + + +class Unauthenticated(Client): + def __init__(self, port, cert): + self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) + self.buf = b'' + self.queue = [] + self.send(OPEN) + self.until(b'') + self.send("") + assert b'proceed' in self.until(b'/>') + self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket( + self.sock, server_hostname='localhost') + self.send(OPEN) + self.features = self.until(b'') + + +def iq(client, identifier, payload, target='localhost', typ='get', error=False): + client.send(f"{payload}") + return client.iq(identifier, error=error) + + +def error_condition(response, expected): + assert response.find(f'{{jabber:client}}error/{{{STANZAS}}}{expected}') is not None, ET.tostring(response) + + +def check_authenticated(client, enabled): + for target in ('localhost', 'alice@localhost'): + response = iq(client, 'disco-' + target, f"", target) + features = {node.get('var') for node in response.iter(f'{{{DISCO}}}feature')} + for namespace in (LABEL, REGISTER, CATALOGS[0]): + assert (namespace in features) == enabled, (target, enabled, namespace, features) + for index, namespace in enumerate(CATALOGS): + for target in ('localhost', 'alice@localhost'): + response = iq(client, f'catalog-{index}-{target}', + f"", target, + error=not enabled) + if not enabled: + error_condition(response, 'service-unavailable') + continue + catalog = response.find(f'{{{namespace}}}catalog') + assert catalog is not None, ET.tostring(response) + assert catalog.get('to') == 'bob@localhost', ET.tostring(response) + entry = catalog.find(f'{{{namespace}}}item') if index == 0 else catalog.find(f'{{{LABEL}}}securitylabel') + assert entry is not None, ET.tostring(response) + assert entry.get('selector') == 'Public' and entry.get('default') == 'true', ET.tostring(response) + label = entry.find(f'{{{LABEL}}}securitylabel') if index == 0 else entry + assert label.find(f'{{{LABEL}}}label').text == 'PUBLIC', ET.tostring(response) + response = iq(client, 'account-registration', f"", error=not enabled) + if enabled: + query = response.find(f'{{{REGISTER}}}query') + assert query is not None and query.find(f'{{{REGISTER}}}registered') is not None, ET.tostring(response) + assert query.find(f'{{{REGISTER}}}username').text == 'alice', ET.tostring(response) + iq(client, 'change-password', + f"alicechanged-secret", typ='set') + else: + error_condition(response, 'service-unavailable') + response = iq(client, 'change-password-disabled', + f"changed-secret", + typ='set', error=True) + error_condition(response, 'service-unavailable') + + +def scenario(binary, root, enabled, allow_registration): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + name = f'enabled-{enabled}-registration-{allow_registration}' + modules = ['tls', 'saslauth', 'pep', 'disco', 'ping'] + if enabled: + modules += ['seclabels', 'register'] + cfg = {'global': { + 'data_path': str(root / name), 'interfaces': ['127.0.0.1'], + 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], + 'modules_enabled': modules, 'allow_registration': allow_registration, + 'security_labels': [{'name': 'Public', 'label': 'PUBLIC', 'default': True}], + }, 'hosts': {'localhost': {'options': { + 'authentication': 'internal_hashed', 'password_hash': 'SHA-256', + 'ssl': {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')}, + }}}} + config = root / f'{name}.json' + config.write_text(json.dumps(cfg)) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], + input='original-secret\n', text=True, check=True, capture_output=True) + with (root / f'{name}.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + clients = [] + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + client = Client(port, root / 'cert.pem', 'alice', 'original-secret') + clients.append(client) + check_authenticated(client, enabled) + # An actual fresh SASL login proves the password update was persisted. + logged_in = Client(port, root / 'cert.pem', 'alice', + 'changed-secret' if enabled else 'original-secret', 'new-login') + clients.append(logged_in) + logged_in.sync() + unauth = Unauthenticated(port, root / 'cert.pem') + clients.append(unauth) + registration_enabled = enabled and allow_registration + assert (b'http://jabber.org/features/iq-register' in unauth.features) == registration_enabled, unauth.features + response = iq(unauth, 'ibr-form', f"", error=not registration_enabled) + if registration_enabled: + query = response.find(f'{{{REGISTER}}}query') + assert query is not None and query.find(f'{{{REGISTER}}}username') is not None, ET.tostring(response) + else: + error_condition(response, 'forbidden') + response = iq(unauth, 'ibr-create', + f"newuseribr-secret", + typ='set', error=not registration_enabled) + if registration_enabled: + registered = Client(port, root / 'cert.pem', 'newuser', 'ibr-secret', 'ibr-login') + clients.append(registered) + registered.sync() + # XEP-0077 removal must acknowledge the request and revoke login. + iq(registered, 'ibr-remove', f"", typ='set') + deleted_login = Unauthenticated(port, root / 'cert.pem') + clients.append(deleted_login) + credentials = base64.b64encode(b'\0newuser\0ibr-secret').decode() + deleted_login.send(f"{credentials}") + assert b'failure' in deleted_login.until(b'>') + assert not list((root / name).glob('*/accounts/newuser.dat')) + else: + error_condition(response, 'forbidden') + # Rejection must not create an account on disk. + assert not list((root / name).glob('*/accounts/newuser.dat')) + print('PASS:', name) + except BaseException: + log.flush() + log.seek(0) + print(log.read(), file=sys.stderr) + raise + finally: + for client in clients: + client.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-gajim-discovery-') 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) + for enabled in (False, True): + for allow_registration in (False, True): + scenario(binary, root, enabled, allow_registration) + print('PASS: Gajim domain/account discovery, both label catalogs, authenticated password changes, IBR registration gates and fresh logins') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else str(BIN)) diff --git a/tests/gajim_features.rs b/tests/gajim_features.rs new file mode 100644 index 0000000..690bef5 --- /dev/null +++ b/tests/gajim_features.rs @@ -0,0 +1,234 @@ +use rust_xmpp::{ + config::{Config, Host}, + extras, +}; +use serde_json::json; +use std::time::Instant; + +fn config() -> Config { + let mut config = Config::default(); + config.hosts.insert("example.test".into(), Host::default()); + config + .global + .insert("modules_enabled".into(), json!(["seclabels"])); + config +} + +fn request(config: &Config, namespace: &str, extra: &str) -> String { + request_at(config, "example.test", namespace, extra) +} + +fn request_at(config: &Config, domain: &str, namespace: &str, extra: &str) -> String { + let xml = format!( + "" + ); + let document = roxmltree::Document::parse(&xml).unwrap(); + extras::handle(config, domain, document.root_element(), Instant::now()) + .unwrap() + .unwrap() + .replies + .remove(0) +} + +#[test] +fn security_catalogs_serve_both_versions_and_preserve_requested_target() { + let mut config = config(); + config + .global + .insert("security_catalog_name".into(), json!("Team & partners")); + config.global.insert("security_labels".into(), json!({ + "Classified": {"SECRET": { + "label": "SECRET", + "display": "Secret & restricted", "color": "black", "bgcolor": "aqua", "default": true + }} + })); + for namespace in [ + "urn:xmpp:sec-label:catalog:2", + "urn:xmpp:sec-label:catalog:0", + ] { + let response = request(&config, namespace, " to='friend@example.test' "); + let document = roxmltree::Document::parse(&response).unwrap(); + let iq = document.root_element(); + assert_eq!(iq.attribute("type"), Some("result")); + assert_eq!(iq.attribute("id"), Some("catalog-1")); + let catalog = iq.first_element_child().unwrap(); + assert_eq!(catalog.tag_name().namespace(), Some(namespace)); + assert_eq!(catalog.attribute("to"), Some("friend@example.test")); + assert_eq!(catalog.attribute("name"), Some("Team & partners")); + let item = catalog.first_element_child().unwrap(); + assert_eq!(item.attribute("selector"), Some("Classified|SECRET")); + assert_eq!(item.attribute("default"), Some("true")); + let label = if namespace.ends_with(":2") { + assert_eq!(item.tag_name().name(), "item"); + item.first_element_child().unwrap() + } else { + item + }; + assert_eq!(label.tag_name().name(), "securitylabel"); + assert_eq!(label.tag_name().namespace(), Some("urn:xmpp:sec-label:0")); + let display = label.first_element_child().unwrap(); + assert_eq!(display.text(), Some("Secret & restricted")); + assert_eq!(display.attribute("fgcolor"), Some("black")); + assert_eq!(display.attribute("bgcolor"), Some("aqua")); + assert!( + label + .descendants() + .any(|node| node.has_tag_name(("urn:example:policy", "esssecuritylabel"))) + ); + } +} + +#[test] +fn security_catalog_default_is_a_real_label_and_module_can_be_disabled() { + let mut config = config(); + let response = request(&config, "urn:xmpp:sec-label:catalog:2", ""); + let document = roxmltree::Document::parse(&response).unwrap(); + let item = document + .descendants() + .find(|n| n.has_tag_name(("urn:xmpp:sec-label:catalog:2", "item"))) + .unwrap(); + assert_eq!(item.attribute("selector"), Some("Unclassified")); + assert_eq!(item.attribute("default"), Some("true")); + assert!( + item.descendants() + .any(|n| n.has_tag_name(("urn:xmpp:sec-label:0", "label")) + && n.text() == Some("Unclassified")) + ); + config.global.insert("modules_enabled".into(), json!([])); + let document = roxmltree::Document::parse( + "", + ) + .unwrap(); + assert!( + extras::handle( + &config, + "example.test", + document.root_element(), + Instant::now() + ) + .unwrap() + .is_none() + ); +} + +#[test] +fn unsupported_security_policy_configuration_returns_explicit_error() { + let mut config = config(); + config + .global + .insert("security_labels".into(), json!({"policies": ["SECRET"]})); + let response = request(&config, "urn:xmpp:sec-label:catalog:2", ""); + let document = roxmltree::Document::parse(&response).unwrap(); + assert_eq!(document.root_element().attribute("type"), Some("error")); + assert!( + document + .descendants() + .any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "not-acceptable"))) + ); +} + +#[test] +fn actual_example_policy_configuration_exposes_classifications_as_catalog_labels() { + let config: Config = serde_json::from_str(include_str!("../examples/config.json")).unwrap(); + for namespace in [ + "urn:xmpp:sec-label:catalog:2", + "urn:xmpp:sec-label:catalog:0", + ] { + let response = request_at(&config, "wthome.ru", namespace, " to='wt@wthome.ru'"); + let document = roxmltree::Document::parse(&response).unwrap(); + assert_eq!(document.root_element().attribute("type"), Some("result")); + let catalog = document.root_element().first_element_child().unwrap(); + assert_eq!(catalog.attribute("to"), Some("wt@wthome.ru")); + let entries: Vec<_> = catalog + .children() + .filter(|node| node.is_element()) + .collect(); + let names = ["Unclassified", "Confidential", "Secret", "Top Secret"]; + assert_eq!(entries.len(), names.len()); + for (index, (entry, name)) in entries.iter().zip(names).enumerate() { + assert_eq!(entry.attribute("selector"), Some(name)); + assert_eq!(entry.attribute("default"), (index == 0).then_some("true")); + let label = entry + .descendants() + .find(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "label"))) + .unwrap(); + assert_eq!(label.text(), Some(name)); + } + } +} + +#[test] +fn policy_catalog_selects_default_policy_and_maps_display_and_explicit_label() { + let mut config = config(); + config.global.insert("security_labels".into(), json!({ + "default_policy": "selected", "policies": { + "other": {"classifications": [{"name": "Other"}]}, + "selected": {"classifications": [{"name": "Secret", "label": "CLASSIFIED", "display": "Restricted", "color": "black", "bgcolor": "red", "default": true}]} + } + })); + let response = request(&config, "urn:xmpp:sec-label:catalog:2", ""); + let document = roxmltree::Document::parse(&response).unwrap(); + let item = document + .descendants() + .find(|node| node.has_tag_name(("urn:xmpp:sec-label:catalog:2", "item"))) + .unwrap(); + assert_eq!(item.attribute("selector"), Some("Secret")); + assert_eq!(item.attribute("default"), Some("true")); + let display = item + .descendants() + .find(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "displaymarking"))) + .unwrap(); + assert_eq!(display.text(), Some("Restricted")); + assert_eq!(display.attribute("fgcolor"), Some("black")); + assert_eq!(display.attribute("bgcolor"), Some("red")); + assert!( + item.descendants() + .any(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "label")) + && node.text() == Some("CLASSIFIED")) + ); + assert!(!response.contains("Other")); +} + +#[test] +fn malformed_or_unknown_policy_catalog_schemas_are_rejected() { + let cases = [ + json!({"policies": {}}), + json!({"default_policy": "default", "policies": {}}), + json!({"default_policy": "default", "policies": []}), + json!({"default_policy": "default", "policies": {"default": {}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": {}}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": " "}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": 1}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "default": "yes"}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "color": 1}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "label": false}]}}}), + json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "label": " Config { + serde_json::from_value(json!({"global":global,"hosts":{"xmpp.test":{"options":local}}})) + .unwrap() +} +#[test] +fn native_link_order_schemes_and_loaded_module_gates() { + let mut c = config( + json!({"modules_enabled":["http_altconnect","bosh","websocket"]}), + json!({}), + ); + let all = links(&c, "xmpp.test", true, 5281).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].rel, "urn:xmpp:alt-connections:xbosh"); + assert_eq!(all[0].href, "https://xmpp.test:5281/http-bind"); + assert_eq!(all[1].rel, "urn:xmpp:alt-connections:websocket"); + assert_eq!(all[1].href, "wss://xmpp.test:5281/xmpp-websocket"); + c.hosts + .get_mut("xmpp.test") + .unwrap() + .options + .insert("advertise_bosh".into(), json!(false)); + assert_eq!( + links(&c, "xmpp.test", false, 80).unwrap()[0].href, + "ws://xmpp.test/xmpp-websocket" + ); + c.global + .insert("modules_disabled".into(), json!(["websocket"])); + assert!(links(&c, "xmpp.test", true, 443).unwrap().is_empty()); +} +#[test] +fn public_prefix_and_host_paths_inherit_independently() { + let c = config( + json!({"modules_enabled":["http_altconnect","bosh","websocket"],"http_host":"web.test","http_external_url":"https://global.test/base","http_paths":{"bosh":"/bind-$host","altconnect":"/discover-$host"}}), + json!({"http_external_url":"https://proxy.test:443/public/","http_paths":{"websocket":"/ws"}}), + ); + let all = links(&c, "xmpp.test", false, 5280).unwrap(); + assert_eq!(all[0].href, "https://proxy.test/public/bind-xmpp.test"); + assert_eq!(all[1].href, "wss://proxy.test/public/ws"); + assert_eq!( + HttpConfig::for_host(&c, "xmpp.test") + .unwrap() + .altconnect_path, + "/discover-xmpp.test" + ); +} +#[test] +fn bad_advertisement_types_and_route_collisions_are_rejected() { + let c = config(json!({"advertise_websocket":"yes"}), json!({})); + assert!(c.validate().is_err()); + assert!(links(&c, "xmpp.test", false, 5280).is_err()); + for module in ["bosh", "websocket"] { + for path in ["/.well-known/host-meta", "/.well-known/host-meta.json/"] { + let c = config( + json!({"modules_enabled":["http_altconnect",module],"http_paths":{module:path}}), + json!({}), + ); + assert!(c.validate().is_err()); + } + } +} diff --git a/tests/http_altconnect_network.py b/tests/http_altconnect_network.py new file mode 100644 index 0000000..2c21c0e --- /dev/null +++ b/tests/http_altconnect_network.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Real-binary XEP-0156 host-meta regression using isolated loopback listeners. + +Requires Python 3.11+, openssl and websockets. No external services or trust +changes: the temporary certificate is trusted only by this test's TLS client. +""" +import argparse +import asyncio +from contextlib import contextmanager +import copy +import http.client +import json +from pathlib import Path +import socket +import ssl +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + +import websockets + +XRD = 'http://docs.oasis-open.org/ns/xri/xrd-1.0' +BOSH_REL = 'urn:xmpp:alt-connections:xbosh' +WS_REL = 'urn:xmpp:alt-connections:websocket' +FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing' + + +def free_port(): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + return probe.getsockname()[1] + + +def request(port, path, method='GET', context=None, host=None): + connection = (http.client.HTTPSConnection('localhost', port, context=context, timeout=5) + if context else http.client.HTTPConnection('127.0.0.1', port, timeout=5)) + try: + connection.request(method, path, headers={ + 'Host': host or f'localhost:{port}', 'Origin': 'https://unrelated.example', + 'Access-Control-Request-Method': 'GET', 'Connection': 'close'}) + response = connection.getresponse() + return response.status, {k.lower(): v for k, v in response.getheaders()}, response.read() + finally: + connection.close() + + +@contextmanager +def server(binary, config, root, label): + directory = root / label + directory.mkdir() + config = copy.deepcopy(config) + config['global']['data_path'] = str(directory / 'data') + path = directory / 'config.json' + path.write_text(json.dumps(config)) + ports = config['global']['http_ports'] + config['global']['https_ports'] + with (directory / 'server.log').open('w+') as log: + process = subprocess.Popen([binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for port in ports: + for _ in range(100): + assert process.poll() is None, f'{label}: server exited during startup' + try: + with socket.create_connection(('127.0.0.1', port), .1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError(f'{label}: listener {port} startup timed out') + yield + except BaseException: + log.flush() + log.seek(0) + print(f'{label} SERVER LOG:\n' + log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def documents(port, expected, context=None, base='/.well-known', host=None): + """Both serializations describe exactly the same ordered transport links.""" + for suffix in ('', '?callback=PRIVATE_CALLBACK&cache=1'): + for extension in ('', '.json'): + path = base + '/host-meta' + extension + suffix + status, headers, body = request(port, path, context=context, host=host) + assert status == 200, (path, status, body) + assert headers.get('access-control-allow-origin') == '*', headers + content_type = headers.get('content-type', '').split(';')[0] + if extension: + assert content_type == 'application/json', headers + parsed = json.loads(body) + assert isinstance(parsed.get('links'), list), parsed + links = [(link['rel'], link['href']) for link in parsed['links']] + else: + assert content_type == 'application/xrd+xml', headers + parsed = ET.fromstring(body) + assert parsed.tag == f'{{{XRD}}}XRD', body + links = [(link.get('rel'), link.get('href')) for link in parsed.findall(f'{{{XRD}}}Link')] + assert links == expected, (path, expected, links) + assert b'PRIVATE_CALLBACK' not in body, body + status, head_headers, body = request(port, path, 'HEAD', context, host) + assert status == 200 and body == b'', (path, status, body) + assert head_headers.get('access-control-allow-origin') == '*', head_headers + status, options_headers, body = request(port, path, 'OPTIONS', context, host) + assert 200 <= status < 300 and body == b'', (path, status, body) + assert options_headers.get('access-control-allow-origin') == '*', options_headers + allowed = {s.strip() for s in options_headers.get('access-control-allow-methods', '').split(',')} + assert {'GET', 'HEAD', 'OPTIONS'} <= allowed, options_headers + for extension in ('', '.json'): + status, _, body = request(port, base + '/host-meta' + extension, 'POST', context, host) + assert status == 405, (status, body) + + +async def websocket_exchange(port, context, path='/xmpp-websocket'): + async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context, + origin='https://browser.example', subprotocols=['xmpp'], open_timeout=5) as ws: + assert ws.subprotocol == 'xmpp' + await ws.send(f'') + assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}open' + assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag.endswith('}features') + await ws.send(f'') + assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}close' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='xmpp-http-altconnect-') as temporary: + root = Path(temporary) + cert, key = root / 'cert.pem', root / 'key.pem' + 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(key), '-out', str(cert)], + check=True, capture_output=True) + context = ssl.create_default_context(cafile=str(cert)) + http, https = free_port(), free_port() + while https == http: + https = free_port() + config = {'global': {'interfaces': ['127.0.0.1'], 'http_interfaces': ['127.0.0.1'], + 'https_interfaces': ['127.0.0.1'], 'c2s_ports': [], 's2s_ports': [], + 'http_ports': [http], 'https_ports': [https], + 'modules_enabled': ['tls', 'bosh', 'websocket', 'http_altconnect', 'saslauth'], + 'http_cors_override': {'websocket': {'origins': ['https://browser.example']}}, + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}} + secure = [(BOSH_REL, f'https://localhost:{https}/http-bind'), + (WS_REL, f'wss://localhost:{https}/xmpp-websocket')] + with server(args.binary, config, root, 'defaults'): + documents(http, secure) + documents(https, secure, context) + for port, tls in ((http, None), (https, context)): + for host in ('unknown.example', 'localhost:999999', 'bad host'): + status, _, body = request(port, '/.well-known/host-meta.json', context=tls, host=host) + assert status in (400, 404), (host, status, body) + asyncio.run(websocket_exchange(https, context)) + + custom = copy.deepcopy(config) + custom['global'].update({'http_paths': {'altconnect': 'discovery/$host/', + 'bosh': '/custom/bind', 'websocket': '/custom/socket'}, + 'http_external_url': 'https://global.example/ignored'}) + custom['hosts']['localhost']['options'].update({ + 'http_external_url': 'https://proxy.example:443/public/prefix/', 'http_host': 'chat.example'}) + public = [(BOSH_REL, 'https://proxy.example/public/prefix/custom/bind'), + (WS_REL, 'wss://proxy.example/public/prefix/custom/socket')] + with server(args.binary, custom, root, 'custom'): + for port, tls in ((http, None), (https, context)): + documents(port, public, tls, '/discovery/localhost', 'chat.example') + for path in ('/.well-known/host-meta', '/.well-known/host-meta.json', + '/public/prefix/discovery/localhost/host-meta'): + status, _, body = request(port, path, context=tls, host='chat.example') + assert status == 404, (path, status, body) + status, _, body = request(port, '/discovery/localhost/host-meta.json', context=tls) + assert status == 404, (status, body) + + disabled = copy.deepcopy(config) + disabled['hosts']['localhost']['options']['modules_disabled'] = ['http_altconnect'] + with server(args.binary, disabled, root, 'disabled'): + for port, tls in ((http, None), (https, context)): + for extension in ('', '.json'): + status, _, body = request(port, '/.well-known/host-meta' + extension, context=tls) + assert status == 404, (status, body) + asyncio.run(websocket_exchange(https, context)) + + for label, disabled_modules, flags, expected in ( + ('bosh-disabled', ['bosh'], {}, [secure[1]]), + ('websocket-disabled', ['websocket'], {}, [secure[0]]), + ('no-transports', ['bosh', 'websocket'], {}, []), + ('bosh-hidden', [], {'advertise_bosh': False}, [secure[1]]), + ('websocket-hidden', [], {'advertise_websocket': False}, [secure[0]]), + ('all-hidden', [], {'advertise_bosh': False, 'advertise_websocket': False}, [])): + variant = copy.deepcopy(config) + variant['hosts']['localhost']['options'].update(flags, modules_disabled=disabled_modules) + with server(args.binary, variant, root, label): + documents(http, expected) + + plain = copy.deepcopy(config) + plain['global']['https_ports'] = [] + plain_links = [(BOSH_REL, f'http://localhost:{http}/http-bind'), + (WS_REL, f'ws://localhost:{http}/xmpp-websocket')] + with server(args.binary, plain, root, 'http-only'): + documents(http, plain_links) + print('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') + + +if __name__ == '__main__': + main() diff --git a/tests/http_proxy_paths_network.py b/tests/http_proxy_paths_network.py new file mode 100644 index 0000000..4003f3f --- /dev/null +++ b/tests/http_proxy_paths_network.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Loopback network regression for custom routes and TLS-terminating proxies. + +Requires websockets and openssl. Uses an actual verified HTTPS proxy, HTTP +upstream, and isolated server data; never contacts an external service. +""" +import argparse +import asyncio +import base64 +from contextlib import contextmanager +import http.client +import json +from pathlib import Path +import select +import socket +import socketserver +import ssl +import subprocess +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + +import websockets + +ORIGIN = 'https://browser.example' +WS_PATH = '/private/xmpp/socket' +BOSH_PATH = '/private/xmpp/bind' +PUBLIC_WS = '/public/chat/socket' +PUBLIC_BOSH = '/public/chat/bind' +BOSH = 'http://jabber.org/protocol/httpbind' +FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing' +SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' +CLIENT = 'jabber:client' +BIND = 'urn:ietf:params:xml:ns:xmpp-bind' +PASSWORD = 'proxy-integration-password' +PLAIN = base64.b64encode(f'\0alice\0{PASSWORD}'.encode()).decode() + + +def free_port(): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + return probe.getsockname()[1] + + +def request(port, path, method='GET', body=b'', origin=ORIGIN, + forwarded=None, context=None, address='127.0.0.1', upgrade=False, rfc=False): + headers = {'Host': f'localhost:{port}', 'Connection': 'close'} + if origin is not None: + headers['Origin'] = origin + if forwarded is not None: + headers['Forwarded' if rfc else 'X-Forwarded-Proto'] = ( + f'for=198.51.100.77;proto={forwarded}' if rfc else forwarded) + if method == 'POST': + headers['Content-Type'] = 'text/xml' + if upgrade: + headers.update({'Connection': 'keep-alive, Upgrade', 'Upgrade': 'websocket', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Protocol': 'xmpp'}) + connection = (http.client.HTTPSConnection(address, port, context=context, timeout=8) + if context else http.client.HTTPConnection(address, port, timeout=8)) + try: + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +@contextmanager +def server(binary, config, root): + path = root / 'config.json' + path.write_text(json.dumps(config)) + with (root / 'server.log').open('w+') as log: + process = subprocess.Popen([binary, 'serve', str(path), '--experimental'], + stdout=log, stderr=log) + try: + for _ in range(100): + assert process.poll() is None, 'server exited during startup' + try: + with socket.create_connection(('127.0.0.1', config['global']['http_ports'][0]), .1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('HTTP listener startup timed out') + yield + except BaseException: + log.flush() + log.seek(0) + print('SERVER LOG:\n' + log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +class Proxy(socketserver.ThreadingTCPServer): + """Small TLS-terminating, path-rewriting TCP proxy supporting WS upgrades.""" + daemon_threads = True + allow_reuse_address = True + + def __init__(self, backend, tls, source='127.0.0.2', proto='https', rfc=False): + self.backend, self.tls, self.source, self.proto = backend, tls, source, proto + self.rfc = rfc + self.errors = [] + super().__init__(('127.0.0.1', 0), ProxyHandler) + + def get_request(self): + stream, peer = super().get_request() + try: + return self.tls.wrap_socket(stream, server_side=True), peer + except BaseException: + stream.close() + raise + + def handle_error(self, request, client_address): + import traceback + self.errors.append(traceback.format_exc()) + + +class ProxyHandler(socketserver.BaseRequestHandler): + def handle(self): + client = self.request + client.settimeout(8) + incoming = b'' + while b'\r\n\r\n' not in incoming: + chunk = client.recv(4096) + if not chunk: + return + incoming += chunk + assert len(incoming) < 65536, 'oversized proxy request headers' + head, payload = incoming.split(b'\r\n\r\n', 1) + lines = head.decode('latin1').split('\r\n') + method, path, version = lines[0].split(' ', 2) + for public, internal in ((PUBLIC_WS, WS_PATH), (PUBLIC_BOSH, BOSH_PATH)): + if path == public or path.startswith(public + '/') or path.startswith(public + '?'): + path = internal + path[len(public):] + break + # The fixture models a proxy which replaces client-supplied metadata. + headers = [line for line in lines[1:] if line.split(':', 1)[0].lower() + not in ('forwarded', 'x-forwarded-for', 'x-forwarded-proto')] + if self.server.rfc: + headers += [f'Forwarded: for=198.51.100.77;proto={self.server.proto}'] + else: + headers += ['X-Forwarded-For: 198.51.100.7', f'X-Forwarded-Proto: {self.server.proto}'] + outgoing = ('\r\n'.join([f'{method} {path} {version}', *headers]) + '\r\n\r\n').encode('latin1') + with socket.create_connection(('127.0.0.1', self.server.backend), timeout=8, + source_address=(self.server.source, 0)) as upstream: + upstream.sendall(outgoing + payload) + while True: + # SSLSocket may already have decrypted bytes buffered internally. + readable = [client] if client.pending() else select.select([client, upstream], [], [], 8)[0] + if not readable: + return + for source in readable: + try: + data = source.recv(65536) + if not data: + return + (upstream if source is client else client).sendall(data) + except (ConnectionResetError, BrokenPipeError, ssl.SSLEOFError): + # Raw handshake probes intentionally close after HTTP101. + return + + +@contextmanager +def proxy(backend, tls, **kwargs): + with Proxy(backend, tls, **kwargs) as fixture: + worker = threading.Thread(target=fixture.serve_forever, daemon=True) + worker.start() + try: + yield fixture.server_address[1] + assert not fixture.errors, '\n'.join(fixture.errors) + finally: + fixture.shutdown() + worker.join(timeout=5) + + +async def websocket_exchange(port, context, path, authenticate=False): + async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context, + origin=ORIGIN, subprotocols=['xmpp'], open_timeout=8) as ws: + assert ws.subprotocol == 'xmpp' + await ws.send(f'') + opened = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert opened.tag == f'{{{FRAMING}}}open', ET.tostring(opened) + features = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert features.find(f'.//{{{SASL}}}mechanism') is not None, ET.tostring(features) + if authenticate: + await ws.send(f'{PLAIN}') + success = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert success.tag == f'{{{SASL}}}success', ET.tostring(success) + await ws.send(f'') + assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}open' + assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag.endswith('}features') + await ws.send(f'proxy-ws') + bound = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert bound.get('type') == 'result' and bound.get('id') == 'bind', ET.tostring(bound) + assert bound.find(f'.//{{{BIND}}}jid').text == 'alice@localhost/proxy-ws', ET.tostring(bound) + await ws.send(f'') + pong = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert pong.get('type') == 'result' and pong.get('id') == 'proxy-ping', ET.tostring(pong) + await ws.send(f'') + closed = ET.fromstring(await asyncio.wait_for(ws.recv(), 5)) + assert closed.tag == f'{{{FRAMING}}}close', ET.tostring(closed) + + +def bosh_exchange(port, context, path, authenticate=False): + first = f''.encode() + status, _, payload = request(port, path, 'POST', first, context=context) + assert status == 200, (status, payload) + body = ET.fromstring(payload) + sid = body.get('sid') + assert sid and body.find(f'.//{{{SASL}}}mechanism') is not None, payload + rid = 101 + + def send(stanza='', extra=''): + nonlocal rid + document = f'{stanza}'.encode() + rid += 1 + status, _, response = request(port, path, 'POST', document, context=context) + assert status == 200, (status, response) + return document, response, ET.fromstring(response) + + if authenticate: + _, response, body = send(f'{PLAIN}') + assert body.find(f'{{{SASL}}}success') is not None, response + _, response, body = send(extra='xmlns:xmpp="urn:xmpp:xbosh" xmpp:restart="true" to="localhost"') + assert any(n.tag.endswith('}features') for n in body), response + _, response, body = send(f'proxy-bosh') + assert body.find(f'.//{{{BIND}}}jid').text == 'alice@localhost/proxy-bosh', response + poll, response, body = send(f'') + assert any(n.get('id') == 'proxy-ping' and n.get('type') == 'result' for n in body), response + else: + poll, response, body = send() + assert body.get('type') != 'terminate', response + # A retried RID after URI rewriting must return its exact cached response. + retried_status, _, retried = request(port, path, 'POST', poll, context=context) + assert retried_status == 200 and retried == response, (response, retried) + _, response, body = send(extra='type="terminate"') + assert body.get('type') == 'terminate', response + + +def policy_checks(port, context, ws_path, bosh_path): + for path, method, upgrade in ((ws_path, 'GET', True), (bosh_path, 'OPTIONS', False)): + status, headers, body = request(port, path, method, context=context, upgrade=upgrade) + assert status == (101 if upgrade else 200), (path, status, body) + assert headers.get('access-control-allow-origin') == ORIGIN, headers + for origin in ('https://wrong.example', ORIGIN + ':8443', 'null'): + status, _, body = request(port, path, method, context=context, + upgrade=upgrade, origin=origin) + assert status == 403, (path, origin, status, body) + status, headers, _ = request(port, path, method, context=context, + upgrade=upgrade, origin=None) + assert status == (101 if upgrade else 200), (path, status) + assert 'access-control-allow-origin' not in headers, headers + status, _, body = request(port, bosh_path, 'POST', context=context, + origin='https://wrong.example') + assert status == 403, (bosh_path, status, body) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='xmpp-http-proxy-paths-') as temporary: + root = Path(temporary) + cert, key = root / 'cert.pem', root / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1', + '-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)], + check=True, capture_output=True) + client_tls = ssl.create_default_context(cafile=str(cert)) + proxy_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + proxy_tls.load_cert_chain(str(cert), str(key)) + backend, https = free_port(), free_port() + interfaces = ['127.0.0.1'] + try: + with socket.socket(socket.AF_INET6) as probe: + probe.bind(('::1', 0)) + interfaces.append('::1') + except OSError: + pass + config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], + 'http_interfaces': interfaces, 'https_interfaces': interfaces, + 'c2s_ports': [], 's2s_ports': [], 'http_ports': [backend], 'https_ports': [https], + 'http_host': 'localhost', 'http_default_host': 'localhost', 'trusted_proxies': ['127.0.0.2/32'], + 'http_legacy_x_forwarded': True, + 'consider_websocket_secure': False, 'consider_bosh_secure': False, + 'http_paths': {'websocket': WS_PATH, 'bosh': BOSH_PATH}, + 'http_cors_override': {name: {'origins': [ORIGIN]} for name in ('websocket', 'bosh')}, + 'modules_enabled': ['tls', 'websocket', 'bosh', 'saslauth', 'ping'], + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}} + config_path = root / 'config.json' + config_path.write_text(json.dumps(config)) + subprocess.run([args.binary, 'create-user', str(config_path), 'alice', '--password-stdin'], + input=PASSWORD + '\n', text=True, check=True, capture_output=True) + with server(args.binary, config, root): + policy_checks(https, client_tls, WS_PATH, BOSH_PATH) + for suffix in ('', '/', '?cache=1'): + asyncio.run(websocket_exchange(https, client_tls, WS_PATH + suffix)) + bosh_exchange(https, client_tls, BOSH_PATH + suffix) + for path in ('/xmpp-websocket', '/xmpp-websocket/', '/http-bind', '/http-bind/'): + status, _, body = request(https, path, context=client_tls, upgrade='websocket' in path) + assert status == 404, (path, status, body) + for address in interfaces: + for path, method in ((WS_PATH, 'GET'), (BOSH_PATH, 'POST')): + for spoof in (None, 'https'): + status, _, body = request(backend, path, method, forwarded=spoof, + address=address, upgrade=method == 'GET') + assert status == 403, (address, path, spoof, status, body) + if address == '::1': + status, _, body = request(https, WS_PATH, context=client_tls, + address=address, upgrade=True) + assert status == 101, (address, status, body) + with proxy(backend, proxy_tls) as public: + policy_checks(public, client_tls, PUBLIC_WS, PUBLIC_BOSH) + asyncio.run(websocket_exchange(public, client_tls, PUBLIC_WS, authenticate=True)) + bosh_exchange(public, client_tls, PUBLIC_BOSH, authenticate=True) + # Both TLS on an untrusted peer and a trusted peer declaring plain + # HTTP must fail the same gate, before any XMPP session is created. + for options in ({'source': '127.0.0.1'}, {'proto': 'http'}): + with proxy(backend, proxy_tls, **options) as public: + for path, method in ((PUBLIC_WS, 'GET'), (PUBLIC_BOSH, 'POST')): + status, _, body = request(public, path, method, context=client_tls, + upgrade=method == 'GET') + assert status == 403, (options, path, status, body) + config['global']['http_legacy_x_forwarded'] = False + with server(args.binary, config, root): + with proxy(backend, proxy_tls, rfc=True) as public: + asyncio.run(websocket_exchange(public, client_tls, PUBLIC_WS, authenticate=True)) + bosh_exchange(public, client_tls, PUBLIC_BOSH, authenticate=True) + for path, method in ((WS_PATH, 'GET'), (BOSH_PATH, 'POST')): + status, _, body = request(backend, path, method, forwarded='https', + rfc=True, upgrade=method == 'GET') + assert status == 403, (path, status, body) + for options in ({'source': '127.0.0.1'}, {'proto': 'http'}): + with proxy(backend, proxy_tls, rfc=True, **options) as public: + for path, method in ((PUBLIC_WS, 'GET'), (PUBLIC_BOSH, 'POST')): + status, _, body = request(public, path, method, context=client_tls, + upgrade=method == 'GET') + assert status == 403, (options, path, status, body) + print('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') + + +if __name__ == '__main__': + main() diff --git a/tests/http_transport.rs b/tests/http_transport.rs new file mode 100644 index 0000000..2bbed1a --- /dev/null +++ b/tests/http_transport.rs @@ -0,0 +1,482 @@ +use rust_xmpp::{ + http_service::serve_router, + http_transport::{ConnectionHandler, bosh_router, websocket_router}, + wire::Framer, +}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; +fn handler(count: Arc) -> ConnectionHandler { + Arc::new(move |mut stream| { + let count = count.clone(); + Box::pin(async move { + let mut f = Framer::default(); + while let Ok(xml) = f.next(&mut stream).await { + if xml.starts_with("PLAIN").await.unwrap(); + } else if xml == "" { + break; + } else { + count.fetch_add(1, Ordering::SeqCst); + stream.write_all(xml.as_bytes()).await.unwrap(); + } + } + }) + }) +} +async fn http(addr: std::net::SocketAddr, body: &str) -> String { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let req = format!( + "POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(req.as_bytes()).await.unwrap(); + let mut bytes = vec![]; + stream.read_to_end(&mut bytes).await.unwrap(); + String::from_utf8(bytes) + .unwrap() + .split_once("\r\n\r\n") + .unwrap() + .1 + .into() +} +#[tokio::test] +async fn bosh_session_retries_restart_termination() { + let count = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = bosh_router(handler(count.clone())); + let task = tokio::spawn(serve_router(listener, None, app)); + let initial=http(addr,"").await; + let doc = roxmltree::Document::parse(&initial).unwrap(); + let sid = doc.root_element().attribute("sid").unwrap().to_string(); + assert!(initial.contains("mechanisms")); + let msg = format!( + "hello" + ); + let response = http(addr, &msg).await; + assert!(response.contains("hello")); + let replay = http(addr, &msg).await; + assert_eq!(replay, response); + assert_eq!(count.load(Ordering::SeqCst), 1); + let restart=http(addr,&format!("")).await; + assert!(restart.contains("mechanisms")); + let end=http(addr,&format!("")).await; + assert!(end.contains("type='terminate'")); + task.abort(); +} +async fn ws_send(s: &mut TcpStream, text: &str) { + let bytes = text.as_bytes(); + let mut frame = vec![0x81]; + if bytes.len() < 126 { + frame.push(0x80 | bytes.len() as u8) + } else { + frame.push(0x80 | 126); + frame.extend_from_slice(&(bytes.len() as u16).to_be_bytes()); + } + frame.extend_from_slice(&[1, 2, 3, 4]); + frame.extend( + bytes + .iter() + .enumerate() + .map(|(i, b)| b ^ [1, 2, 3, 4][i % 4]), + ); + s.write_all(&frame).await.unwrap(); +} +async fn ws_read(s: &mut TcpStream) -> String { + let mut h = [0; 2]; + s.read_exact(&mut h).await.unwrap(); + assert_eq!(h[0], 0x81); + let n = if h[1] == 126 { + s.read_u16().await.unwrap() as usize + } else { + h[1] as usize + }; + let mut b = vec![0; n]; + s.read_exact(&mut b).await.unwrap(); + String::from_utf8(b).unwrap() +} +#[tokio::test] +async fn websocket_frames_are_independent_xml() { + let count = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = websocket_router(handler(count.clone())); + let task = tokio::spawn(serve_router(listener, None, app)); + let mut s = TcpStream::connect(addr).await.unwrap(); + s.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap(); + let mut header = vec![]; + loop { + header.push(s.read_u8().await.unwrap()); + if header.ends_with(b"\r\n\r\n") { + break; + } + } + assert!( + String::from_utf8(header) + .unwrap() + .starts_with("HTTP/1.1 101") + ); + ws_send( + &mut s, + "", + ) + .await; + let open = ws_read(&mut s).await; + assert!(open.contains("hi", + ) + .await; + let message = ws_read(&mut s).await; + assert!(message.contains("hi")); + assert_eq!(count.load(Ordering::SeqCst), 1); + drop(s); + task.abort(); +} +#[tokio::test] +async fn configured_origin_is_enforced() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = rust_xmpp::http_transport::with_origins( + bosh_router(handler(Arc::new(AtomicUsize::new(0)))), + vec!["https://allowed.test".into()], + ); + let task = tokio::spawn(serve_router(listener, None, router)); + for (origin, status) in [ + ("https://evil.test", "403"), + ("https://allowed.test", "200"), + ] { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let request = format!( + "OPTIONS /http-bind HTTP/1.1\r\nHost: example.test\r\nOrigin: {origin}\r\nConnection: close\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut result = String::new(); + stream.read_to_string(&mut result).await.unwrap(); + assert!( + result.starts_with(&format!("HTTP/1.1 {status}")), + "{result}" + ); + if status == "200" { + assert!(result.contains("access-control-allow-origin: https://allowed.test")); + } + } + task.abort(); +} +#[tokio::test] +async fn bosh_reorders_two_requests_without_replaying_stanzas() { + let count = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(serve_router( + listener, + None, + bosh_router(handler(count.clone())), + )); + let first = http( + addr, + "", + ) + .await; + let doc = roxmltree::Document::parse(&first).unwrap(); + let sid = doc.root_element().attribute("sid").unwrap(); + let newer = format!( + "three" + ); + let older = format!( + "two" + ); + let a = tokio::spawn(async move { http(addr, &newer).await }); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + let b = http(addr, &older).await; + let a = a.await.unwrap(); + assert!( + !a.contains("terminate") && !b.contains("terminate"), + "{a} {b}" + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + assert!(format!("{b}{a}").contains("three")); + task.abort(); +} +#[tokio::test] +async fn bosh_preserves_namespace_inherited_from_body() { + let count = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(serve_router( + listener, + None, + bosh_router(handler(count.clone())), + )); + let first = http( + addr, + "", + ) + .await; + let doc = roxmltree::Document::parse(&first).unwrap(); + let sid = doc.root_element().attribute("sid").unwrap(); + let reply=http(addr,&format!("namespaced")).await; + let parsed = roxmltree::Document::parse(&reply).unwrap(); + assert!( + parsed + .descendants() + .any(|n| n.tag_name().name() == "message" + && n.tag_name().namespace() == Some("jabber:client")), + "{reply}" + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + task.abort(); +} + +async fn raw_http(addr: std::net::SocketAddr, request: &str) -> String { + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut result = String::new(); + stream.read_to_string(&mut result).await.unwrap(); + result +} + +#[tokio::test] +async fn transport_paths_and_cors_preflight_match_prosody() { + use rust_xmpp::http_transport::with_origins; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let h = handler(Arc::new(AtomicUsize::new(0))); + let app = with_origins( + bosh_router(h.clone()).merge(websocket_router(h)), + vec!["https://wthome.ru".into()], + ); + let task = tokio::spawn(serve_router(listener, None, app)); + for path in [ + "/http-bind", + "/http-bind/", + "/xmpp-websocket", + "/xmpp-websocket/", + ] { + let result = raw_http(addr, &format!( + "OPTIONS {path} HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nAccess-Control-Request-Method: {}\r\nAccess-Control-Request-Headers: Content-Type\r\nConnection: close\r\n\r\n", + if path.starts_with("/http-bind") { "POST" } else { "GET" } + )).await; + assert!(result.starts_with("HTTP/1.1 200"), "{path}: {result}"); + assert!( + result.contains("access-control-allow-origin: https://wthome.ru"), + "{result}" + ); + assert!( + result + .to_ascii_lowercase() + .contains("access-control-allow-headers: content-type"), + "{result}" + ); + } + let body = + ""; + let result = raw_http(addr, &format!( + "POST /http-bind/ HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() + )).await; + assert!( + result.starts_with("HTTP/1.1 200") && result.contains("mechanisms"), + "{result}" + ); + task.abort(); +} + +#[tokio::test] +async fn bosh_retry_survives_aborted_http_request_without_replaying_stanzas() { + use tokio::sync::Notify; + let seen = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let count = Arc::new(AtomicUsize::new(0)); + let h: ConnectionHandler = { + let seen = seen.clone(); + let release = release.clone(); + let count = count.clone(); + Arc::new(move |mut stream| { + let seen = seen.clone(); + let release = release.clone(); + let count = count.clone(); + Box::pin(async move { + let mut f = Framer::default(); + while let Ok(xml) = f.next(&mut stream).await { + if xml.starts_with("").await.unwrap(); + } else { + count.fetch_add(1, Ordering::SeqCst); + seen.notify_one(); + release.notified().await; + stream.write_all(xml.as_bytes()).await.unwrap(); + } + } + }) + }) + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(serve_router(listener, None, bosh_router(h))); + let initial = http( + addr, + "", + ) + .await; + let doc = roxmltree::Document::parse(&initial).unwrap(); + let sid = doc.root_element().attribute("sid").unwrap(); + let body = format!( + "once" + ); + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(format!("POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), seen.notified()) + .await + .unwrap(); + socket2::SockRef::from(&stream) + .set_linger(Some(std::time::Duration::ZERO)) + .unwrap(); + drop(stream); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let retry_body = body.clone(); + let retry = tokio::spawn(async move { http(addr, &retry_body).await }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + release.notify_one(); + let result = tokio::time::timeout(std::time::Duration::from_secs(2), retry) + .await + .expect("retry must receive the original response even after HTTP disconnect") + .unwrap(); + assert!( + result.contains("once") && !result.contains("terminate"), + "{result}" + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert_eq!(http(addr, &body).await, result); + task.abort(); +} + +#[tokio::test] +async fn bosh_termination_waits_for_graceful_cleanup_before_reconnect() { + let cleaned = Arc::new(AtomicUsize::new(0)); + let h: ConnectionHandler = { + let cleaned = cleaned.clone(); + Arc::new(move |mut stream| { + let cleaned = cleaned.clone(); + Box::pin(async move { + let mut framer = Framer::default(); + while let Ok(xml) = framer.next(&mut stream).await { + if xml.starts_with("").await.unwrap(); + } else if xml == "" { + stream.write_all(b"").await.unwrap(); + // The stream response can arrive before backend cleanup ends. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + cleaned.fetch_add(1, Ordering::SeqCst); + break; + } + } + }) + }) + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(serve_router(listener, None, bosh_router(h))); + let initial = http( + addr, + "", + ) + .await; + let doc = roxmltree::Document::parse(&initial).unwrap(); + let sid = doc.root_element().attribute("sid").unwrap(); + let end = http(addr, &format!("")).await; + assert!(end.contains("type='terminate'"), "{end}"); + assert_eq!( + cleaned.load(Ordering::SeqCst), + 1, + "termination must finish graceful backend cleanup before acknowledging the client" + ); + let reconnected = http( + addr, + "", + ) + .await; + assert!(reconnected.contains("sid="), "{reconnected}"); + task.abort(); +} + +async fn websocket_cleanup(explicit_close: bool) { + let cleaned = Arc::new(tokio::sync::Notify::new()); + let outcome = Arc::new(AtomicUsize::new(0)); + let h: ConnectionHandler = { + let cleaned = cleaned.clone(); + let outcome = outcome.clone(); + Arc::new(move |mut stream| { + let cleaned = cleaned.clone(); + let outcome = outcome.clone(); + Box::pin(async move { + let mut graceful = false; + let mut f = Framer::default(); + while let Ok(xml) = f.next(&mut stream).await { + if xml.starts_with("").await.unwrap(); + } else if xml == "" { + graceful = true; + break; + } + } + // Simulate cleanup that yields after receiving close or EOF. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + outcome.store(if graceful { 1 } else { 2 }, Ordering::SeqCst); + cleaned.notify_one(); + }) + }) + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(serve_router(listener, None, websocket_router(h))); + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + header.push(stream.read_u8().await.unwrap()); + } + ws_send( + &mut stream, + "", + ) + .await; + ws_read(&mut stream).await; + ws_read(&mut stream).await; + if explicit_close { + ws_send( + &mut stream, + "", + ) + .await; + // Follow the XMPP close immediately with a masked WebSocket close frame. + stream.write_all(&[0x88, 0x80, 0, 0, 0, 0]).await.unwrap(); + } + drop(stream); + tokio::time::timeout(std::time::Duration::from_secs(2), cleaned.notified()) + .await + .expect("transport must let the engine finish cleanup"); + assert_eq!( + outcome.load(Ordering::SeqCst), + if explicit_close { 1 } else { 2 } + ); + task.abort(); +} + +#[tokio::test] +async fn websocket_close_allows_graceful_engine_cleanup() { + websocket_cleanup(true).await; +} + +#[tokio::test] +async fn websocket_disconnect_delivers_eof_without_graceful_close() { + websocket_cleanup(false).await; +} diff --git a/tests/http_upload.rs b/tests/http_upload.rs new file mode 100644 index 0000000..24c2b99 --- /dev/null +++ b/tests/http_upload.rs @@ -0,0 +1,306 @@ +use rust_xmpp::http_service::{UploadConfig, UploadService}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; +async fn request(addr: std::net::SocketAddr, text: &str) -> String { + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(text.as_bytes()).await.unwrap(); + let mut out = vec![]; + stream.read_to_end(&mut out).await.unwrap(); + String::from_utf8_lossy(&out).to_string() +} +#[tokio::test] +async fn upload_authorization_streaming_ranges_and_restart() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let cfg = UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: format!("http://{addr}/file_share"), + size_limit: 10 * 1024 * 1024 * 1024, + daily_quota: 100, + global_quota: 100, + expires_after: None, + secret: Some("test-secret".into()), + allowed_types: vec![], + }; + let service = UploadService::new(cfg.clone()).unwrap(); + assert!( + service + .request_slot("a@example.test", "../secret", 5, "text/plain") + .is_err() + ); + assert!( + service + .request_slot("a@example.test", "foo\\bar", 5, "text/plain") + .is_err() + ); + let slot = service + .request_slot("a@example.test", "hello.txt", 5, "text/plain") + .unwrap(); + let path = slot + .url + .strip_prefix(&format!("http://{addr}")) + .unwrap() + .to_string(); + let serving = service.clone(); + let task = tokio::spawn(async move { serving.serve(listener, None).await.unwrap() }); + let unauthorized=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")).await; + assert!(unauthorized.starts_with("HTTP/1.1 403"), "{unauthorized}"); + let oversized=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 6\r\nConnection: close\r\n\r\nhello!",slot.authorization)).await; + assert!(oversized.starts_with("HTTP/1.1 413")); + let oversized_chunked=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n6\r\nhello!\r\n0\r\n\r\n",slot.authorization)).await; + assert!(oversized_chunked.starts_with("HTTP/1.1 413")); + let short_chunked=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n4\r\nhell\r\n0\r\n\r\n",slot.authorization)).await; + assert!(short_chunked.starts_with("HTTP/1.1 400")); + let authorization = slot.authorization.replacen("Bearer ", "bEaReR ", 1); + let uploaded=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n",authorization)).await; + assert!(uploaded.starts_with("HTTP/1.1 201"), "{uploaded}"); + let again=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",slot.authorization)).await; + assert!(again.starts_with("HTTP/1.1 409")); + let get = request( + addr, + &format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ) + .await; + assert!( + get.starts_with("HTTP/1.1 200") && get.ends_with("hello"), + "{get}" + ); + let range = request( + addr, + &format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=1-3\r\nConnection: close\r\n\r\n" + ), + ) + .await; + assert!(range.starts_with("HTTP/1.1 206") && range.ends_with("ell")); + let beyond_eof = request( + addr, + &format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=1-999\r\nConnection: close\r\n\r\n" + ), + ) + .await; + assert!( + beyond_eof.starts_with("HTTP/1.1 206") && beyond_eof.ends_with("ello"), + "{beyond_eof}" + ); + assert!(beyond_eof.contains("content-range: bytes 1-4/5")); + let unsatisfiable = request( + addr, + &format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=5-999\r\nConnection: close\r\n\r\n" + ), + ) + .await; + assert!(unsatisfiable.starts_with("HTTP/1.1 416")); + let traverse = request( + addr, + &format!("GET {path}/../../secret HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ) + .await; + assert!(traverse.starts_with("HTTP/1.1 404")); + assert!( + service + .request_slot("a@example.test", "more", 96, "text/plain") + .is_err() + ); + task.abort(); + let _ = task.await; + let restarted = UploadService::new(cfg).unwrap(); + let l = TcpListener::bind(addr).await.unwrap(); + let task = tokio::spawn(async move { restarted.serve(l, None).await.unwrap() }); + let get = request( + addr, + &format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ) + .await; + assert!(get.ends_with("hello")); + task.abort(); +} +#[test] +fn expired_upload_releases_quota() { + let temp = tempfile::tempdir().unwrap(); + let cfg = UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: "https://example.test/file_share".into(), + size_limit: 10, + daily_quota: 10, + global_quota: 10, + expires_after: Some(0), + secret: None, + allowed_types: vec!["image/*".into()], + }; + let s = UploadService::new(cfg).unwrap(); + assert!( + s.request_slot("a@example.test", "x", 10, "text/plain") + .is_err() + ); + s.request_slot("a@example.test", "x", 10, "image/png") + .unwrap(); + s.request_slot("a@example.test", "x", 10, "image/png") + .unwrap(); +} +#[tokio::test] +async fn persisted_prosody_archive_and_hs256_slot_survive_without_sidecar() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let config = UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: format!("http://{addr}/file_share"), + size_limit: 1024, + daily_quota: 1024, + global_quota: 1024, + expires_after: None, + secret: Some("shared-prosody-secret".into()), + allowed_types: vec![], + }; + let service = UploadService::new(config.clone()).unwrap(); + let slot = service + .request_slot("a@example.test", "legacy.txt", 4, "text/plain") + .unwrap(); + drop(service); + std::fs::remove_file( + temp.path() + .join(rust_xmpp::storage::encode_name("upload.example.test")) + .join("rust_uploads.json"), + ) + .unwrap(); + let service = UploadService::new(config).unwrap(); + let task = tokio::spawn(async move { service.serve(listener, None).await.unwrap() }); + let path = slot.url.strip_prefix(&format!("http://{addr}")).unwrap(); + let result=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata",slot.authorization)).await; + assert!(result.starts_with("HTTP/1.1 201"), "{result}"); + task.abort(); +} +#[tokio::test] +async fn https_endpoint_uses_real_tls() { + use std::io::BufReader; + use tokio_rustls::{ + TlsAcceptor, TlsConnector, + rustls::{ClientConfig, RootCertStore, ServerConfig, pki_types::ServerName}, + }; + let temp = tempfile::tempdir().unwrap(); + let cert = temp.path().join("cert.pem"); + let key = temp.path().join("key.pem"); + let output = std::process::Command::new("openssl") + .args([ + "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", + ]) + .arg(&key) + .arg("-out") + .arg(&cert) + .output() + .expect("openssl required for TLS integration test"); + assert!(output.status.success()); + let certs = rustls_pemfile::certs(&mut BufReader::new(std::fs::File::open(cert).unwrap())) + .collect::, _>>() + .unwrap(); + let key = rustls_pemfile::private_key(&mut BufReader::new(std::fs::File::open(key).unwrap())) + .unwrap() + .unwrap(); + let server = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs.clone(), key) + .unwrap(); + let mut roots = RootCertStore::empty(); + roots.add(certs[0].clone()).unwrap(); + let client = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let cfg = UploadConfig { + root: temp.path().into(), + host: "localhost".into(), + base_url: format!("https://localhost:{}/file_share", addr.port()), + size_limit: 1024, + daily_quota: 1024, + global_quota: 1024, + expires_after: None, + secret: None, + allowed_types: vec![], + }; + let s = UploadService::new(cfg).unwrap(); + let task = tokio::spawn(async move { + s.serve( + listener, + Some(TlsAcceptor::from(std::sync::Arc::new(server))), + ) + .await + .unwrap() + }); + let socket = TcpStream::connect(addr).await.unwrap(); + let mut tls = TlsConnector::from(std::sync::Arc::new(client)) + .connect(ServerName::try_from("localhost").unwrap(), socket) + .await + .unwrap(); + tls.write_all( + b"GET /file_share/missing/name HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let mut result = String::new(); + tls.read_to_string(&mut result).await.unwrap(); + assert!(result.starts_with("HTTP/1.1 404")); + task.abort(); +} +#[tokio::test] +async fn percent_escape_case_and_equivalent_url_spelling() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let config = UploadConfig { + root: temp.path().into(), + host: "upload.example.test".into(), + base_url: format!("http://{addr}/file_share"), + size_limit: 10, + daily_quota: 100, + global_quota: 100, + expires_after: None, + secret: None, + allowed_types: vec![], + }; + let service = UploadService::new(config).unwrap(); + let slot = service + .request_slot("a@example.test", "фото+1.txt", 4, "text/plain") + .unwrap(); + let path = slot + .url + .strip_prefix(&format!("http://{addr}")) + .unwrap() + .to_string(); + let task = tokio::spawn(async move { service.serve(listener, None).await.unwrap() }); + let authorization = slot.authorization.replacen("Bearer ", "bearer ", 1); + let put=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata",authorization)).await; + assert!(put.starts_with("HTTP/1.1 201")); + let alternate = path.to_lowercase().replace("%2b", "+"); + let get = request( + addr, + &format!("GET {alternate} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), + ) + .await; + assert!(get.ends_with("data"), "{get}"); + task.abort(); +} diff --git a/tests/iq_errors.rs b/tests/iq_errors.rs new file mode 100644 index 0000000..7adeda8 --- /dev/null +++ b/tests/iq_errors.rs @@ -0,0 +1,35 @@ +use rust_xmpp::protocol; + +#[test] +fn pep_errors_identify_the_account_and_echo_the_request() { + let doc = roxmltree::Document::parse("").unwrap(); + let reply = protocol::iq_error(doc.root_element(), "item-not-found"); + let parsed = roxmltree::Document::parse(&reply).unwrap(); + let iq = parsed.root_element(); + assert_eq!(iq.attribute("from"), Some("alice@example.test")); + assert_eq!(iq.attribute("id"), Some("bundle")); + assert!( + iq.first_element_child() + .unwrap() + .has_tag_name(("http://jabber.org/protocol/pubsub", "pubsub")) + ); + assert!( + iq.descendants() + .any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "item-not-found"))) + ); +} + +#[test] +fn server_errors_without_target_remain_unaddressed_and_escape_attributes() { + for target in ["", " to='alice&bob@example.test'"] { + let request = format!(""); + let doc = roxmltree::Document::parse(&request).unwrap(); + let reply = protocol::iq_error(doc.root_element(), "service-unavailable"); + let parsed = roxmltree::Document::parse(&reply).unwrap(); + assert_eq!( + parsed.root_element().attribute("from"), + doc.root_element().attribute("to") + ); + assert_eq!(parsed.root_element().attribute("id"), Some("a&b")); + } +} diff --git a/tests/logging.rs b/tests/logging.rs new file mode 100644 index 0000000..9f701c1 --- /dev/null +++ b/tests/logging.rs @@ -0,0 +1,263 @@ +use rust_xmpp::logging::{Level, Logger}; +use serde_json::json; + +#[test] +fn filters_appends_escapes_and_redacts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.log"); + std::fs::write(&path, "existing\n").unwrap(); + let logger = Logger::from_config(Some(&json!({"info":path.to_str().unwrap()}))).unwrap(); + logger.event(Level::Debug, "test", None, "hidden", &[]); + logger.event( + Level::Info, + "test\nmodule", + Some("c1"), + "connected", + &[ + ("password", "supersecret"), + ("access_token", "token-secret"), + ("raw_xml", "secret"), + ("peer", "a\n\r\u{1b}\"b"), + ], + ); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("existing\n")); + assert_eq!(text.lines().count(), 2); + assert!(!text.contains("hidden")); + assert!(!text.contains("supersecret")); + assert!(!text.contains("token-secret")); + assert!(!text.contains("")); + assert!(text.contains("[REDACTED]")); + assert!(text.contains("test\\nmodule")); + assert!(text.contains("\\u001b")); + assert!(text.contains("level=info")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +#[test] +fn json_records_and_bounded_ranges() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("debug.log"); + let config = json!([{"levels":{"min":"debug","max":"info"},"to":"file","filename":path}]); + let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap(); + logger.event(Level::Error, "net", None, "excluded", &[]); + logger.event( + Level::Debug, + "net", + Some("42"), + "accepted", + &[("client_proof", "secret"), ("peer", "localhost")], + ); + let text = std::fs::read_to_string(path).unwrap(); + let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + assert_eq!(record["level"], "debug"); + assert_eq!(record["connection"], "42"); + assert_eq!(record["fields"][0]["value"], "[REDACTED]"); + let timestamp = record["timestamp"].as_str().unwrap(); + assert!(timestamp.ends_with('Z')); + assert_eq!(timestamp.len(), 24); + chrono::DateTime::parse_from_rfc3339(timestamp).unwrap(); +} + +#[test] +fn cloned_loggers_serialize_records() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("threads.log"); + let logger = Logger::from_config(Some(&json!({"debug":path}))).unwrap(); + let handles: Vec<_> = (0..4) + .map(|_| { + let logger = logger.clone(); + std::thread::spawn(move || { + for _ in 0..40 { + logger.event( + Level::Info, + "worker", + None, + "complete", + &[("text", "x".repeat(4096).as_str())], + ); + } + }) + }) + .collect(); + for handle in handles { + handle.join().unwrap(); + } + let text = std::fs::read_to_string(path).unwrap(); + assert_eq!(text.lines().count(), 160); + assert!( + text.lines() + .all(|line| line.starts_with("timestamp=") && line.ends_with('"')) + ); +} + +#[test] +fn rejects_malformed_configuration() { + for config in [ + json!(42), + json!({"bogus":"*console"}), + json!({"info":42}), + json!("*unknown"), + json!([{"to":"file"}]), + json!([{"to":"console","levels":{"min":"error","max":"debug"}}]), + ] { + assert!( + Logger::from_config(Some(&config)).is_err(), + "accepted {config}" + ); + } + assert!(Logger::from_config(None).is_ok()); + assert!(Logger::from_config(Some(&json!("debug"))).is_ok()); + assert!(Logger::from_config(Some(&json!("*console"))).is_ok()); + assert!(Logger::from_config_with_format(None, Some("invalid")).is_err()); +} + +#[test] +fn explicit_sink_levels_select_exactly_and_deduplicate() { + let dir = tempfile::tempdir().unwrap(); + for (name, selection, expected) in [ + ("single", json!("warn"), vec!["warn"]), + ( + "set", + json!(["debug", "error", "debug"]), + vec!["debug", "error"], + ), + ( + "all", + serde_json::Value::Null, + vec!["debug", "info", "warn", "error"], + ), + ] { + let path = dir.path().join(name); + let mut config = json!({"to":"file", "filename":path}); + if !selection.is_null() { + config["levels"] = selection; + } + let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap(); + for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] { + logger.event(level, "test", None, "selection", &[]); + } + let text = std::fs::read_to_string(path).unwrap(); + let actual: Vec = text + .lines() + .map(|line| { + let record: serde_json::Value = serde_json::from_str(line).unwrap(); + record["level"].as_str().unwrap().to_owned() + }) + .collect(); + assert_eq!(actual, expected, "selection {name}"); + } +} + +#[test] +fn native_console_shorthands_are_accepted() { + assert!(Logger::from_config(Some(&json!("*console"))).is_ok()); + assert!(Logger::from_config(Some(&json!(["*console"]))).is_ok()); + assert!(Logger::from_config(Some(&json!(["*unknown"]))).is_err()); +} + +#[test] +fn overlapping_shorthand_destinations_receive_each_record_once() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shared.log"); + let config = json!({"debug":path,"info":path,"error":path}); + let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap(); + for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] { + logger.event(level, "test", None, "selection", &[]); + } + let text = std::fs::read_to_string(path).unwrap(); + let levels: Vec = text + .lines() + .map(|line| { + let record: serde_json::Value = serde_json::from_str(line).unwrap(); + record["level"].as_str().unwrap().to_owned() + }) + .collect(); + assert_eq!(levels, ["debug", "info", "warn", "error"]); +} + +#[test] +fn shared_sink_unions_levels_and_preserves_distinct_destinations() { + let dir = tempfile::tempdir().unwrap(); + let shared = dir.path().join("shared.log"); + let other = dir.path().join("other.log"); + let config = json!([ + {"to":"file","filename":shared,"levels":["debug","error"]}, + {"to":"file","filename":shared,"levels":["warn","error"]}, + {"to":"file","filename":other,"levels":{"min":"info","max":"error"}} + ]); + let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap(); + for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] { + logger.event(level, "test", None, "selection", &[]); + } + for (path, expected) in [ + (shared, vec!["debug", "warn", "error"]), + (other, vec!["info", "warn", "error"]), + ] { + let text = std::fs::read_to_string(path).unwrap(); + let levels: Vec = text + .lines() + .map(|line| { + let record: serde_json::Value = serde_json::from_str(line).unwrap(); + record["level"].as_str().unwrap().to_owned() + }) + .collect(); + assert_eq!(levels, expected); + } +} + +#[test] +fn reopen_rotated_files_updates_clones() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.log"); + let rotated = dir.path().join("events.log.1"); + let logger = Logger::from_config(Some(&json!({"info":path}))).unwrap(); + let clone = logger.clone(); + logger.event(Level::Info, "test", None, "before_rotation", &[]); + std::fs::rename(&path, &rotated).unwrap(); + logger.reopen().unwrap(); + clone.event(Level::Info, "test", None, "after_rotation", &[]); + let old = std::fs::read_to_string(rotated).unwrap(); + let new = std::fs::read_to_string(path).unwrap(); + assert!(old.contains("before_rotation")); + assert!(!old.contains("after_rotation")); + assert!(new.contains("after_rotation")); + assert!(!new.contains("before_rotation")); +} + +#[test] +fn failed_reopen_keeps_all_original_handles() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.log"); + let second = dir.path().join("second.log"); + let first_old = dir.path().join("first.log.1"); + let second_old = dir.path().join("second.log.1"); + let logger = Logger::from_config(Some(&json!([ + {"to":"file","filename":first}, + {"to":"file","filename":second} + ]))) + .unwrap(); + std::fs::rename(&first, &first_old).unwrap(); + std::fs::rename(&second, &second_old).unwrap(); + std::fs::create_dir(&second).unwrap(); + assert!(logger.reopen().is_err()); + logger.event(Level::Info, "test", None, "retained_handles", &[]); + assert!( + std::fs::read_to_string(first_old) + .unwrap() + .contains("retained_handles") + ); + assert!( + std::fs::read_to_string(second_old) + .unwrap() + .contains("retained_handles") + ); + assert!(std::fs::read_to_string(first).unwrap().is_empty()); +} diff --git a/tests/logging_network.py b/tests/logging_network.py new file mode 100644 index 0000000..4c4978e --- /dev/null +++ b/tests/logging_network.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Structured logging regression using isolated accounts and real STARTTLS. + +Usage: python3 tests/logging_network.py [BINARY] +Only a short PASS is printed: server output and credential-bearing wire data +remain in the temporary fixture and are never included in assertion messages. +""" +import base64 +from datetime import datetime, timezone +import hashlib +import hmac +import json +from pathlib import Path +import socket +import signal +import ssl +import subprocess +import sys +import tempfile +import time + +from _wire_client import BIN, Client +from android_sasl_network import Connection, SASL, b64 + +PASSWORD = 'logging-fixture-password-7d092c' +WRONG_PASSWORD = 'logging-wrong-password-8f14ad' +BODY = 'logging-private-message-body-326abc' + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +def records(path): + if not path.exists(): + return [] + result = [] + for line in path.read_text().splitlines(): + try: + result.append(json.loads(line)) + except json.JSONDecodeError: + raise AssertionError('log file contains a non-JSON record') from None + return result + + +def wait_for(path, predicate): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + found = records(path) + if predicate(found): + return found + time.sleep(.02) + raise AssertionError('required structured log event was not written') + + +def fields(record): + values = record['fields'] + require(isinstance(values, list), 'fields must be a list') + require(all(isinstance(f, dict) and set(f) == {'key', 'value'} for f in values), + 'invalid structured field shape') + return {f['key']: f['value'] for f in values} + + +def plain(port, cert, user, password, forbidden): + connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2) + try: + payload = b64(f'\0{user}\0{password}'.encode()) + forbidden.add(payload) + connection.send(f"{payload}") + require(connection.stanza().tag == '{' + SASL + '}failure', + 'invalid PLAIN login was accepted') + finally: + connection.sock.close() + + +def corrupt_account(port, cert, forbidden): + connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2) + try: + payload = b64(b'n,,n=broken,r=corrupt-account-log-check') + forbidden.add(payload) + connection.send(f"{payload}") + require(connection.stanza().tag == '{' + SASL + '}failure', + 'corrupt account record did not reject authentication') + finally: + connection.sock.close() + + +def scram(port, cert, forbidden, wrong_binding=False, disconnect=False): + connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2) + try: + gs2 = 'p=tls-server-end-point,,' + bare = 'n=alice,r=log-fixture-client-nonce' + initial = b64((gs2 + bare).encode()) + forbidden.add(initial) + connection.send(f"{initial}") + challenge = connection.stanza() + require(challenge.tag == '{' + SASL + '}challenge', 'SCRAM challenge missing') + if disconnect: + return + first = base64.b64decode(challenge.text).decode() + attrs = dict(item.split('=', 1) for item in first.split(',')) + binding = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).digest() + if wrong_binding: + binding = bytes([binding[0] ^ 1]) + binding[1:] + final = 'c=' + b64(gs2.encode() + binding) + ',r=' + attrs['r'] + salted = hashlib.pbkdf2_hmac('sha256', PASSWORD.encode(), + base64.b64decode(attrs['s']), int(attrs['i'])) + mac = lambda key, message: hmac.new(key, message, 'sha256').digest() + client_key = mac(salted, b'Client Key') + signature = mac(hashlib.sha256(client_key).digest(), + (bare + ',' + first + ',' + final).encode()) + proof = bytes(a ^ b for a, b in zip(client_key, signature)) + if not wrong_binding: + proof = bytes([proof[0] ^ 1]) + proof[1:] + encoded_proof = b64(proof) + response = final + ',p=' + encoded_proof + payload = b64(response.encode()) + forbidden.update((encoded_proof, response, payload)) + connection.send(f"{payload}") + require(connection.stanza().tag == '{' + SASL + '}failure', + 'invalid SCRAM response was accepted') + finally: + connection.sock.close() + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-logging-') as tmp: + root = Path(tmp) + cert = root / 'cert.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', + '-sha256', '-nodes', '-days', '1', '-keyout', str(root / 'key.pem'), + '-out', str(cert), '-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] + log_path = root / 'structured.jsonl' + config = root / 'config.json' + config.write_text(json.dumps({'global': { + 'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], + 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], + 'modules_enabled': ['tls', 'saslauth', 'ping'], + 'log_format': 'json', 'log': {'debug': str(log_path)}}, + 'hosts': {'localhost': {'options': { + 'authentication': 'internal_hashed', 'password_hash': 'SHA-256', + 'ssl': {'key': str(root / 'key.pem'), 'certificate': str(cert)}}}}})) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], + input=PASSWORD + '\n', text=True, check=True, capture_output=True) + account_dir = root / 'data' / 'localhost' / 'accounts' + require((account_dir / 'alice.dat').exists(), 'fixture account path missing') + (account_dir / 'broken.dat').write_text('return { malformed =\n') + forbidden = {PASSWORD, WRONG_PASSWORD, BODY, + b64(f'\0alice\0{PASSWORD}'.encode())} + start = datetime.now(timezone.utc) + with (root / 'process.log').open('w') as output: + process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], + stdout=output, stderr=output) + try: + for _ in range(100): + require(process.poll() is None, 'fixture server stopped') + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('fixture server did not listen') + # Wait for readiness-probe close before measuring individual sessions. + wait_for(log_path, lambda rows: any(r.get('event') == 'connection-closed' for r in rows)) + cases = [] + + def case(name, action, expected, rejected=False, reason=None): + before = {r.get('connection') for r in records(log_path) + if r.get('event') == 'connection-open'} + action() + rows = wait_for(log_path, lambda rs: any( + r.get('event') == 'connection-closed' and r.get('connection') not in before + for r in rs)) + opens = [r for r in rows if r.get('event') == 'connection-open' + and r.get('connection') not in before] + require(len(opens) == 1, name + ': expected one connection ID') + cid = opens[0]['connection'] + require(isinstance(cid, str) and bool(cid), name + ': missing connection ID') + session = [r for r in rows if r.get('connection') == cid] + events = [r['event'] for r in session] + cursor = 0 + for event in expected: + require(event in events[cursor:], name + ': missing or out-of-order ' + event) + cursor = events.index(event, cursor) + 1 + if rejected: + rejections = [r for r in session if r['event'] == 'sasl-rejected'] + require(bool(rejections), name + ': missing rejection') + reasons = [fields(r).get('reason') for r in rejections] + require(all(isinstance(r, str) and r for r in reasons), name + ': reason missing') + if reason: + lookup = [r for r in session if r['event'] == 'account-lookup'] + require(bool(lookup), name + ': missing account lookup diagnostic') + require(any(fields(r).get('reason') in reason for r in lookup), + name + ': expected account lookup reason missing') + require('auth-success' not in events and 'resource-bound' not in events, + name + ': rejected session authenticated') + cases.append(cid) + + def success(): + client = Client(port, cert, 'alice', PASSWORD, resource='logging-check') + try: + client.sync() + client.send(f"{BODY}") + client.sync() + finally: + client.close() + + common = ['connection-open', 'tls-established', 'sasl-features', 'sasl-attempt'] + case('PLAIN success', success, common + ['auth-success', 'resource-bound', 'connection-closed']) + case('PLAIN wrong password', lambda: plain(port, cert, 'alice', WRONG_PASSWORD, forbidden), + common + ['sasl-rejected', 'connection-closed'], rejected=True) + for wrong_binding in (False, True): + case('SCRAM invalid binding' if wrong_binding else 'SCRAM wrong proof', + lambda wrong_binding=wrong_binding: scram(port, cert, forbidden, wrong_binding), + common + ['sasl-challenge', 'sasl-rejected', 'connection-closed'], rejected=True) + case('disconnect during challenge', lambda: scram(port, cert, forbidden, disconnect=True), + common + ['sasl-challenge', 'connection-closed']) + case('unknown user', lambda: plain(port, cert, 'missing-user', WRONG_PASSWORD, forbidden), + common + ['sasl-rejected', 'connection-closed'], rejected=True) + case('corrupt account', lambda: corrupt_account(port, cert, forbidden), + common + ['sasl-rejected', 'connection-closed'], rejected=True, reason={'account-record-invalid', 'account-hash-or-record-invalid'}) + rotated = root / 'structured-rotated.jsonl' + log_path.rename(rotated) + process.send_signal(signal.SIGHUP) + wait_for(log_path, lambda rs: any(r.get('event') == 'logs-reopened' for r in rs)) + case('PLAIN success after SIGHUP rotation', success, + common + ['auth-success', 'resource-bound', 'connection-closed']) + require(len(set(cases)) == len(cases), 'connection IDs were reused') + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + end = datetime.now(timezone.utc) + rows = records(rotated) + records(log_path) + require(bool(rows), 'configured JSON log file is empty') + previous = start + for row in rows: + require(set(row) == {'timestamp', 'level', 'module', 'connection', 'event', 'fields'}, + 'invalid JSON log record shape') + require(row['level'] in ('debug', 'info', 'warn', 'error'), 'invalid log level') + require(isinstance(row['module'], str) and row['module'], 'missing module') + require(isinstance(row['event'], str) and row['event'], 'missing event') + fields(row) + try: + stamp = datetime.fromisoformat(row['timestamp'].replace('Z', '+00:00')) + except (TypeError, ValueError): + raise AssertionError('invalid log timestamp') from None + require(stamp.tzinfo is not None, 'log timestamp lacks timezone') + # Logger timestamps use millisecond precision. + require(start.timestamp() - .001 <= stamp.timestamp() <= end.timestamp(), + 'log timestamp is outside fixture lifetime') + require(stamp.timestamp() + .001 >= previous.timestamp(), 'log timestamps are out of order') + previous = stamp + raw = rotated.read_text() + log_path.read_text() + require(all(value not in raw for value in forbidden), 'sensitive wire content leaked into log') + print('PASS: JSON file logging, TLS/auth/bind correlation, rejection reasons, secret redaction and SIGHUP rotation') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else str(BIN)) diff --git a/tests/message_routing.rs b/tests/message_routing.rs new file mode 100644 index 0000000..f907be7 --- /dev/null +++ b/tests/message_routing.rs @@ -0,0 +1,76 @@ +use rust_xmpp::sessions::Hub; +use std::time::Duration; +use tokio::sync::mpsc; + +#[test] +fn unknown_resource_falls_back_to_available_bare_jid() { + let hub = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let s = hub + .bind("bob@example.test/phone", "c", tx, Duration::from_secs(30)) + .unwrap(); + s.lock().unwrap().available = true; + let targets = hub.message_targets("bob@example.test/old-resource"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].lock().unwrap().jid, "bob@example.test/phone"); +} + +#[test] +fn headline_uses_all_nonnegative_available_resources_and_errors_never_fallback() { + let hub = Hub::default(); + let mut receivers = vec![]; + for (resource, available, priority) in [ + ("high", true, 5), + ("low", true, 1), + ("negative", true, -1), + ("unavailable", false, 10), + ] { + let (tx, rx) = mpsc::channel(8); + receivers.push(rx); + let s = hub + .bind( + &format!("bob@example.test/{resource}"), + resource, + tx, + Duration::from_secs(30), + ) + .unwrap(); + let mut state = s.lock().unwrap(); + state.available = available; + state.priority = priority; + } + let names = |to: &str, kind: &str| { + let mut names = hub + .message_targets_for_type(to, kind) + .iter() + .map(|s| s.lock().unwrap().jid.clone()) + .collect::>(); + names.sort(); + names + }; + assert_eq!( + names("bob@example.test", "normal"), + ["bob@example.test/high"] + ); + assert_eq!( + names("bob@example.test", "headline"), + ["bob@example.test/high", "bob@example.test/low"] + ); + assert_eq!( + names("bob@example.test/negative", "headline"), + ["bob@example.test/negative"] + ); + assert_eq!( + names("bob@example.test/absent", "chat"), + ["bob@example.test/high"] + ); + for kind in ["error", "groupchat"] { + assert!(names("bob@example.test", kind).is_empty()); + assert!(names("bob@example.test/absent", kind).is_empty()); + assert_eq!( + names("bob@example.test/low", kind), + ["bob@example.test/low"] + ); + } + assert!(names("bob@example.test/absent", "headline").is_empty()); +} diff --git a/tests/muc_access.rs b/tests/muc_access.rs new file mode 100644 index 0000000..1aabe9e --- /dev/null +++ b/tests/muc_access.rs @@ -0,0 +1,113 @@ +use rust_xmpp::{ + muc::Muc, + protocol::ModuleResult, + storage::{self, LuaValue as V}, +}; + +fn send(muc: &mut Muc, jid: &str, packet: &str) -> ModuleResult { + let doc = roxmltree::Document::parse(packet).unwrap(); + muc.handle(jid, doc.root_element()).unwrap().unwrap() +} + +fn join(muc: &mut Muc, jid: &str, nick: &str) -> ModuleResult { + send(muc, jid, &format!("")) +} + +fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult { + send( + muc, + actor, + &format!( + "" + ), + ) +} + +#[test] +fn moderators_cannot_kick_or_demote_other_moderators() { + let temp = tempfile::tempdir().unwrap(); + let mut muc = Muc::new(temp.path().into(), "rooms.test".into()); + join(&mut muc, "owner@test/r", "Owner"); + join(&mut muc, "bob@test/r", "Bob"); + join(&mut muc, "carol@test/r", "Carol"); + for nick in ["Bob", "Carol"] { + assert!( + role(&mut muc, "owner@test/r", nick, "moderator").replies[0].contains("type='result'") + ); + } + for denied_role in ["none", "visitor", "participant"] { + let result = role(&mut muc, "bob@test/r", "Carol", denied_role); + assert!(result.replies[0].contains("not-allowed")); + assert!(result.deliveries.is_empty()); + } + // An owner can still revoke an unaffiliated moderator's role. + let result = role(&mut muc, "owner@test/r", "Carol", "participant"); + assert!(result.replies[0].contains("type='result'")); + assert!( + result + .deliveries + .iter() + .any(|(jid, packet)| jid == "carol@test/r" && packet.contains("role='participant'")) + ); + assert!(role(&mut muc, "carol@test/r", "Bob", "none").replies[0].contains("forbidden")); +} + +#[test] +fn binary_occupant_id_salt_survives_saves_and_restarts() { + let temp = tempfile::tempdir().unwrap(); + let salt = V::String(vec![0xff, 0, 0x80, b's']); + let mut raw = storage::parse( + b"return {_jid='r@rooms.test'; _data={persistent=true}; ['owner@test']='owner'}", + ) + .unwrap(); + let mut data = raw.get("_data").unwrap().clone(); + data.set("occupant_id_salt", salt.clone()); + raw.set("_data", data); + storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap(); + let mut ids = Vec::new(); + for _ in 0..2 { + let mut muc = Muc::new(temp.path().into(), "rooms.test".into()); + let result = join(&mut muc, "owner@test/r", "Owner"); + let packet = result + .replies + .iter() + .find(|p| p.starts_with("", + ); + assert!(result.replies[0].contains("type='result'")); + 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()); + assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required")); +} diff --git a/tests/muc_domain_bans.rs b/tests/muc_domain_bans.rs new file mode 100644 index 0000000..29bd202 --- /dev/null +++ b/tests/muc_domain_bans.rs @@ -0,0 +1,87 @@ +use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage}; + +fn send(muc: &mut Muc, jid: &str, xml: &str) -> ModuleResult { + let doc = roxmltree::Document::parse(xml).unwrap(); + muc.handle(jid, doc.root_element()).unwrap().unwrap() +} + +#[test] +fn native_domain_bans_block_remote_users_but_allow_explicit_members() { + let temp = tempfile::tempdir().unwrap(); + // Prosody freezes room affiliations into the top-level room table. + 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()); + let denied = send( + &mut muc, + "outsider@remote.test/client", + "", + ); + assert!( + denied.replies[0].contains("forbidden"), + "{:?}", + denied.replies + ); + let admitted = send( + &mut muc, + "member@remote.test/client", + "", + ); + assert!(!admitted.replies.iter().any(|p| p.contains("type='error'"))); + assert!( + admitted + .replies + .iter() + .any(|p| p.contains("affiliation='member'")) + ); +} + +#[test] +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()); + send( + &mut muc, + "owner@test/r", + "", + ); + send( + &mut muc, + "remote@remote.test/r", + "", + ); + send( + &mut muc, + "other@allowed.test/r", + "", + ); + let banned = send( + &mut muc, + "owner@test/r", + "", + ); + assert!(banned.replies[0].contains("type='result'")); + assert!( + banned + .deliveries + .iter() + .any(|(jid, packet)| jid == "remote@remote.test/r" && packet.contains("code='301'")) + ); + let message = "hello"; + assert!(send(&mut muc, "remote@remote.test/r", message).replies[0].contains("not-acceptable")); + assert_eq!( + send(&mut muc, "other@allowed.test/r", message) + .deliveries + .len(), + 2 + ); + assert!( + send( + &mut muc, + "remote@remote.test/new", + "" + ) + .replies[0] + .contains("forbidden") + ); +} diff --git a/tests/muc_extensions.rs b/tests/muc_extensions.rs new file mode 100644 index 0000000..e737701 --- /dev/null +++ b/tests/muc_extensions.rs @@ -0,0 +1,387 @@ +use rust_xmpp::{muc::Muc, protocol::ModuleResult}; +use serde_json::json; +const ROOM: &str = "room@rooms.example.org"; +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 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_defaults(defaults); + send( + &mut m, + "alice@example.org/r", + &format!(""), + ); + (root, m) +} +#[test] +fn restricted_private_messages_allow_self_and_optional_moderators() { + let (_root, mut m) = setup(json!({"allow_pm":"none"})); + send( + &mut m, + "bob@example.org/r", + &format!(""), + ); + let denied = send( + &mut m, + "bob@example.org/r", + &format!("blocked"), + ); + assert!(denied.replies[0].contains("policy-violation")); + assert!(denied.deliveries.is_empty()); + let own = send( + &mut m, + "bob@example.org/r", + &format!("self"), + ); + assert_eq!(own.deliveries.len(), 1); + let config = send( + &mut m, + "alice@example.org/r", + &format!( + "1" + ), + ); + assert!(config.replies[0].contains("type='result'")); + let allowed = send( + &mut m, + "bob@example.org/r", + &format!("allowed"), + ); + assert_eq!(allowed.deliveries.len(), 1); +} +#[test] +fn broadcast_roles_hide_visitors_and_configuration_announces_transition() { + let (_root, mut m) = setup(json!({"moderated":true,"presence_broadcast":{"moderator":true}})); + let join = send( + &mut m, + "bob@example.org/r", + &format!(""), + ); + assert!(join.deliveries.is_empty()); + assert!( + join.replies + .iter() + .any(|p| p.contains("/Bob") && p.contains("110")) + ); + let update = send( + &mut m, + "alice@example.org/r", + &format!( + "moderatorvisitor" + ), + ); + assert!( + update + .deliveries + .iter() + .any(|(to, p)| to == "alice@example.org/r" + && p.contains("/Bob") + && !p.contains("unavailable")) + ); +} +#[test] +fn language_config_sections_discovery_and_native_affiliation_hats() { + let (root, mut m) = setup(json!({"persistent":true})); + let config = send( + &mut m, + "alice@example.org/r", + &format!( + "de" + ), + ); + assert!(config.replies[0].contains("type='result'")); + let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config"); + let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap(); + let hats=rust_xmpp::storage::parse(br#"return {['alice@example.org']={hats={['urn:staff']={active=true,title='Staff'},['urn:off']={active=false,title='Hidden'}}}}"#).unwrap(); + 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()); + let join = send( + &mut m, + "alice@example.org/r", + &format!( + "" + ), + ); + let presence = join.replies.iter().find(|p| p.contains("110")).unwrap(); + assert!(presence.contains("urn:staff")); + assert!(!presence.contains("urn:off")); + assert!(!presence.contains("fake")); + let disco = send( + &mut m, + "alice@example.org/r", + &format!( + "" + ), + ); + assert!(disco.replies[0].contains("urn:xmpp:hats:0")); + assert!(disco.replies[0].contains("de")); + let form = send( + &mut m, + "alice@example.org/r", + &format!( + "" + ), + ); + assert!(form.replies[0].contains("Room information")); + assert!(form.replies[0].contains("muc#roomconfig_lang")); +} +#[test] +fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() { + let (_root, mut m) = setup(json!({})); + send( + &mut m, + "bob@example.org/r", + &format!(""), + ); + for (kind, to) in [ + ("groupchat", ROOM.to_string()), + ("chat", format!("{ROOM}/Bob")), + ] { + let result = send( + &mut m, + "alice@example.org/r", + &format!( + "safe" + ), + ); + assert!(!result.deliveries.is_empty()); + for (_, p) in result.deliveries { + assert!(!p.contains("forged")); + assert!(p.contains("occupant-id")); + } + } +} +#[test] +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); + let join = send( + &mut m, + "alice@example.org/r", + &format!(""), + ); + assert!(join.replies.iter().all(|p| !p.contains("occupant-id"))); + let disco = send( + &mut m, + "alice@example.org/r", + &format!( + "" + ), + ); + assert!(!disco.replies[0].contains("occupant-id")); + assert!(!disco.replies[0].contains("vcard-temp")); + let vcard = send( + &mut m, + "alice@example.org/r", + &format!(""), + ); + assert!(vcard.replies[0].contains("feature-not-implemented")); +} +#[test] +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); + send( + &mut m, + "alice@example.org/r", + &format!(""), + ); + send( + &mut m, + "alice@example.org/r", + &format!("retained"), + ); + let query = send( + &mut m, + "outsider@example.org/r", + &format!(""), + ); + let text = query.replies.join(""); + assert!(text.contains("retained")); + let mut saw_presence = false; + rust_xmpp::archive::scan_store( + root.path(), + "rooms.example.org", + Some("room"), + "muc_log", + |entry| { + saw_presence |= entry.xml.contains("")); + assert!(!text.contains(" index=")); + send( + &mut m, + "alice@example.org/r", + &format!( + "" + ), + ); + let mut count = 0; + rust_xmpp::archive::scan_store( + root.path(), + "rooms.example.org", + Some("room"), + "muc_log", + |_| { + count += 1; + Ok(()) + }, + ) + .unwrap(); + assert_eq!(count, 0); +} +#[test] +fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames() { + let (root, _m) = setup( + json!({"persistent":true,"whois":"moderators","presence_broadcast":{"none":true,"visitor":true,"participant":true,"moderator":true},"description":"Room description"}), + ); + let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config"); + let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap(); + frozen.set("offline@example.org", "member".into()); + frozen.set("unnamed@example.org", "member".into()); + frozen.set( + "_affiliation_data", + rust_xmpp::storage::parse( + b"return {['offline@example.org']={reserved_nickname='Offline'}}", + ) + .unwrap(), + ); + std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap(); + let mut m = Muc::new(root.path().into(), "rooms.example.org".into()); + let guest = send( + &mut m, + "bob@example.org/r", + &format!(""), + ); + let offline = guest + .replies + .iter() + .find(|p| p.contains("/Offline")) + .unwrap(); + assert!(offline.contains("unavailable")); + assert!(!offline.contains("offline@example.org")); + assert!( + guest + .replies + .iter() + .all(|p| !p.contains("unnamed@example.org")) + ); + let owner = send( + &mut m, + "alice@example.org/r", + &format!(""), + ); + assert!( + owner + .replies + .iter() + .any(|p| p.contains("unnamed@example.org")) + ); + send( + &mut m, + "bob@example.org/second", + &format!(""), + ); + let disco = send( + &mut m, + "bob@example.org/r", + &format!( + "" + ), + ); + assert!(disco.replies[0].contains("muc_semianonymous")); + assert!(disco.replies[0].contains("2")); + assert!(disco.replies[0].contains("Room description")); +} +#[test] +fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() { + let (root, mut first) = setup(json!({"persistent":false})); + first.checkpoint_live().unwrap(); + drop(first); + let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into()); + recovered.prepare_recovery().unwrap(); + recovered.disconnect("unrelated@example.org/r"); + let restored = recovered + .restore_for_session("alice@example.org/r") + .unwrap(); + assert!( + restored + .replies + .iter() + .any(|p| p.contains("110") && p.contains("role='moderator'")) + ); + drop(recovered); + // No new checkpoint: an already consumed claim cannot resurrect stale role. + let mut again = Muc::new(root.path().into(), "rooms.example.org".into()); + 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()); +} +#[test] +fn large_optional_history_does_not_prevent_checkpointing_membership() { + let (_root, mut m) = setup(json!({})); + send( + &mut m, + "alice@example.org/r", + &format!( + "{}", + "x".repeat(20 * 1024) + ), + ); + m.checkpoint_live().unwrap(); +} +#[test] +fn explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved() { + let (root, mut first) = setup(json!({"persistent":false})); + send( + &mut first, + "bob@example.org/r", + &format!(""), + ); + first.checkpoint_live().unwrap(); + drop(first); + let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into()); + recovered.prepare_recovery().unwrap(); + let stolen = send( + &mut recovered, + "mallory@example.org/r", + &format!(""), + ); + assert!(stolen.replies.iter().any(|p| p.contains("conflict"))); + let explicit = send( + &mut recovered, + "alice@example.org/r", + &format!(""), + ); + 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.prepare_recovery().unwrap(); + assert!( + again + .restore_for_session("alice@example.org/r") + .unwrap() + .replies + .is_empty() + ); + assert!( + again + .restore_for_session("bob@example.org/r") + .unwrap() + .replies + .iter() + .any(|p| p.contains("/Bob") && p.contains("110")) + ); +} diff --git a/tests/muc_flow.rs b/tests/muc_flow.rs new file mode 100644 index 0000000..168df98 --- /dev/null +++ b/tests/muc_flow.rs @@ -0,0 +1,481 @@ +use rust_xmpp::{muc::Muc, protocol::ModuleResult}; +fn send(m: &mut Muc, j: &str, x: &str) -> ModuleResult { + let d = roxmltree::Document::parse(x).unwrap(); + m.handle(j, d.root_element()).unwrap().unwrap() +} +fn join(m: &mut Muc, j: &str, n: &str) -> ModuleResult { + send( + m, + j, + &format!( + "" + ), + ) +} +fn unlock(m: &mut Muc) { + let r = send( + m, + "alice@test/a", + "", + ); + assert!(r.replies[0].contains("type='result'")); +} +#[test] +fn join_lock_chat_leave() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + let a = join(&mut m, "alice@test/a", "Alice"); + assert!(a.replies[0].contains("201")); + assert!(join(&mut m, "bob@test/b", "Bob").replies[0].contains("item-not-found")); + unlock(&mut m); + let b = join(&mut m, "bob@test/b", "Bob"); + assert!(b.replies.iter().any(|x| x.contains("Alice"))); + assert!(!b.replies[0].contains("alice@test")); + let c = send( + &mut m, + "bob@test/b", + "Hello", + ); + assert_eq!(c.deliveries.len(), 2); + assert!(c.deliveries[0].1.contains("Hello")); + assert_eq!(m.disconnect("bob@test/b").deliveries.len(), 1); +} +#[test] +fn authorization_and_conflict() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + assert!(join(&mut m, "bob@test/b", "Alice").replies[0].contains("conflict")); + join(&mut m, "bob@test/b", "Bob"); + let r = send( + &mut m, + "bob@test/b", + "", + ); + assert!(r.replies[0].contains("forbidden")); + let r = send( + &mut m, + "alice@test/a", + "", + ); + assert!(r.replies[0].contains("conflict")); + let r = send( + &mut m, + "alice@test/a", + "", + ); + assert_eq!(r.deliveries.len(), 2); + assert!(join(&mut m, "bob@test/b", "Bob").replies[0].contains("forbidden")); +} +#[test] +fn persistence_roundtrip() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + let r = send( + &mut m, + "alice@test/a", + "1Комната", + ); + assert!(r.replies[0].contains("result")); + drop(m); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + let r = send( + &mut m, + "alice@test/a", + "", + ); + assert!(r.replies[0].contains("Комната")); + join(&mut m, "alice@test/a", "Alice"); +} +#[test] +fn archive_history_and_permissions() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"archiving":true,"persistent":true})); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + send( + &mut m, + "alice@test/a", + "Archived", + ); + let query = ""; + // Native public/open room archives are readable by unaffiliated users. + assert!(send(&mut m, "outsider@test/a", query).replies[0].contains("Archived")); + let r = send(&mut m, "alice@test/a", query); + assert!(r.replies[0].contains("Archived")); + assert!(r.replies.last().unwrap().contains("1", + ); + assert!(send(&mut m, "outsider@test/a", query).replies[0].contains("forbidden")); +} +#[test] +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_local_domain("test".into()); + assert!(join(&mut m, "external@else/a", "External").replies[0].contains("forbidden")); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let renamed = join(&mut m, "alice@test/a", "NewName"); + assert!(renamed.replies[0].contains("303")); + assert!(renamed.replies[0].contains("nick='NewName'")); + let inv = send( + &mut m, + "alice@test/a", + "Join us", + ); + assert_eq!(inv.deliveries[0].0, "bob@test"); + assert!(inv.deliveries[0].1.contains("Join us")); + let destroy = ""; + assert!(send(&mut m, "bob@test/a", destroy).replies[0].contains("forbidden")); + assert_eq!(send(&mut m, "alice@test/a", destroy).deliveries.len(), 1); + assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201")); +} +#[test] +fn preserves_unknown_binary_mixed_room_fields() { + use rust_xmpp::storage::{self, LuaValue as V}; + let t = tempfile::tempdir().unwrap(); + let mut raw = V::table(); + raw.set("_jid", "r@rooms.test".into()); + raw.set("alice@test", "owner".into()); + let mut data = V::table(); + data.set("persistent", V::Bool(true)); + data.set("salt", V::String(vec![0xff, 0x00, 0x80])); + let mut mixed = V::table(); + mixed.set("extension", "original".into()); + mixed.set_key(V::Integer(1), "first".into()); + data.set("mixed", mixed.clone()); + raw.set("_data", data); + storage::save(t.path(), "rooms.test", Some("r"), "config", &raw).unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + send( + &mut m, + "alice@test/a", + "New", + ); + let saved = storage::load(t.path(), "rooms.test", Some("r"), "config") + .unwrap() + .unwrap(); + let d = saved.get("_data").unwrap(); + assert_eq!(d.get("salt"), Some(&V::String(vec![0xff, 0x00, 0x80]))); + assert_eq!(d.get("mixed"), Some(&mixed)); + assert_eq!(d.get("name").unwrap().as_str(), Some("New")); +} +#[test] +fn multiple_resources_share_nick_and_private_delivery() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + join(&mut m, "bob@test/a", "Bob"); + let second = join(&mut m, "bob@test/b", "Bob"); + assert!(!second.replies.iter().any(|s| s.contains("type='error'"))); + let pm = send( + &mut m, + "alice@test/a", + "Private", + ); + assert_eq!(pm.deliveries.len(), 2); + assert_eq!(m.disconnect("bob@test/a").deliveries.len(), 0); + assert_eq!(m.disconnect("bob@test/b").deliveries.len(), 1); +} +#[test] +fn iq_response_requires_expected_sender() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + join(&mut m, "bob@test/b", "Bob"); + let request = send( + &mut m, + "alice@test/a", + "", + ); + let packet = roxmltree::Document::parse(&request.deliveries[0].1).unwrap(); + let id = packet.root_element().attribute("id").unwrap(); + let answer = format!(""); + assert!( + send(&mut m, "mallory@test/c", &answer) + .deliveries + .is_empty() + ); + let response = send(&mut m, "bob@test/b", &answer); + assert_eq!(response.deliveries[0].0, "alice@test/a"); + assert!(response.deliveries[0].1.contains("id='original'")); +} +#[test] +fn persistent_destroy_creates_prosody_tombstone() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"persistent":true})); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + send( + &mut m, + "alice@test/a", + "Closed", + ); + drop(m); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("gone")); + let saved = rust_xmpp::storage::load(t.path(), "rooms.test", Some("r"), "config") + .unwrap() + .unwrap(); + assert_eq!( + saved.get("_data").unwrap().get("destroyed"), + Some(&rust_xmpp::storage::LuaValue::Bool(true)) + ); +} +#[test] +fn reserved_nickname_blocks_other_users() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"persistent":true})); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let reg = "http://jabber.org/protocol/muc#registerReserved"; + assert!(send(&mut m, "alice@test/a", reg).replies[0].contains("type='result'")); + assert!(join(&mut m, "bob@test/a", "Reserved").replies[0].contains("conflict")); + drop(m); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + assert!(join(&mut m, "bob@test/a", "Reserved").replies[0].contains("conflict")); + assert!(!join(&mut m, "alice@test/a", "Reserved").replies[0].contains("type='error'")); +} +#[test] +fn occupant_id_is_stable_and_spoofed_ids_removed() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let first = send( + &mut m, + "alice@test/a", + "Hi", + ); + let d = roxmltree::Document::parse(&first.deliveries[0].1).unwrap(); + let ids = d + .descendants() + .filter(|n| n.has_tag_name(("urn:xmpp:occupant-id:0", "occupant-id"))) + .collect::>(); + assert_eq!(ids.len(), 1); + let id = ids[0].attribute("id").unwrap().to_owned(); + assert_ne!(id, "fake"); + let next = join(&mut m, "alice@test/b", "Alice"); + assert!(next.replies.iter().any(|p| p.contains(&id))); +} +#[test] +fn switching_to_members_only_removes_unaffiliated_users() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + join(&mut m, "bob@test/a", "Bob"); + let r = send( + &mut m, + "alice@test/a", + "1", + ); + assert!( + r.deliveries + .iter() + .any(|(j, p)| j == "bob@test/a" && p.contains("322")) + ); + assert!( + send( + &mut m, + "bob@test/a", + "Still here?" + ) + .replies[0] + .contains("not-acceptable") + ); +} +#[test] +fn room_vcard_owner_only_and_avatar_presence() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let set = "Test Roomimage/pngaGVsbG8="; + assert!(send(&mut m, "bob@test/a", set).replies[0].contains("forbidden")); + let result = send(&mut m, "alice@test/a", set); + assert!(result.replies[0].contains("result")); + assert!( + result + .deliveries + .iter() + .any(|(_, p)| p.contains("vcard-temp:x:update")) + ); + let get = send( + &mut m, + "bob@test/a", + "", + ); + assert!(get.replies[0].contains("Test Room")); + let joined = join(&mut m, "bob@test/a", "Bob"); + assert!( + joined + .replies + .iter() + .any(|p| p.contains("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")) + ); +} +#[test] +fn visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"moderated":true})); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + join(&mut m, "bob@test/a", "Bob"); + let message = "Hello"; + assert!(send(&mut m, "bob@test/a", message).replies[0].contains("forbidden")); + let request = "http://jabber.org/protocol/muc#requestmallory@test/a1moderator"; + let forwarded = send(&mut m, "bob@test/a", request); + assert_eq!(forwarded.deliveries.len(), 1); + assert_eq!(forwarded.deliveries[0].0, "alice@test/a"); + assert!(forwarded.deliveries[0].1.contains("bob@test/a")); + assert!(!forwarded.deliveries[0].1.contains("mallory")); + assert!(send(&mut m, "bob@test/a", message).replies[0].contains("forbidden")); + let grant = request.replace("mallory@test/a", "bob@test/a"); + let result = send(&mut m, "alice@test/a", &grant); + assert_eq!(result.deliveries.len(), 2); + assert!(result.deliveries[0].1.contains("role='participant'")); + assert_eq!(send(&mut m, "bob@test/a", message).deliveries.len(), 2); +} +#[test] +fn maintenance_expires_idle_archives_and_tombstones() { + use rust_xmpp::archive::{self, Entry}; + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"persistent":true,"archiving":true})); + m.set_archive_retention(Some(60.0)); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64(); + for (id, when) in [("old", now - 100.0), ("new", now - 10.0)] { + archive::append_new( + t.path(), + "rooms.test", + Some("r"), + "muc_log", + Entry { + id: id.into(), + when, + peer: "alice@test/a".into(), + xml: + "Stored" + .into(), + }, + ) + .unwrap(); + } + drop(m); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_archive_retention(Some(60.0)); + m.maintenance(now).unwrap(); + let entries = archive::load_store(t.path(), "rooms.test", Some("r"), "muc_log").unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "new"); + send( + &mut m, + "alice@test/a", + "", + ); + m.maintenance(now + 32.0 * 86400.0).unwrap(); + assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201")); +} +#[test] +fn restored_temporary_room_is_deleted_after_last_disconnect() { + use rust_xmpp::storage::{self, LuaValue as V}; + let t = tempfile::tempdir().unwrap(); + let raw = storage::parse( + b"return {_jid='r@rooms.test';_data={persistent=false};['alice@test']='owner'}", + ) + .unwrap(); + storage::save(t.path(), "rooms.test", Some("r"), "config", &raw).unwrap(); + storage::save(t.path(), "rooms.test", Some("r"), "state", &V::table()).unwrap(); + let mut index = V::table(); + index.set("r@rooms.test", V::Bool(true)); + storage::save(t.path(), "rooms.test", None, "persistent", &index).unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + join(&mut m, "alice@test/a", "Alice"); + m.disconnect("alice@test/a"); + assert!( + storage::load(t.path(), "rooms.test", Some("r"), "config") + .unwrap() + .is_none() + ); + assert!( + storage::load(t.path(), "rooms.test", Some("r"), "state") + .unwrap() + .is_none() + ); + assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201")); +} +#[test] +fn configured_lock_timeout_expires_without_owner_submission() { + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_component_options(serde_json::json!({"muc_room_lock_timeout":0}), false); + join(&mut m, "alice@test/a", "Alice"); + assert!(!join(&mut m, "bob@test/a", "Bob").replies[0].contains("item-not-found")); +} +#[test] +fn native_muc_archive_type_and_anonymity_are_preserved() { + use rust_xmpp::archive::{self, Entry}; + let t = tempfile::tempdir().unwrap(); + let mut m = Muc::new(t.path().into(), "rooms.test".into()); + m.set_defaults(serde_json::json!({"persistent":true,"archiving":true,"whois":"moderators"})); + join(&mut m, "alice@test/a", "Alice"); + unlock(&mut m); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64(); + archive::append_new(t.path(),"rooms.test",Some("r"),"muc_log",Entry{id:"native".into(),when:now,peer:"messageNative old message".into()}).unwrap(); + let joined = join(&mut m, "bob@test/b", "Bob"); + let historic = joined + .replies + .iter() + .find(|s| s.contains("Native old message")) + .unwrap(); + assert!(!historic.contains("secret@example")); + let query = "urn:xmpp:mam:2secret@example/r"; + let result = send(&mut m, "bob@test/b", query); + assert!( + result + .replies + .iter() + .any(|s| s.contains("Native old message")) + ); + assert!(!result.replies.iter().any(|s| s.contains("secret@example"))); + send( + &mut m, + "alice@test/a", + "New", + ); + assert!( + archive::load_store(t.path(), "rooms.test", Some("r"), "muc_log") + .unwrap() + .iter() + .all(|e| e.peer == "message String { + format!( + "{content}" + ) +} +fn join(attributes: &str) -> String { + format!( + "" + ) +} +fn populated() -> RoomHistory { + let mut history = RoomHistory::new(3); + for id in 1..=4 { + history.push( + &packet(id, &format!("message {id}")), + id as i64 * 10, + ); + } + history +} +fn ids(packets: &[String]) -> Vec { + packets + .iter() + .map(|xml| { + let doc = roxmltree::Document::parse(xml).unwrap(); + doc.root_element().attribute("id").unwrap().to_owned() + }) + .collect() +} +#[test] +fn ordinary_history_is_bounded_and_returns_oldest_first() { + let history = populated(); + assert_eq!(history.len(), 3); + assert_eq!( + ids(&history.replay("room@rooms.example", "bob@example/r", "", 40)), + ["2", "3", "4"] + ); +} +#[test] +fn explicit_count_and_zero_limit() { + let history = populated(); + assert_eq!( + ids(&history.replay("room", "bob@example/r", &join("maxstanzas='2'"), 40)), + ["3", "4"] + ); + for attrs in ["maxstanzas='0'", "maxchars='0'", "maxstanzas='-1'"] { + assert!( + history + .replay("room", "bob@example/r", &join(attrs), 40) + .is_empty() + ); + } +} +#[test] +fn default_count_applies_only_without_valid_explicit_constraints() { + let mut history = populated(); + history.set_default_messages(1); + assert_eq!( + ids(&history.replay("room", "bob/r", "", 40)), + ["4"] + ); + assert_eq!( + ids(&history.replay("room", "bob/r", &join("seconds='30'"), 40)), + ["2", "3", "4"] + ); + assert_eq!( + ids(&history.replay("room", "bob/r", &join("maxstanzas='bad'"), 40)), + ["4"] + ); +} +#[test] +fn since_and_seconds_use_stricter_cutoff_inclusive() { + let history = populated(); + let attrs = "since='1970-01-01T00:00:20Z' seconds='10'"; + assert_eq!( + ids(&history.replay("room", "bob/r", &join(attrs), 40)), + ["3", "4"] + ); + assert_eq!( + ids(&history.replay( + "room", + "bob/r", + &join("since='1970-01-01T00:00:30.001Z'"), + 40 + )), + ["4"] + ); +} +#[test] +fn replay_carries_room_delay_and_recipient() { + let history = populated(); + let replay = history.replay("room", "bob@example/r&device", &join("maxstanzas='1'"), 40); + let doc = roxmltree::Document::parse(&replay[0]).unwrap(); + let message = doc.root_element(); + assert_eq!(message.attribute("to"), Some("bob@example/r&device")); + assert_eq!(message.attribute("from"), Some("room@rooms.example/Alice")); + let delays: Vec<_> = message + .children() + .filter(|n| n.has_tag_name(("urn:xmpp:delay", "delay"))) + .collect(); + assert_eq!(delays.len(), 1); + assert_eq!(delays[0].attribute("from"), Some("room@rooms.example")); + assert_eq!(delays[0].attribute("stamp"), Some("1970-01-01T00:00:40Z")); +} +#[test] +fn maxchars_counts_transmitted_utf8_bytes_including_address_and_delay() { + let mut history = RoomHistory::new(3); + history.push(&packet(1, "é🦀"), 10); + let all = history.replay("room", "bob/r", "", 10); + let size = all[0].len(); + assert_eq!( + history + .replay("room", "bob/r", &join(&format!("maxchars='{size}'")), 10) + .len(), + 1 + ); + assert!( + history + .replay( + "room", + "bob/r", + &join(&format!("maxchars='{}'", size - 1)), + 10 + ) + .is_empty() + ); + assert!( + history + .replay( + "room", + "a-much-longer-recipient/r", + &join(&format!("maxchars='{size}'")), + 10 + ) + .is_empty() + ); +} +#[test] +fn subjects_and_hints_follow_native_history_eligibility() { + let mut history = RoomHistory::new(20); + for content in [ + "topic", + "x", + "x", + "", + ] { + history.push(&packet(0, content), 1); + } + assert!(history.is_empty()); + for content in [ + "", + "", + "", + "", + ] { + history.push(&packet(1, content), 1); + } + assert_eq!(history.len(), 4); +} +#[test] +fn capacity_changes_trim_and_zero_disables_history() { + let mut history = populated(); + history.set_capacity(1); + assert_eq!( + ids(&history.replay("room", "bob/r", "", 40)), + ["4"] + ); + history.set_capacity(0); + history.push(&packet(5, "ignored"), 50); + assert!(history.is_empty()); +} +#[test] +fn snapshot_restores_last_message_without_adding_another_delay() { + let history = populated(); + let (stanza, timestamp) = history.last_message().unwrap(); + let mut restored = RoomHistory::new(20); + restored.restore_last_message(stanza, timestamp); + let replay = restored.replay("room", "bob/r", "", 99); + assert_eq!(ids(&replay), ["4"]); + let doc = roxmltree::Document::parse(&replay[0]).unwrap(); + assert_eq!( + doc.root_element() + .children() + .filter(|n| n.has_tag_name(("urn:xmpp:delay", "delay"))) + .count(), + 1 + ); +} +#[test] +fn only_direct_history_children_are_parsed() { + let request = HistoryRequest::parse( + "", + 40, + ); + assert!(!request.has_constraints()); + let request = HistoryRequest::parse(&join("seconds='NaN' maxchars='inf' maxstanzas='2.9'"), 40); + assert_eq!(request.maxstanzas, Some(2)); + assert_eq!(request.maxchars, None); + assert_eq!(request.since, None); +} diff --git a/tests/muc_native.rs b/tests/muc_native.rs new file mode 100644 index 0000000..22ffd07 --- /dev/null +++ b/tests/muc_native.rs @@ -0,0 +1,97 @@ +//! Exact upstream MUC + muc_mam oracle, with no replacement storage/module API. +use rust_xmpp::{muc::Muc, protocol::ModuleResult}; +fn send(muc: &mut Muc, xml: &str) -> ModuleResult { + let doc = roxmltree::Document::parse(xml).unwrap(); + muc.handle("alice@example.org/r", doc.root_element()) + .unwrap() + .unwrap() +} +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn actual_prosody_muc_mam_reads_rust_and_writes_native_archive() { + let root = tempfile::tempdir().unwrap(); + let mut muc = Muc::new(root.path().into(), "rooms.example.org".into()); + muc.set_defaults(serde_json::json!({"persistent":true,"archiving":true,"whois":"moderators"})); + send( + &mut muc, + "", + ); + send( + &mut muc, + "", + ); + send( + &mut muc, + "Rust archived body", + ); + drop(muc); + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let script = format!( + "{bootstrap}\n{}", + r#" +config.set('rooms.example.org','component_module','muc'); +config.set('rooms.example.org','modules_enabled',{'muc_mam'}); +config.set('rooms.example.org','muc_log_expires_after','never'); +assert(require('prosody.core.hostmanager').activate('rooms.example.org')); +if not mm.get_module('rooms.example.org','muc_mam') then assert(mm.load('rooms.example.org','muc_mam')); end +local muc=assert(mm.get_module('rooms.example.org','muc')); +local room=assert(muc.get_room_from_jid('room@rooms.example.org')); +assert(room:get_affiliation('alice@example.org')=='owner'); +assert(room:get_persistent());assert(room._data.archiving); +local archive=assert(sm.open('rooms.example.org','muc_log','archive')); +local count=0; +for id,item,when,peer in assert(archive:find('room',{with='message", + ); + assert!( + result + .replies + .iter() + .any(|s| s.contains("Native archived body")), + "{:?}", + result.replies + ); + let disco = send( + &mut muc, + "", + ); + assert!(disco.replies[0].contains("Native room name")); +} diff --git a/tests/muc_recovery.rs b/tests/muc_recovery.rs new file mode 100644 index 0000000..29eef75 --- /dev/null +++ b/tests/muc_recovery.rs @@ -0,0 +1,211 @@ +use rust_xmpp::muc_recovery::{self as recovery, OccupantSnapshot, PendingOccupants}; + +fn occupant() -> OccupantSnapshot { + OccupantSnapshot { jid: "alice@test/phone".into(), nick: "Alice".into(), role: "participant".into(), presence_payload: "awayhi & byesecret".into() } +} + +#[test] +fn native_checkpoint_roundtrip_filters_sensitive_extensions() { + let dir = tempfile::tempdir().unwrap(); + recovery::save_room_checkpoint( + dir.path(), + "rooms.test", + "r@rooms.test", + &[occupant()], + Some(&"a".repeat(64)), + 100, + ) + .unwrap(); + let occupants = recovery::load_room(dir.path(), "rooms.test", "r@rooms.test").unwrap(); + assert_eq!(occupants.len(), 1); + assert_eq!(occupants[0].jid, "alice@test/phone"); + let wrapped = format!("{}", occupants[0].presence_payload); + let parsed = roxmltree::Document::parse(&wrapped).unwrap(); + let fields = parsed + .root_element() + .children() + .filter(|node| node.is_element()) + .map(|node| (node.tag_name().name(), node.text().unwrap_or(""))) + .collect::>(); + assert_eq!(fields.len(), 2); + assert_eq!(fields.get("show"), Some(&"away")); + assert_eq!(fields.get("status"), Some(&"hi & bye")); + assert!( + parsed + .descendants() + .filter(|node| node.is_element()) + .all(|node| matches!(node.tag_name().name(), "presence" | "show" | "status")) + ); + assert_eq!( + recovery::load_checkpoint_time(dir.path(), "rooms.test", "r@rooms.test").unwrap(), + Some(100) + ); + let raw = std::fs::read_to_string(rust_xmpp::storage::path( + dir.path(), + "rooms.test", + Some("r"), + "state", + )) + .unwrap(); + assert!(!raw.contains("secret")); + assert!(!raw.contains("password")); +} + +#[test] +fn recovery_claim_requires_exact_authenticated_resource_and_expires() { + let mut pending = PendingOccupants::default(); + pending + .insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 200) + .unwrap(); + assert!(pending.candidates("alice@test/other", 200).is_empty()); + assert!( + pending + .claim("r@rooms.test", "mallory@test/phone", "Alice", 200) + .is_none() + ); + assert_eq!(pending.candidates("alice@test/phone", 200).len(), 1); + assert_eq!(pending.expire(400), 1); + assert!( + pending + .claim("r@rooms.test", "alice@test/phone", "Alice", 400) + .is_none() + ); + pending + .insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 401) + .unwrap(); + assert!(pending.is_empty()); +} + +#[test] +fn claim_is_one_time_and_does_not_change_untrusted_role() { + let mut pending = PendingOccupants::default(); + pending + .insert("r@rooms.test".into(), vec![occupant()], 100) + .unwrap(); + assert!( + pending + .claim("r@rooms.test", "alice@test/phone", "Wrong", 101) + .is_none() + ); + assert!( + pending + .claim("r@rooms.test", "alice@test/phone", "Alice", 101) + .is_some() + ); + assert!( + pending + .claim("r@rooms.test", "alice@test/phone", "Alice", 101) + .is_none() + ); +} + +#[test] +fn native_last_message_roundtrip_preserves_namespaces_and_rejects_other_rooms() { + let dir = tempfile::tempdir().unwrap(); + let packet = "hello"; + recovery::set_last_message( + dir.path(), + "rooms.test", + "r@rooms.test", + Some((packet, 100)), + ) + .unwrap(); + let (saved, at) = recovery::load_last_message(dir.path(), "rooms.test", "r@rooms.test") + .unwrap() + .unwrap(); + assert_eq!(at, 100); + let doc = roxmltree::Document::parse(&saved).unwrap(); + assert_eq!( + doc.root_element().tag_name().namespace(), + Some("jabber:client") + ); + assert!( + doc.descendants() + .any(|n| n.is_element() && n.tag_name().namespace() == Some("urn:xmpp:delay")) + ); + assert!( + recovery::set_last_message( + dir.path(), + "rooms.test", + "other@rooms.test", + Some((packet, 100)) + ) + .is_err() + ); + recovery::set_last_message(dir.path(), "rooms.test", "r@rooms.test", None).unwrap(); + assert!( + recovery::load_last_message(dir.path(), "rooms.test", "r@rooms.test") + .unwrap() + .is_none() + ); +} + +#[test] +fn pending_snapshot_keeps_original_checkpoint_time() { + let mut pending = PendingOccupants::default(); + pending + .insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 200) + .unwrap(); + let (saved_at, occupants) = pending.room_snapshot("r@rooms.test", 300).unwrap(); + assert_eq!(saved_at, 100); + let mut restarted = PendingOccupants::default(); + restarted + .insert_checkpoint("r@rooms.test".into(), occupants, saved_at, 350) + .unwrap(); + assert!(restarted.room_snapshot("r@rooms.test", 400).is_none()); +} + +#[test] +fn oversized_status_never_prevents_membership_checkpoint() { + let dir = tempfile::tempdir().unwrap(); + for length in [4096, 256 * 1024] { + let mut original = occupant(); + original.presence_payload = format!("{}", "x".repeat(length)); + recovery::save_room_checkpoint( + dir.path(), + "rooms.test", + "r@rooms.test", + &[original.clone()], + None, + 100, + ) + .unwrap(); + let restored = recovery::load_room(dir.path(), "rooms.test", "r@rooms.test").unwrap(); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].jid, original.jid); + assert_eq!(restored[0].nick, original.nick); + assert_eq!(restored[0].role, original.role); + assert!(restored[0].presence_payload.is_empty()); + } +} + +#[test] +fn configuration_fingerprint_is_independent_of_lua_table_iteration_order() { + use rust_xmpp::storage::{self, LuaValue as V}; + let a = storage::parse( + b"return {a=1,_data={password='hidden',nested={x=true,y='value'}},owner='owner'}", + ) + .unwrap(); + let b = storage::parse( + b"return {owner='owner',_data={nested={y='value',x=true},password='hidden'},a=1}", + ) + .unwrap(); + let expected = rust_xmpp::muc_recovery::configuration_fingerprint(&a).unwrap(); + assert_eq!( + expected, + rust_xmpp::muc_recovery::configuration_fingerprint(&b).unwrap() + ); + for _ in 0..32 { + let value = storage::parse(storage::serialize(&a).unwrap().as_bytes()).unwrap(); + assert_eq!( + expected, + rust_xmpp::muc_recovery::configuration_fingerprint(&value).unwrap() + ); + } + let mut changed = b; + changed.set("owner", V::from("outcast")); + assert_ne!( + expected, + rust_xmpp::muc_recovery::configuration_fingerprint(&changed).unwrap() + ); +} diff --git a/tests/muc_restart_network.py b/tests/muc_restart_network.py new file mode 100644 index 0000000..cba8cc1 --- /dev/null +++ b/tests/muc_restart_network.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Live MUC membership recovery across SIGTERM over real loopback STARTTLS. + +Usage: python3 tests/muc_restart_network.py target/release/rust-xmpp +The sockets stay open until shutdown, so ordinary disconnect cleanup cannot +substitute for checkpointing the room's live occupants. +""" +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +from _wire_client import Client, local + +MUC = 'http://jabber.org/protocol/muc' +USER = MUC + '#user' +ROOMS = ('persistent@rooms.localhost', 'temporary@rooms.localhost') + + +def iq(client, identifier, payload, room, error=False): + client.send(f"{payload}") + return client.iq(identifier, error=error) + + +def presence(client, room, nick): + return client.take(lambda e: local(e) == 'presence' and e.get('from') == f'{room}/{nick}') + + +def occupant(packet, role, affiliation=None): + assert packet.get('type') is None, ET.tostring(packet) + item = packet.find(f'{{{USER}}}x/{{{USER}}}item') + assert item is not None and item.get('role') == role, ET.tostring(packet) + if affiliation is not None: + assert item.get('affiliation') == affiliation, ET.tostring(packet) + + +def join(client, room, nick): + client.send(f"awaybefore restart") + return presence(client, room, nick) + + +def not_joined(client, room): + # A ping fences the initial broadcast presence without timeout-based reads. + client.sync() + assert not any(local(e) == 'presence' and e.get('from', '').startswith(room + '/') + for e in client.queue), [ET.tostring(e) for e in client.queue] + client.send(f"must not route") + response = client.take(lambda e: local(e) == 'message' and e.get('id') == 'not-joined') + assert response.get('type') == 'error', ET.tostring(response) + assert response.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}not-acceptable') is not None, ET.tostring(response) + + +def start(binary, config, port, log): + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + for _ in range(100): + 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') + + +def stop(server): + server.terminate() + try: + code = server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + raise AssertionError('SIGTERM did not complete orderly shutdown') + assert code == 0, f'SIGTERM shutdown exited {code}' + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-muc-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] + config = root/'config.json' + config.write_text(json.dumps({'global': { + 'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [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': {}}, + }})) + for user in ('alice', 'bob', 'carol'): + 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: + try: + server = start(binary, config, port, log) + alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop') + bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop') + clients.extend((alice, bob)) + alice.available() + bob.available() + for index, room in enumerate(ROOMS): + occupant(join(alice, room, 'Alice'), 'moderator', 'owner') + fields = ("http://jabber.org/protocol/muc#roomconfig" + f"{int(index == 0)}") + iq(alice, f'configure-{index}', f"{fields}", room) + occupant(join(bob, room, 'Bob'), 'participant', 'none') + iq(alice, f'moderator-{index}', f"", room) + bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob' + and e.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'moderator') + alice.sync() + bob.sync() + stop(server) + server = None + for client in clients: + client.close() + clients.clear() + + server = start(binary, config, port, log) + other_resource = Client(port, root/'cert.pem', 'alice', 'secret', 'phone') + carol = Client(port, root/'cert.pem', 'carol', 'secret', 'desktop') + clients.extend((other_resource, carol)) + other_resource.available() + carol.available() + for room in ROOMS: + not_joined(other_resource, room) + not_joined(carol, room) + + bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop') + alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop') + clients.extend((bob, alice)) + bob.send('chatafter restart') + for room in ROOMS: + packet = presence(bob, room, 'Bob') + occupant(packet, 'moderator', 'none') + alice.send('awayrestored owner') + for room in ROOMS: + occupant(presence(alice, room, 'Alice'), 'moderator', 'owner') + packet = presence(bob, room, 'Alice') + occupant(packet, 'moderator', 'owner') + assert packet.find('{jabber:client}show').text == 'away', ET.tostring(packet) + assert packet.find('{jabber:client}status').text == 'before restart', ET.tostring(packet) + alice.send(f"chatupdated after recovery") + updated = presence(bob, room, 'Alice') + assert updated.find('{jabber:client}status').text == 'updated after recovery', ET.tostring(updated) + carol.send(f"") + denied = presence(carol, room, 'Alice') + assert denied.get('type') == 'error', ET.tostring(denied) + assert denied.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}conflict') is not None, ET.tostring(denied) + text = 'after recovery ' + room + alice.send(f"{text}") + assert bob.body(text).get('from') == room + '/Alice' + assert alice.body(text).get('from') == room + '/Alice' + # Explicit joins remain valid for another authenticated resource. + occupant(join(other_resource, room, 'Phone'), 'moderator', 'owner') + stop(server) + server = None + finally: + if server is not None and server.poll() is None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + 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: 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') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/muc_restart_state.rs b/tests/muc_restart_state.rs new file mode 100644 index 0000000..5c531d4 --- /dev/null +++ b/tests/muc_restart_state.rs @@ -0,0 +1,106 @@ +use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage}; +use serde_json::json; +const HOST: &str = "rooms.example.org"; +const ROOM: &str = "live@rooms.example.org"; +const ALICE: &str = "alice@example.org/laptop"; +fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult { + let doc = roxmltree::Document::parse(packet).unwrap(); + m.handle(jid, doc.root_element()).unwrap().unwrap() +} +fn initial(root: &std::path::Path) -> Muc { + let mut m = Muc::new(root.into(), HOST.into()); + m.set_defaults(json!({"persistent": true, "password":"private-pass"})); + send( + &mut m, + ALICE, + &format!( + "private-passsaved status" + ), + ); + m +} +#[test] +fn repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing() { + let root = tempfile::tempdir().unwrap(); + let mut m = initial(root.path()); + m.checkpoint_live().unwrap(); + let before = rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap(); + drop(m); + let mut m = Muc::new(root.path().into(), HOST.into()); + m.prepare_recovery().unwrap(); + assert!( + m.restore_for_session("alice@example.org/phone") + .unwrap() + .replies + .is_empty() + ); + assert!( + m.restore_for_session("eve@example.org/laptop") + .unwrap() + .replies + .is_empty() + ); + m.checkpoint_live().unwrap(); + assert_eq!( + rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap(), + before + ); + drop(m); + let mut m = Muc::new(root.path().into(), HOST.into()); + m.prepare_recovery().unwrap(); + let restored = m.restore_for_session(ALICE).unwrap(); + assert!( + restored + .replies + .iter() + .any(|p| p.contains("saved status") && p.contains("110")) + ); + assert!(restored.replies.iter().all(|p| !p.contains("private-pass"))); + assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty()); + // Simulate a later crash: no clean checkpoint. A consumed on-disk proof + // must not revive a prior moderator grant on another process start. + 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()); +} +#[test] +fn changed_configuration_invalidates_auto_recovery_without_disclosing_password() { + let root = tempfile::tempdir().unwrap(); + let mut m = initial(root.path()); + m.checkpoint_live().unwrap(); + drop(m); + storage::update(root.path(), HOST, Some("live"), "config", |v| { + let mut data = v.get("_data").cloned().unwrap(); + data.set("password", "new-private-password".into()); + v.set("_data", data); + Ok(()) + }) + .unwrap(); + let mut m = Muc::new(root.path().into(), HOST.into()); + m.prepare_recovery().unwrap(); + let result = m.restore_for_session(ALICE).unwrap(); + assert!(result.replies.is_empty() && result.deliveries.is_empty()); + let denied = send(&mut m, ALICE, &format!("")); + assert!(denied.replies.iter().any(|p| p.contains("not-authorized"))); + assert!( + denied + .replies + .iter() + .all(|p| !p.contains("new-private-password")) + ); +} + +#[test] +fn unrelated_disconnect_does_not_delete_dormant_temporary_room() { + let root = tempfile::tempdir().unwrap(); + let mut m = Muc::new(root.path().into(), HOST.into()); + send(&mut m, ALICE, &format!("")); + m.checkpoint_live().unwrap(); + drop(m); + let mut m = Muc::new(root.path().into(), HOST.into()); + m.prepare_recovery().unwrap(); + m.disconnect("unrelated@example.org/mobile"); + let restored = m.restore_for_session(ALICE).unwrap(); + assert!(restored.replies.iter().any(|p| p.contains("110"))); +} diff --git a/tests/muc_state_native.rs b/tests/muc_state_native.rs new file mode 100644 index 0000000..4c78aa8 --- /dev/null +++ b/tests/muc_state_native.rs @@ -0,0 +1,140 @@ +//! Native freeze(true)/restore differential coverage for MUC occupant checkpoints. +use rust_xmpp::{ + muc::Muc, + muc_recovery::{OccupantSnapshot, load_room, save_room}, +}; + +const DOMAIN: &str = "rooms.example.org"; +const ROOM: &str = "room@rooms.example.org"; + +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +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_defaults(serde_json::json!({"persistent":true,"whois":"moderators"})); + let join = roxmltree::Document::parse( + "Rust desktop", + ) + .unwrap(); + muc.handle("alice@example.org/desktop", join.root_element()) + .unwrap() + .unwrap(); + drop(muc); + + let snapshots = vec![ + OccupantSnapshot { + jid: "alice@example.org/desktop".into(), + nick: "Alice".into(), + role: "moderator".into(), + presence_payload: "awayRust desktop".into(), + }, + OccupantSnapshot { + jid: "alice@example.org/mobile".into(), + nick: "Alice".into(), + role: "moderator".into(), + presence_payload: "Rust mobile3".into(), + }, + OccupantSnapshot { + jid: "bob@example.org/native".into(), + nick: "Bob".into(), + role: "participant".into(), + presence_payload: "Rust Bob".into(), + }, + ]; + save_room(root.path(), DOMAIN, ROOM, &snapshots).unwrap(); + + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let script = format!( + "{bootstrap}\n{}", + r#" +config.set('rooms.example.org','component_module','muc'); +assert(require('prosody.core.hostmanager').activate('rooms.example.org')); +local muc=assert(mm.get_module('rooms.example.org','muc')); +local room=assert(muc.get_room_from_jid('room@rooms.example.org')); +assert(room:get_persistent()); +local alice=assert(room:get_occupant_by_nick('room@rooms.example.org/Alice')); +assert(alice.bare_jid=='alice@example.org' and alice.role=='moderator'); +local sessions=0; +for jid,presence in alice:each_session() do + sessions=sessions+1; + assert(presence.attr.from=='room@rooms.example.org/Alice'); + if jid=='alice@example.org/desktop' then + assert(presence:get_child_text('show')=='away'); + assert(presence:get_child_text('status')=='Rust desktop'); + elseif jid=='alice@example.org/mobile' then + assert(presence:get_child_text('status')=='Rust mobile'); + assert(presence:get_child_text('priority')=='3'); + else error('unexpected Alice session '..jid); end +end +assert(sessions==2,'native restore lost a resource'); +local bob=assert(room:get_occupant_by_nick('room@rooms.example.org/Bob')); +assert(bob.bare_jid=='bob@example.org' and bob.role=='participant'); +assert(bob:get_presence():get_child_text('status')=='Rust Bob'); + +-- Change the native occupant and serialize through upstream freeze(true). +bob.role='visitor'; +bob:set_session('bob@example.org/native',st.presence({xmlns='jabber:client'}) + :tag('show'):text('dnd'):up():tag('status'):text('Native Bob & ready'):up()); +room:save_occupant(bob); +local frozen,state=room:freeze(true); +assert(state['room@rooms.example.org/Alice'].role=='moderator'); +assert(state['room@rooms.example.org/Bob'].role=='visitor'); +assert(state['alice@example.org/desktop'].name=='presence'); +assert(state['alice@example.org/mobile'].name=='presence'); +assert(room:save(nil,true)); +print('NATIVE_MUC_STATE_ROUNDTRIP_OK'); +"# + ); + let script_path = root.path().join("native-muc-state.lua"); + std::fs::write(&script_path, script).unwrap(); + let output = std::process::Command::new( + std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()), + ) + .arg(&script_path) + .arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap()) + .arg(root.path()) + .arg("muc-state") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("NATIVE_MUC_STATE_ROUNDTRIP_OK")); + + let recovered = load_room(root.path(), DOMAIN, ROOM).unwrap(); + assert_eq!(recovered.len(), 3, "{recovered:?}"); + for original in &snapshots[..2] { + assert_eq!( + recovered.iter().find(|o| o.jid == original.jid), + Some(original), + "native freeze must preserve both Alice sessions" + ); + } + let bob = recovered + .iter() + .find(|o| o.jid == "bob@example.org/native") + .unwrap(); + assert_eq!(bob.nick, "Bob"); + assert_eq!(bob.role, "visitor"); + let wrapped = format!("{}", bob.presence_payload); + let payload = roxmltree::Document::parse(&wrapped).unwrap(); + let text = |name: &str| { + payload + .root_element() + .children() + .find(|n| n.has_tag_name(name)) + .and_then(|n| n.text()) + }; + assert_eq!(text("show"), Some("dnd")); + assert_eq!(text("status"), Some("Native Bob & ready")); +} diff --git a/tests/pep_client_interop.rs b/tests/pep_client_interop.rs new file mode 100644 index 0000000..0bfc90a --- /dev/null +++ b/tests/pep_client_interop.rs @@ -0,0 +1,485 @@ +//! Wire-level PEP requests used by Conversations/monocles. This exercises the +//! server handlers; it does not claim Android device interoperability validation. +use rust_xmpp::{ + auth, + protocol::ModuleResult, + pubsub, + storage::{self, LuaValue as V}, +}; +use std::path::Path; + +fn request(root: &Path, actor: &str, stanza: &str) -> ModuleResult { + let doc = roxmltree::Document::parse(stanza).unwrap(); + pubsub::handle(root, "example.test", actor, doc.root_element()) + .unwrap() + .unwrap() +} +fn account(root: &Path) { + auth::create(root, "example.test", "alice", "secret", "SHA-256").unwrap(); +} +fn publish(root: &Path, node: &str, id: &str, payload: &str, fields: &str) -> ModuleResult { + request( + root, + "alice@example.test/phone", + &format!( + "{payload}http://jabber.org/protocol/pubsub#publish-options{fields}" + ), + ) +} +fn assert_payload(stanza: &str, payload: &str) { + fn same(actual: roxmltree::Node<'_, '_>, expected: roxmltree::Node<'_, '_>) { + assert_eq!(actual.tag_name(), expected.tag_name()); + let attrs = |n: roxmltree::Node<'_, '_>| { + n.attributes() + .map(|a| { + ( + a.namespace().unwrap_or("").to_owned(), + a.name().to_owned(), + a.value().to_owned(), + ) + }) + .collect::>() + }; + assert_eq!(attrs(actual), attrs(expected)); + let actual: Vec<_> = actual + .children() + .filter(|n| n.is_element() || n.is_text()) + .collect(); + let expected: Vec<_> = expected + .children() + .filter(|n| n.is_element() || n.is_text()) + .collect(); + assert_eq!(actual.len(), expected.len()); + for (a, e) in actual.into_iter().zip(expected) { + if e.is_element() { + same(a, e); + } else { + assert_eq!(a.text(), e.text()); + } + } + } + let actual = roxmltree::Document::parse(stanza).unwrap(); + let expected = roxmltree::Document::parse(payload).unwrap(); + let e = expected.root_element(); + let a = actual + .descendants() + .find(|n| n.is_element() && n.tag_name() == e.tag_name()) + .unwrap(); + same(a, e); +} +const OPEN: &str = "open"; + +#[test] +fn missing_own_omemo_bundle_returns_recoverable_item_not_found() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + // Monocles latches pepBroken for other errors in this initial lookup. + for node in [ + "urn:xmpp:omemo:2:bundles", + "eu.siacs.conversations.axolotl.bundles:42", + ] { + let result = request( + d.path(), + "alice@example.test/phone", + &format!( + "" + ), + ); + let doc = roxmltree::Document::parse(&result.replies[0]).unwrap(); + assert_eq!(doc.root_element().attribute("type"), Some("error")); + assert!( + doc.descendants() + .any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "item-not-found"))) + ); + } +} + +#[test] +fn avatar_and_omemo_publish_options_support_readback_and_caps_notifications() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + let presence = roxmltree::Document::parse("").unwrap(); + let caps = pubsub::on_presence( + d.path(), + "example.test", + "alice@example.test/laptop", + presence.root_element(), + ) + .unwrap(); + let caps_doc = roxmltree::Document::parse(&caps.deliveries[0].1).unwrap(); + let id = caps_doc.root_element().attribute("id").unwrap(); + let answer = format!( + "" + ); + let answer = roxmltree::Document::parse(&answer).unwrap(); + pubsub::on_disco_result( + d.path(), + "example.test", + "alice@example.test/laptop", + answer.root_element(), + ) + .unwrap() + .unwrap(); + for (node, id, payload, notify) in [ + ( + "urn:xmpp:avatar:data", + "avatar-hash", + "aW1hZ2U=", + false, + ), + ( + "urn:xmpp:avatar:metadata", + "avatar-hash", + "", + true, + ), + ( + "eu.siacs.conversations.axolotl.devicelist", + "current", + "", + true, + ), + ( + "urn:xmpp:omemo:2:bundles", + "42", + "test-key", + false, + ), + ] { + let result = publish(d.path(), node, id, payload, OPEN); + assert!( + result.replies[0].contains("type='result'"), + "{:?}", + result.replies + ); + assert_eq!(result.deliveries.len(), usize::from(notify)); + if notify { + assert_eq!(result.deliveries[0].0, "alice@example.test/laptop"); + assert_payload(&result.deliveries[0].1, payload); + } + let read = request( + d.path(), + "bob@remote.test/phone", + &format!( + "" + ), + ); + assert_payload(&read.replies[0], payload); + } +} + +#[test] +fn publish_options_match_implicit_notification_defaults_on_old_nodes() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + let mut nodes = V::table(); + let mut node = V::table(); + node.set("name", "urn:test:existing".into()); + // Native and earlier Rust stores can omit default-valued config fields. + node.set("config", V::table()); + nodes.set("urn:test:existing", node); + storage::save(d.path(), "example.test", Some("alice"), "pep", &nodes).unwrap(); + let fields = [ + "deliver_notifications", + "deliver_payloads", + "notify_delete", + "notify_retract", + ] + .into_iter() + .map(|key| format!("true")) + .collect::(); + let result = publish( + d.path(), + "urn:test:existing", + "current", + "new", + &fields, + ); + assert!( + result.replies[0].contains("type='result'"), + "{:?}", + result.replies + ); + let result = publish( + d.path(), + "urn:test:existing", + "current", + "bad", + "false", + ); + assert!(result.replies[0].contains("precondition-not-met")); + let read = request( + d.path(), + "alice@example.test/phone", + "", + ); + assert!(read.replies[0].contains(">new")); + assert!(!read.replies[0].contains(">bad")); +} + +fn owner_config(root: &Path, actor: &str, node: &str, fields: &str) -> ModuleResult { + request( + root, + actor, + &format!( + "{fields}" + ), + ) +} + +#[test] +fn migrated_native_configuration_can_roundtrip_access_model_edit() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + let path = storage::path(d.path(), "example.test", Some("alice"), "pep"); + storage::atomic_write(&path, b"return { ['urn:test:migrated']={name='urn:test:migrated';config={access_model='presence';notification_type='headline';itemreply='none';payload_type='urn:test:data';type='urn:stale:alias';publish_model='publishers';max_items=1};subscribers={};affiliations={['alice@example.test']='owner'}}};").unwrap(); + let get = request( + d.path(), + "alice@example.test/phone", + "", + ); + assert!(!get.replies[0].contains("pubsub#payload_type")); + assert!(get.replies[0].contains("pubsub#type")); + let doc = roxmltree::Document::parse(&get.replies[0]).unwrap(); + assert_eq!( + doc.descendants() + .filter(|n| n.attribute("var") == Some("pubsub#type")) + .count(), + 1 + ); + let form = doc + .descendants() + .find(|n| n.has_tag_name(("jabber:x:data", "x"))) + .unwrap(); + let form = rust_xmpp::protocol::xml(form) + .replace("type='form'", "type='submit'") + .replace(">presence<", ">open<"); + let set = request( + d.path(), + "alice@example.test/phone", + &format!( + "{form}" + ), + ); + assert!( + set.replies[0].contains("type='result'"), + "{:?}", + set.replies + ); + let metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") + .unwrap() + .unwrap(); + let config = metadata + .get("urn:test:migrated") + .unwrap() + .get("config") + .unwrap(); + assert_eq!(config.get("access_model").and_then(V::as_str), Some("open")); + assert!(config.get("type").is_none()); + assert_eq!( + config.get("payload_type").and_then(V::as_str), + Some("urn:test:data") + ); + assert_eq!( + config.get("notification_type").and_then(V::as_str), + Some("headline") + ); + assert_eq!(config.get("itemreply").and_then(V::as_str), Some("none")); + for (key, value) in [ + ("notification_type", "chat"), + ("itemreply", "everyone"), + ("publish_model", "anyone"), + ] { + let bad = owner_config( + d.path(), + "alice@example.test/phone", + "urn:test:migrated", + &format!("{value}"), + ); + assert!( + bad.replies[0].contains("not-acceptable"), + "{:?}", + bad.replies + ); + } +} + +#[test] +fn native_itemreply_controls_publisher_visibility_in_reads_and_replays() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + for reply in ["none", "owner", "publisher"] { + let fields = format!("{OPEN}{reply}"); + let node = format!("urn:test:{reply}"); + assert!( + publish( + d.path(), + &node, + "current", + "value", + &fields + ) + .replies[0] + .contains("type='result'") + ); + let read = request( + d.path(), + "bob@remote.test/phone", + &format!( + "" + ), + ); + let replay = request( + d.path(), + "bob@remote.test/phone", + &format!( + "" + ), + ); + assert_eq!(replay.deliveries.len(), 1); + let event = publish( + d.path(), + &node, + "current", + "update", + &fields, + ); + assert_eq!(event.deliveries.len(), 1); + for stanza in [ + &read.replies[0], + &replay.deliveries[0].1, + &event.deliveries[0].1, + ] { + let doc = roxmltree::Document::parse(stanza).unwrap(); + let item = doc + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "item") + .unwrap(); + assert_eq!( + item.attribute("publisher"), + (reply == "publisher").then_some("alice@example.test") + ); + } + } +} + +#[test] +fn native_component_notification_type_applies_to_events_and_subscription_replay() { + let d = tempfile::tempdir().unwrap(); + let call = |actor: &str, xml: &str| { + let doc = roxmltree::Document::parse(xml).unwrap(); + pubsub::handle_component( + d.path(), + "pubsub.example.test", + actor, + &["alice@example.test".into()], + doc.root_element(), + ) + .unwrap() + .unwrap() + }; + for kind in ["normal", "headline"] { + let result = call( + "alice@example.test/phone", + &format!( + "{kind}on_sub" + ), + ); + assert!(result.replies[0].contains("type='result'")); + let publish = format!( + "" + ); + call("alice@example.test/phone", &publish); + let replay = call( + "bob@remote.test/phone", + &format!( + "" + ), + ); + let event = call("alice@example.test/phone", &publish); + for result in [replay, event] { + assert_eq!(result.deliveries.len(), 1); + let doc = roxmltree::Document::parse(&result.deliveries[0].1).unwrap(); + assert_eq!(doc.root_element().attribute("type"), Some(kind)); + } + } +} + +#[test] +fn native_open_and_subscriber_publish_models_preserve_outcast_and_blocking_checks() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + auth::create(d.path(), "example.test", "bob", "secret", "SHA-256").unwrap(); + for model in ["open", "subscribers"] { + let node = format!("urn:test:{model}"); + let fields = + format!("{OPEN}{model}"); + assert!( + publish( + d.path(), + &node, + "current", + "", + &fields + ) + .replies[0] + .contains("type='result'") + ); + let write = format!( + "" + ); + let initial = request(d.path(), "bob@example.test/phone", &write); + assert_eq!( + initial.replies[0].contains("type='result'"), + model == "open" + ); + let sub = format!( + "" + ); + request(d.path(), "bob@example.test/phone", &sub); + assert!( + request(d.path(), "bob@example.test/phone", &write).replies[0] + .contains("type='result'") + ); + let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") + .unwrap() + .unwrap(); + let mut entry = metadata.get(&node).unwrap().clone(); + let mut affiliations = entry.get("affiliations").unwrap().clone(); + affiliations.set("bob@example.test", "outcast".into()); + entry.set("affiliations", affiliations); + metadata.set(&node, entry); + storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap(); + assert!( + request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden") + ); + let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep") + .unwrap() + .unwrap(); + let mut entry = metadata.get(&node).unwrap().clone(); + entry.set("affiliations", V::table()); + metadata.set(&node, entry); + storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap(); + let mut blocked = V::table(); + blocked.set("bob@example.test", V::Bool(true)); + storage::save( + d.path(), + "example.test", + Some("alice"), + "blocklist", + &blocked, + ) + .unwrap(); + assert!( + request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden") + ); + storage::save( + d.path(), + "example.test", + Some("alice"), + "blocklist", + &V::table(), + ) + .unwrap(); + } +} diff --git a/tests/pep_client_network.py b/tests/pep_client_network.py new file mode 100644 index 0000000..c675d18 --- /dev/null +++ b/tests/pep_client_network.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Monocles/Conversations PEP request sequences over real loopback STARTTLS. + +Usage: python3 tests/pep_client_network.py target/debug/rust-xmpp +Payload shapes follow Monocles IqGenerator and AxolotlService: missing own +bundle must return item-not-found (other errors latch isPepBroken), publishes +omit `to`, and open-access preconditions recover through owner configuration. +Key bytes are opaque transport fixtures, not usable cryptographic bundles. +""" +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +from push_discovery import Client + +PUBSUB = 'http://jabber.org/protocol/pubsub' +OWNER = PUBSUB + '#owner' +ERROR = PUBSUB + '#errors' +DATA = 'jabber:x:data' +STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas' +LEGACY = 'eu.siacs.conversations.axolotl' +OMEMO = 'urn:xmpp:omemo:2' +BUNDLES = OMEMO + ':bundles' + + +def form(kind, fields): + values = {'FORM_TYPE': PUBSUB + '#' + kind, **fields} + return "" + ''.join( + f"{value}" + for key, value in values.items()) + '' + + +def result(response): + assert response.get('type') == 'result', ET.tostring(response) + return response + + +def condition(response, namespace, name): + assert response.get('type') == 'error', ET.tostring(response) + assert next(response.iter(f'{{{namespace}}}{name}'), None) is not None, ET.tostring(response) + + +def fetch(client, node, item=None, identifier='fetch', target='alice@localhost'): + selector = '' if item is None else f"" + return client.iq(identifier, f"{selector}", target) + + +def publish(client, node, item, payload, options=None, identifier='publish'): + options_xml = '' if options is None else '' + form('publish-options', options) + '' + # IqGenerator.publish deliberately does not set a destination. + return client.iq(identifier, f"{payload}{options_xml}", typ='set') + + +def check_items(client): + for device in ('123', '456'): + response = result(fetch(client, BUNDLES, device, 'bundle-' + device)) + items = list(response.iter(f'{{{PUBSUB}}}item')) + assert [item.get('id') for item in items] == [device], ET.tostring(response) + bundle = next(response.iter(f'{{{OMEMO}}}bundle')) + assert bundle.find(f'{{{OMEMO}}}spk').text == 'ZmFrZS1rZXk=' + device, ET.tostring(response) + for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')): + response = result(fetch(client, node)) + devices = next(response.iter(f'{{{namespace}}}{element}')) + assert {d.get('id') for d in devices} == {'123', '456'}, ET.tostring(response) + + +def sequence(client): + # AxolotlService's first own-bundle fetch must never become service-unavailable. + condition(fetch(client, BUNDLES, '123'), STANZAS, 'item-not-found') + for node in (LEGACY + '.bundles:123', 'urn:missing:pep'): + condition(fetch(client, node), STANZAS, 'item-not-found') + for device in ('123', '456'): + bundle = f"ZmFrZS1rZXk={device}c2lnaWs=cGs=" + # OMEMO2 shares a node between devices; retain both records explicitly. + options = {'pubsub#access_model': 'open', 'pubsub#max_items': 'max'} + result(publish(client, BUNDLES, device, bundle, options)) + for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')): + payload = f"<{element} xmlns='{namespace}'>" + # Existing/migrated presence node: Monocles retries after pushing config. + response = publish(client, node, 'current', payload, {'pubsub#access_model': 'open'}) + if response.get('type') == 'error': + condition(response, ERROR, 'precondition-not-met') + response = result(client.iq('get-config', f"")) + x = next(response.iter(f'{{{DATA}}}x')) + x.set('type', 'submit') + field = next(f for f in x if f.get('var') == 'pubsub#access_model') + field.find(f'{{{DATA}}}value').text = 'open' + result(client.iq('set-config', f"" + ET.tostring(x, encoding='unicode') + '', typ='set')) + result(publish(client, node, 'current', payload, {'pubsub#access_model': 'open'})) + check_items(client) + # A missing item on an existing bundle node must also be benign to clients. + condition(fetch(client, BUNDLES, '999'), STANZAS, 'item-not-found') + # A PEP routing fix must keep another user's node protected. + condition(client.iq('other-publish', f"", 'bob@localhost', 'set'), STANZAS, 'forbidden') + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-pep-clients-') 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) + for migrated in (False, True): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + data = root / ('migrated' if migrated else 'fresh') + config = root / 'config.json' + config.write_text(json.dumps({'global': {'data_path': str(data), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}})) + for user in ('alice', 'bob'): + subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL) + if migrated: + # Native-like metadata includes fields which previously failed whole-form resubmission. + store = data / 'localhost' / 'pep' / 'alice.dat' + store.parent.mkdir(parents=True, exist_ok=True) + store.write_text('return { ' + ', '.join(f'["{node}"] = {{ config = {{ access_model = "presence", max_items = 1, persist_items = true, notification_type = "normal", itemreply = "publisher", payload_type = "{LEGACY}" }} }}' for node in (LEGACY + '.devicelist', OMEMO + ':devices')) + ' }\n') + for restart in (False, True): + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + client = None + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + client = Client(port, root/'cert.pem') + features, identities = client.disco('own-pep', 'alice@localhost') + assert ('pubsub', 'pep') in identities and PUBSUB + '#publish-options' in features, (features, identities) + if restart: + check_items(client) + else: + sequence(client) + finally: + if client is not None: + client.sock.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + if sys.exc_info()[0] is not None: + log.seek(0) + print(log.read(), file=sys.stderr) + print('PASS: TLS Monocles/Conversations PEP first-run, no-to publishing, owner config recovery, migrated metadata, device item selectors and restart persistence') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/pep_diagnostics.rs b/tests/pep_diagnostics.rs new file mode 100644 index 0000000..a981a84 --- /dev/null +++ b/tests/pep_diagnostics.rs @@ -0,0 +1,98 @@ +use rust_xmpp::pep_diagnostics::{event_fields, item_fields}; +use std::collections::BTreeMap; + +fn item(xml: &str) -> BTreeMap<&'static str, String> { + let doc = roxmltree::Document::parse(xml).unwrap(); + item_fields(doc.root_element()).into_iter().collect() +} + +#[test] +fn nonempty_xml_can_be_an_empty_android_device_list() { + let fields = item( + "", + ); + assert_eq!(fields["device_protocol"], "legacy"); + assert_eq!(fields["valid_device_count"], "0"); + assert_eq!(fields["invalid_device_count"], "4"); + assert!(!format!("{fields:?}").contains("bad-secret-id")); +} + +#[test] +fn signed_integer_limits_and_duplicates_match_java_parser() { + let fields = item( + "", + ); + assert_eq!(fields["valid_device_count"], "4"); + assert_eq!(fields["invalid_device_count"], "4"); +} + +#[test] +fn payloadless_notification_is_visible_without_identifiers() { + let doc = roxmltree::Document::parse("").unwrap(); + let fields: BTreeMap<_, _> = event_fields(doc.root_element()) + .unwrap() + .into_iter() + .collect(); + assert_eq!(fields["node_kind"], "omemo-devices"); + assert_eq!(fields["items_count"], "1"); + assert_eq!(fields["content_present"], "false"); + assert_eq!(fields["valid_device_count"], "0"); + assert_eq!(fields["device_protocol"], "other"); + let rendered = format!("{fields:?}"); + for secret in ["private", "secret", "resource", "devicelist"] { + assert!(!rendered.contains(secret)); + } +} + +#[test] +fn only_first_direct_item_is_parsed_and_protocols_are_distinct() { + let doc = roxmltree::Document::parse("").unwrap(); + let fields: BTreeMap<_, _> = event_fields(doc.root_element()) + .unwrap() + .into_iter() + .collect(); + assert_eq!(fields["device_protocol"], "omemo2"); + assert_eq!(fields["items_count"], "2"); + assert_eq!(fields["content_present"], "true"); + assert_eq!(fields["valid_device_count"], "1"); +} + +#[test] +fn arbitrary_node_payload_and_wrong_namespace_do_not_leak() { + let doc = roxmltree::Document::parse("PRIVATE-KEY").unwrap(); + let fields: BTreeMap<_, _> = event_fields(doc.root_element()) + .unwrap() + .into_iter() + .collect(); + assert_eq!(fields["node_kind"], "other"); + assert_eq!(fields["device_protocol"], "other"); + assert_eq!(fields["content_present"], "true"); + assert!(!format!("{fields:?}").contains("PRIVATE")); + for xml in [ + "", + "", + "", + ] { + let doc = roxmltree::Document::parse(xml).unwrap(); + assert!(event_fields(doc.root_element()).is_none()); + } +} + +#[test] +fn avatar_classification_does_not_inspect_or_expose_avatar_data() { + for (node, expected) in [ + ("urn:xmpp:avatar:metadata", "avatar-metadata"), + ("urn:xmpp:avatar:data", "avatar-data"), + ] { + let xml = format!( + "PRIVATE-IMAGE" + ); + let doc = roxmltree::Document::parse(&xml).unwrap(); + let fields: BTreeMap<_, _> = event_fields(doc.root_element()) + .unwrap() + .into_iter() + .collect(); + assert_eq!(fields["node_kind"], expected); + assert!(!format!("{fields:?}").contains("PRIVATE")); + } +} diff --git a/tests/pep_discovery.rs b/tests/pep_discovery.rs new file mode 100644 index 0000000..bb86ffe --- /dev/null +++ b/tests/pep_discovery.rs @@ -0,0 +1,194 @@ +use rust_xmpp::pubsub; +use std::path::Path; + +fn account(root: &Path) { + rust_xmpp::auth::create(root, "example.test", "alice", "secret", "SHA-256").unwrap(); +} + +fn query(root: &Path, actor: &str, to: &str, namespace: &str, node: Option<&str>) -> String { + let node = node.map(|n| format!(" node='{n}'")).unwrap_or_default(); + let xml = + format!(""); + let doc = roxmltree::Document::parse(&xml).unwrap(); + pubsub::handle(root, "example.test", actor, doc.root_element()) + .unwrap() + .unwrap() + .replies + .join("") +} + +#[test] +fn account_disco_advertises_pep_without_requiring_a_node() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + for actor in ["alice@example.test/phone", "bob@remote.test/phone"] { + let reply = query( + d.path(), + actor, + "alice@example.test", + "http://jabber.org/protocol/disco#info", + None, + ); + let doc = roxmltree::Document::parse(&reply).unwrap(); + assert!(doc.descendants().any(|n| n.tag_name().name() == "identity" + && n.attribute("category") == Some("pubsub") + && n.attribute("type") == Some("pep"))); + assert!( + !doc.descendants() + .any(|n| n.attribute("category") == Some("server")) + ); + for feature in [ + "auto-create", + "auto-subscribe", + "filtered-notifications", + "create-and-configure", + "publish-options", + "persistent-items", + ] { + let feature = format!("http://jabber.org/protocol/pubsub#{feature}"); + assert!( + doc.descendants().any(|n| n.tag_name().name() == "feature" + && n.attribute("var") == Some(feature.as_str())), + "missing {feature}" + ); + } + for feature in [ + "http://jabber.org/protocol/disco#info", + "http://jabber.org/protocol/disco#items", + ] { + assert!( + doc.descendants() + .any(|n| n.attribute("var") == Some(feature)) + ); + } + } + assert!( + query( + d.path(), + "alice@example.test/phone", + "missing@example.test", + "http://jabber.org/protocol/disco#info", + None + ) + .contains("item-not-found") + ); +} + +#[test] +fn account_discovery_does_not_intercept_server_or_resource_caps() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + for target in [ + None, + Some("example.test"), + Some("alice@example.test/phone"), + Some("alice@remote.test"), + ] { + let to = target.map(|t| format!(" to='{t}'")).unwrap_or_default(); + let xml = format!( + "" + ); + let doc = roxmltree::Document::parse(&xml).unwrap(); + assert!( + pubsub::handle( + d.path(), + "example.test", + "alice@example.test/phone", + doc.root_element() + ) + .unwrap() + .is_none() + ); + } +} + +#[test] +fn node_discovery_keeps_node_identity_errors_and_access_checks() { + let d = tempfile::tempdir().unwrap(); + account(d.path()); + let create = ""; + let create_doc = roxmltree::Document::parse(create).unwrap(); + let created = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/phone", + create_doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(!created.replies[0].contains("type='error'")); + let xml = ""; + let doc = roxmltree::Document::parse(xml).unwrap(); + pubsub::handle( + d.path(), + "example.test", + "alice@example.test/phone", + doc.root_element(), + ) + .unwrap() + .unwrap(); + for namespace in [ + "http://jabber.org/protocol/disco#info", + "http://jabber.org/protocol/disco#items", + ] { + let denied = query( + d.path(), + "bob@remote.test/phone", + "alice@example.test", + namespace, + Some("private"), + ); + assert!(denied.contains("forbidden")); + assert!(!denied.contains("name='one'")); + assert!( + query( + d.path(), + "alice@example.test/phone", + "alice@example.test", + namespace, + Some("missing") + ) + .contains("item-not-found") + ); + let listed = query( + d.path(), + "bob@remote.test/phone", + "alice@example.test", + namespace, + None, + ); + assert!(!listed.contains("node='private'")); + } + let info = query( + d.path(), + "alice@example.test/phone", + "alice@example.test", + "http://jabber.org/protocol/disco#info", + Some("private"), + ); + assert!(info.contains("node='private'")); + assert!(info.contains("type='leaf'")); + assert!(!info.contains("type='pep'")); +} + +#[test] +fn standalone_component_does_not_claim_pep_automatic_subscriptions() { + let d = tempfile::tempdir().unwrap(); + let doc = roxmltree::Document::parse("").unwrap(); + let reply = pubsub::handle_component( + d.path(), + "pubsub.example.test", + "alice@example.test/phone", + &[], + doc.root_element(), + ) + .unwrap() + .unwrap() + .replies + .join(""); + assert!(reply.contains("type='service'")); + assert!(!reply.contains("type='pep'")); + for feature in ["auto-create", "auto-subscribe", "filtered-notifications"] { + assert!(!reply.contains(&format!("pubsub#{feature}"))); + } +} diff --git a/tests/pep_item_order.rs b/tests/pep_item_order.rs new file mode 100644 index 0000000..abfce90 --- /dev/null +++ b/tests/pep_item_order.rs @@ -0,0 +1,134 @@ +//! Multi-item PEP nodes must expose their newest item first, as native Prosody does. +use rust_xmpp::{auth, pubsub}; +use std::path::Path; + +const DEVICES: &str = "eu.siacs.conversations.axolotl.devicelist"; +const PUBSUB: &str = "http://jabber.org/protocol/pubsub"; + +fn request(root: &Path, typ: &str, body: &str) -> String { + let stanza = format!( + "{body}" + ); + let doc = roxmltree::Document::parse(&stanza).unwrap(); + let result = pubsub::handle( + root, + "example.org", + "alice@example.org/phone", + doc.root_element(), + ) + .unwrap() + .unwrap(); + let reply = result.replies.into_iter().next().unwrap(); + assert!(reply.contains("type='result'"), "{reply}"); + reply +} + +fn setup(root: &Path) { + auth::create(root, "example.org", "alice", "test-only", "SHA-256").unwrap(); + request( + root, + "set", + &format!( + "http://jabber.org/protocol/pubsub#node_config4" + ), + ); + for (id, devices) in [("old", ""), ("current", "")] { + request( + root, + "set", + &format!( + "{devices}" + ), + ); + } +} + +#[test] +fn first_pep_item_is_current_device_list_even_with_retained_old_items() { + let d = tempfile::tempdir().unwrap(); + setup(d.path()); + for limit in ["", " max_items='2'", " max_items='1'"] { + let reply = request( + d.path(), + "get", + &format!(""), + ); + let doc = roxmltree::Document::parse(&reply).unwrap(); + // Conversations-family parsers read pubsub/items/item's first child. + // A descendant-only assertion would also accept an obsolete first item. + let items = doc + .root_element() + .children() + .find(|n| n.has_tag_name((PUBSUB, "pubsub"))) + .unwrap() + .children() + .find(|n| n.has_tag_name((PUBSUB, "items"))) + .unwrap(); + let first = items.first_element_child().unwrap(); + assert_eq!(first.attribute("id"), Some("current"), "{reply}"); + let list = first.first_element_child().unwrap(); + assert!(list.has_tag_name(("eu.siacs.conversations.axolotl", "list"))); + assert_eq!( + list.first_element_child().unwrap().attribute("id"), + Some("42") + ); + let ids: Vec<_> = items + .children() + .filter(|n| n.is_element()) + .map(|n| n.attribute("id").unwrap()) + .collect(); + assert_eq!( + ids, + if limit.contains("'1'") { + vec!["current"] + } else { + vec!["current", "old"] + } + ); + } +} + +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn native_prosody_orders_retained_pep_items_newest_first() { + let d = tempfile::tempdir().unwrap(); + setup(d.path()); + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let script = format!( + "{bootstrap}\n{}", + r#" +assert(mm.unload('example.org','auth_internal_hashed')); +require('prosody.core.usermanager').initialize_host('example.org'); +assert(mm.load('example.org','pep')); +local service = mm.get_module('example.org','pep').get_pep_service('alice'); +local ok, items = service:get_items('eu.siacs.conversations.axolotl.devicelist','alice@example.org'); +assert(ok, items); +assert(items[1] == 'current', tostring(items[1])); +assert(items[2] == 'old', tostring(items[2])); +assert(items.current.tags[1].tags[1].attr.id == '42'); +print('NATIVE_PEP_NEWEST_FIRST_OK'); +"# + ); + let path = d.path().join("native-order.lua"); + std::fs::write(&path, script).unwrap(); + let output = std::process::Command::new( + std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()), + ) + .arg(path) + .arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap()) + .arg(d.path()) + .arg("pubsub") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/pep_notifications_network.py b/tests/pep_notifications_network.py new file mode 100644 index 0000000..1aa4ab0 --- /dev/null +++ b/tests/pep_notifications_network.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Real STARTTLS regression for private PEP notification diagnostics. + +Usage: python3 tests/pep_notifications_network.py target/release/rust-xmpp +Exercises generated notifications, payload suppression and matching IQ results. +The fixtures demonstrate diagnostic coverage, not a production root cause. +""" +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time + +from _wire_client import Client + +PUBSUB = 'http://jabber.org/protocol/pubsub' +EVENT = PUBSUB + '#event' +OWNER = PUBSUB + '#owner' +LEGACY = 'eu.siacs.conversations.axolotl' +OMEMO2 = 'urn:xmpp:omemo:2' +PRIVATE_ITEM = 'private-diagnostic-item-607b' +PRIVATE_KEY = 'PRIVATE-KEY-MATERIAL-952a' +VALID_ID = '1937264819' +BAD_ID = 'private-invalid-id-65d2' + + +def require(condition, reason): + if not condition: + raise AssertionError(reason) + + +def fields(record): + return {f['key']: f['value'] for f in record['fields']} + + +def records(path): + return [json.loads(line) for line in path.read_text().splitlines()] + + +def wait_event(path, start, expected): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + events = [r for r in records(path)[start:] + if r.get('event') == 'notification-generated'] + for event in events: + actual = fields(event) + if all(actual.get(k) == v for k, v in expected.items()): + require(event.get('module') == 'pep', 'wrong notification log module') + require(event.get('connection') is None, 'notification log acquired an account context') + return + time.sleep(.02) + raise AssertionError('required private notification diagnostic was not generated') + + +def iq(client, identifier, payload, typ='set'): + client.send(f"{payload}") + return client.iq(identifier) + + +def publish(client, node, payload, identifier): + return iq(client, identifier, f"{payload}") + + +def notification(client, node): + # Client.take preserves unrelated IQs/messages and handles either wire order. + message = client.take(lambda e: e.tag == '{jabber:client}message' + and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items') is not None + and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items').get('node') == node) + require(message.get('from') == 'alice@localhost', 'notification sender must be account bare JID') + wrapper = message.find(f'{{{EVENT}}}event/{{{EVENT}}}items') + items = wrapper.findall(f'{{{EVENT}}}item') + require(len(items) == 1, 'expected exactly one wire notification item') + return items[0] + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-pep-notifications-') as tmp: + root = Path(tmp) + cert = root / 'cert.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-days', '1', '-keyout', str(root / 'key.pem'), '-out', str(cert), + '-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] + log_path = root / 'structured.jsonl' + config = root / 'config.json' + config.write_text(json.dumps({'global': { + 'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], + 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], + 'modules_enabled': ['tls', 'saslauth', 'pep'], + 'log_format': 'json', 'log': {'debug': str(log_path)}}, + 'hosts': {'localhost': {'options': { + 'authentication': 'internal_hashed', 'password_hash': 'SHA-256', + 'ssl': {'key': str(root / 'key.pem'), 'certificate': str(cert)}}}}})) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], + input='secret\n', text=True, check=True, capture_output=True) + with (root / 'process.log').open('w') as output: + process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], + stdout=output, stderr=output) + client = None + try: + for _ in range(100): + require(process.poll() is None, 'fixture server stopped') + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('fixture server did not listen') + client = Client(port, cert, 'alice', 'secret', resource='notification-test') + for index, (node, namespace, element, protocol) in enumerate([ + (LEGACY + '.devicelist', LEGACY, 'list', 'legacy'), + (OMEMO2 + ':devices', OMEMO2, 'devices', 'omemo2')]): + payload = f"<{element} xmlns='{namespace}'>{PRIVATE_KEY}" + publish(client, node, payload, f'initial-{index}') + start = len(records(log_path)) + iq(client, f'subscribe-{index}', f"") + item = notification(client, node) + require(item.find(f'{{{namespace}}}{element}') is not None, + 'replayed wire notification lost the published device payload') + wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': protocol, + 'valid_device_count': '1', 'invalid_device_count': '1', + 'content_present': 'true', 'items_count': '1'}) + + # Successful IQ retrieval and payloadless notifications can coexist. + form = "http://jabber.org/protocol/pubsub#node_configfalse" + iq(client, f'configure-{index}', f"{form}") + start = len(records(log_path)) + publish(client, node, payload, f'payloadless-{index}') + require(len(notification(client, node)) == 0, + 'deliver_payloads=false did not suppress wire payload') + wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': 'other', + 'valid_device_count': '0', 'invalid_device_count': '0', + 'content_present': 'false', 'items_count': '1'}) + response = iq(client, f'fetch-{index}', f"", typ='get') + require(response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items/{{{PUBSUB}}}item/{{{namespace}}}{element}') is not None, + 'payload suppression unexpectedly removed stored IQ payload') + + diagnostics = [r for r in records(log_path) + if r.get('event') in ('pubsub-iq-result', 'notification-generated')] + serialized = json.dumps(diagnostics) + for secret in (PRIVATE_ITEM, PRIVATE_KEY, VALID_ID, BAD_ID): + require(secret not in serialized, 'private fixture value leaked into PEP diagnostics') + iq_rows = [fields(r) for r in diagnostics if r['event'] == 'pubsub-iq-result'] + for protocol in ('legacy', 'omemo2'): + require(any(f.get('action') == 'items' and f.get('device_protocol') == protocol + and f.get('valid_device_count') == '1' + and f.get('invalid_device_count') == '1' for f in iq_rows), + 'successful IQ device parser diagnostic missing') + finally: + if client is not None: + client.close() + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + print('PASS: STARTTLS PEP replay/live diagnostics, legacy/OMEMO2, payloadless notifications with intact IQ payloads, and private values excluded') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/pep_order_network.py b/tests/pep_order_network.py new file mode 100644 index 0000000..6784a3c --- /dev/null +++ b/tests/pep_order_network.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Legacy Android first-item PEP parsing over real loopback STARTTLS. + +Usage: python3 tests/pep_order_network.py target/debug/rust-xmpp +Monocles IqGenerator.retrieveDeviceIds requests every item without max_items; +IqParser.getItem returns only the first item, then deviceIds reads its list. +An older empty record must not hide the latest populated device list. +""" +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +from push_discovery import Client +from pep_client_network import LEGACY, PUBSUB, fetch, publish, result + +NODE = LEGACY + '.devicelist' +DEVICE_IDS = ('2073401123', '2073401456', '2073401789') + + +def check_android_fetch(client, expected): + response = result(fetch(client, NODE, identifier='own-device-list')) + # Match IqParser.getItem: do not search all descendants or merge item lists. + items = response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items') + assert items is not None, ET.tostring(response) + first = items.find(f'{{{PUBSUB}}}item') + assert first is not None and first.get('id') == 'current', ET.tostring(response) + device_list = first.find(f'{{{LEGACY}}}list') + assert device_list is not None, ET.tostring(response) + device_ids = {device.get('id') for device in device_list + if device.tag == f'{{{LEGACY}}}device'} + assert device_ids == expected, ET.tostring(response) + # Retain the old record to exercise real multi-item ordering, not truncation. + assert [item.get('id') for item in items] == ['current', 'older'], ET.tostring(response) + + +def check_info_log(path, expected_reads): + deadline = time.monotonic() + 5 + while True: + text = path.read_text() if path.exists() else '' + records = [json.loads(line) for line in text.splitlines()] + reads = [] + for record in records: + if record.get('event') != 'pubsub-iq-result': + continue + fields = {field['key']: field['value'] for field in record['fields']} + if fields.get('action') == 'items': + reads.append((record, fields)) + if len(reads) >= expected_reads: + break + assert time.monotonic() < deadline, 'missing PEP info log records' + time.sleep(.02) + assert len(reads) == expected_reads, 'unexpected PEP retrieval count' + for record, fields in reads: + assert record['level'] == 'info', record + for key, value in {'status': 'result', 'items_count': '2', + 'first_content_kind': 'omemo-devices', + 'first_device_count': '2', + 'target_scope': 'self-account'}.items(): + assert fields.get(key) == value, (key, fields) + assert all(device not in text for device in DEVICE_IDS), 'device ID leaked in info logs' + assert LEGACY not in text, 'raw PEP node leaked in info logs' + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-pep-order-') 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] + config = root/'config.json' + config.write_text(json.dumps({'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep'], 'log_format': 'json', 'log': {'info': str(root/'structured.jsonl')}}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}})) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL) + for restart in (False, True): + info_log = root/'structured.jsonl' + if info_log.exists(): + info_log.unlink() + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + client = None + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + client = Client(port, root/'cert.pem') + features, identities = client.disco('own-pep', 'alice@localhost') + assert ('pubsub', 'pep') in identities and PUBSUB + '#persistent-items' in features + if not restart: + # Existing device-list nodes can retain multiple differently named + # records. The older item is intentionally a valid empty list. + result(publish(client, NODE, 'older', f"", {'pubsub#max_items': '3'})) + result(publish(client, NODE, 'current', f"")) + check_android_fetch(client, set(DEVICE_IDS[:2])) + # Monocles publishes id=current, then reads its own node without + # an item selector. Replacement must remain the first record. + result(publish(client, NODE, 'current', f"")) + check_android_fetch(client, {DEVICE_IDS[0], DEVICE_IDS[2]}) + check_info_log(info_log, 1 if restart else 2) + finally: + if client is not None: + client.sock.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + if sys.exc_info()[0] is not None: + log.seek(0) + print(log.read(), file=sys.stderr) + print('PASS: Android first-item device-list parsing sees latest publish and replacement before and after restart') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/preauth.rs b/tests/preauth.rs new file mode 100644 index 0000000..1e3983e --- /dev/null +++ b/tests/preauth.rs @@ -0,0 +1,368 @@ +use roxmltree::Document; +use rust_xmpp::{ + account_modules, preauth, + storage::{self, LuaValue as V}, +}; +use tempfile::tempdir; +fn account(root: &std::path::Path, user: &str) { + let mut data = V::table(); + data.set("password", "test".into()); + storage::save(root, "example.org", Some(user), "accounts", &data).unwrap(); +} +#[test] +fn invitations_expire_are_single_use_and_owner_scoped() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "other"); + let invite = + preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100) + .unwrap(); + assert_eq!(invite.token.len(), 32); + assert!( + !preauth::consume( + d.path(), + "example.org", + "other@example.org", + "bob@remote.test", + &invite.token, + 105 + ) + .unwrap() + ); + assert!( + preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test/mobile", + &invite.token, + 109 + ) + .unwrap() + ); + assert!( + !preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + &invite.token, + 109 + ) + .unwrap() + ); + let expired = + preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100) + .unwrap(); + assert!( + !preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + &expired.token, + 110 + ) + .unwrap() + ); +} +#[test] +fn identity_scope_does_not_burn_on_wrong_sender_or_leak_to_prosody() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let invite = preauth::create_invite( + d.path(), + "example.org", + "alice@example.org", + Some("Bob@remote.test"), + 10, + 100, + ) + .unwrap(); + assert!( + storage::load(d.path(), "example.org", Some("alice"), "invite_token") + .unwrap() + .is_none() + ); + assert!( + !preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "eve@remote.test", + &invite.token, + 101 + ) + .unwrap() + ); + assert!( + preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + &invite.token, + 101 + ) + .unwrap() + ); +} +#[test] +fn prosody_numeric_expiry_is_consumed_and_other_entries_preserved() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let mut tokens = V::table(); + tokens.set("legacy", V::Number(200.0)); + tokens.set("unrelated", V::Integer(300)); + storage::save( + d.path(), + "example.org", + Some("alice"), + "invite_token", + &tokens, + ) + .unwrap(); + assert!( + preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + "legacy", + 100 + ) + .unwrap() + ); + let data = storage::load(d.path(), "example.org", Some("alice"), "invite_token") + .unwrap() + .unwrap(); + assert!(data.get("legacy").is_none()); + assert_eq!(data.get("unrelated"), Some(&V::Integer(300))); +} +#[test] +fn duplicate_or_wrong_namespace_tokens_do_not_qualify() { + for xml in [ + "", + "", + "", + ] { + let doc = Document::parse(xml).unwrap(); + assert!(preauth::token_from_presence(doc.root_element()).is_none()); + } +} +#[test] +fn standard_adhoc_command_returns_contact_uri() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let doc = Document::parse("").unwrap(); + let result = preauth::handle_iq( + d.path(), + "example.org", + "alice@example.org/phone", + doc.root_element(), + ) + .unwrap() + .unwrap(); + let answer = Document::parse(&result.replies[0]).unwrap(); + assert_eq!(answer.root_element().attribute("type"), Some("result")); + assert!(result.replies[0].contains("xmpp:alice@example.org?roster;preauth=")); + assert!(result.replies[0].contains("var='expire'")); + assert!(!result.replies[0].contains("ibr=y")); +} +#[test] +fn unknown_client_token_is_preserved_on_remote_and_pending_delivery() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let doc = Document::parse("").unwrap(); + let result = account_modules::subscription( + d.path(), + "example.org", + "alice@example.org/phone", + doc.root_element(), + ) + .unwrap() + .unwrap(); + let forwarded = result + .deliveries + .iter() + .find(|(jid, _)| jid == "bob@remote.test") + .unwrap(); + assert!(forwarded.1.contains("client-token")); + let doc = Document::parse("").unwrap(); + let result = account_modules::inbound_subscription( + d.path(), + "example.org", + "bob@remote.test", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!( + result + .deliveries + .iter() + .any(|(jid, xml)| jid == "alice@example.org" && xml.contains("unknown-token")) + ); + let pending = account_modules::pending_subscriptions(d.path(), "example.org", "alice").unwrap(); + assert!(pending.iter().any(|xml| xml.contains("unknown-token"))); +} +#[test] +fn valid_token_autoapproves_and_sends_reciprocal_request() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let invite = preauth::create_invite( + d.path(), + "example.org", + "alice@example.org", + None, + 60, + chrono::Utc::now().timestamp(), + ) + .unwrap(); + let xml = format!( + "", + invite.token + ); + let doc = Document::parse(&xml).unwrap(); + let result = account_modules::inbound_subscription( + d.path(), + "example.org", + "bob@remote.test", + doc.root_element(), + ) + .unwrap() + .unwrap(); + for kind in ["subscribed", "subscribe"] { + assert!(result.deliveries.iter().any(|(jid, xml)| { + jid == "bob@remote.test" + && Document::parse(xml) + .unwrap() + .root_element() + .attribute("type") + == Some(kind) + })); + } + let roster = storage::load(d.path(), "example.org", Some("alice"), "roster") + .unwrap() + .unwrap(); + let entry = roster.get("bob@remote.test").unwrap(); + assert_eq!(entry.get("subscription").and_then(V::as_str), Some("from")); + assert_eq!(entry.get("ask").and_then(V::as_str), Some("subscribe")); + assert!( + account_modules::pending_subscriptions(d.path(), "example.org", "alice") + .unwrap() + .is_empty() + ); +} +#[test] +fn concurrent_redemption_has_exactly_one_winner() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + let invite = + preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 60, 100) + .unwrap(); + let winners = std::thread::scope(|scope| { + let tasks: Vec<_> = (0..8) + .map(|_| { + let token = &invite.token; + let root = d.path(); + scope.spawn(move || { + preauth::consume( + root, + "example.org", + "alice@example.org", + "bob@remote.test", + token, + 101, + ) + .unwrap() + }) + }) + .collect(); + tasks + .into_iter() + .map(|t| t.join().unwrap()) + .filter(|accepted| *accepted) + .count() + }); + assert_eq!(winners, 1); +} +#[test] +fn local_invitation_updates_both_rosters_without_granting_reverse_subscription() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "bob"); + let invite = preauth::create_invite( + d.path(), + "example.org", + "alice@example.org", + None, + 60, + chrono::Utc::now().timestamp(), + ) + .unwrap(); + let xml = format!( + "", + invite.token + ); + let doc = Document::parse(&xml).unwrap(); + let result = account_modules::subscription( + d.path(), + "example.org", + "bob@example.org/mobile", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(result.deliveries.iter().any(|(jid, xml)| { + jid == "bob@example.org" + && Document::parse(xml) + .unwrap() + .root_element() + .attribute("type") + == Some("subscribe") + })); + for (user, contact, subscription) in [ + ("alice", "bob@example.org", "from"), + ("bob", "alice@example.org", "to"), + ] { + let data = storage::load(d.path(), "example.org", Some(user), "roster") + .unwrap() + .unwrap(); + assert_eq!( + data.get(contact) + .unwrap() + .get("subscription") + .and_then(V::as_str), + Some(subscription) + ); + } +} + +#[test] +fn unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue() { + let d = tempdir().unwrap(); + account(d.path(), "alice"); + assert!( + !preauth::consume( + d.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + "unknown", + 100 + ) + .unwrap() + ); + assert!(!storage::path(d.path(), "example.org", Some("alice"), "invite_token").exists()); + assert!(!storage::path(d.path(), "example.org", Some("alice"), "preauth_scoped").exists()); + storage::update(d.path(), "example.org", Some("alice"), "accounts", |v| { + v.set("disabled", V::Bool(true)); + Ok(()) + }) + .unwrap(); + assert!( + preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100) + .is_err() + ); +} diff --git a/tests/preauth_native.rs b/tests/preauth_native.rs new file mode 100644 index 0000000..eae51f5 --- /dev/null +++ b/tests/preauth_native.rs @@ -0,0 +1,118 @@ +//! Interoperability with the supplied, unmodified Prosody mod_invites. +use rust_xmpp::{auth, preauth}; +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn native_prosody_invitation_tokens_roundtrip() { + let data = tempfile::tempdir().unwrap(); + auth::create(data.path(), "example.org", "alice", "secret", "SHA-256").unwrap(); + let now = chrono::Utc::now().timestamp(); + let rust = preauth::create_invite( + data.path(), + "example.org", + "alice@example.org", + None, + 120, + now, + ) + .unwrap(); + let scoped = preauth::create_invite( + data.path(), + "example.org", + "alice@example.org", + Some("bob@remote.test"), + 120, + now, + ) + .unwrap(); + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let script = format!( + "{bootstrap}\n{}", + r#" +config.set('*', 'modules_enabled', {}); +prosody.shutdown = function() end; -- mod_invites' real-server storage bootstrap condition. +assert(mm.load('example.org', 'invites')); +local invites = assert(mm.get_module('example.org', 'invites')); +if phase == 'invites' then + local invite = assert(invites.get(arg[5], 'alice')); + assert(invite.username == 'alice'); + assert(not invites.get(arg[5], 'other')); + assert(not invites.get(arg[6], 'alice')); -- Scoped Rust token cannot be redeemed by unaware native code. + assert(invite:use()); + assert(not invites.get(arg[5], 'alice')); + local native = assert(invites.create_contact('alice', false, nil, 120)); + assert(not native.allow_registration); + print('NATIVE_INVITE_TOKEN '..native.token); +else + assert(not invites.get(arg[5], 'alice')); -- Rust consumed the native token. + print('NATIVE_INVITE_FINAL_OK'); +end +"# + ); + let path = data.path().join("native-invites.lua"); + std::fs::write(&path, script).unwrap(); + let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap(); + let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()); + let run = |phase: &str, token: &str| { + let result = std::process::Command::new(&lua) + .arg(&path) + .arg(&source) + .arg(data.path()) + .arg(phase) + .arg("SHA-256") + .arg(token) + .arg(&scoped.token) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() + }; + let result = run("invites", &rust.token); + let token = result + .lines() + .find_map(|line| line.strip_prefix("NATIVE_INVITE_TOKEN ")) + .unwrap(); + assert!( + !preauth::consume( + data.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + &rust.token, + chrono::Utc::now().timestamp() + ) + .unwrap() + ); + assert!( + preauth::consume( + data.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + token, + chrono::Utc::now().timestamp() + ) + .unwrap() + ); + assert!(run("verify-invites", token).contains("NATIVE_INVITE_FINAL_OK")); + assert!( + preauth::consume( + data.path(), + "example.org", + "alice@example.org", + "bob@remote.test", + &scoped.token, + chrono::Utc::now().timestamp() + ) + .unwrap() + ); +} diff --git a/tests/preauth_network.py b/tests/preauth_network.py new file mode 100644 index 0000000..a42d329 --- /dev/null +++ b/tests/preauth_network.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Isolated TLS contact invitation, token forwarding and roster regression.""" +import json, socket, subprocess, tempfile, time, sys +from pathlib import Path +from urllib.parse import urlsplit +import xml.etree.ElementTree as ET +from _wire_client import Client, local, BIN + +COMMANDS='http://jabber.org/protocol/commands' +DISCO='http://jabber.org/protocol/disco#items' +PARS='urn:xmpp:pars:0' + +def roster(client, identifier): + client.send(f"") + return {e.get('jid'):e.get('subscription') for e in client.iq(identifier).iter() if e.tag=='{jabber:iq:roster}item'} + +def run(): + with tempfile.TemporaryDirectory(prefix='xmpp-preauth-') 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] + cfg={'global':{'data_path':str(root/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[port],'http_ports':[],'https_ports':[],'s2s_ports':[],'modules_enabled':['tls','saslauth','roster','disco','pep','cloud_notify','mam','ping','smacks']},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256','ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}}}} + config=root/'config.json';config.write_text(json.dumps(cfg)) + for user in ['alice','bob','carol']: + subprocess.run([str(BIN),'create-user',str(config),user,'--password-stdin'],input=user+'-secret\n',text=True,check=True,capture_output=True) + with (root/'server.log').open('w+') as log: + server=subprocess.Popen([str(BIN),'serve',str(config),'--experimental'],stdout=log,stderr=log);clients=[] + try: + for _ in range(100): + assert server.poll() is None,'server stopped' + try: + with socket.create_connection(('127.0.0.1',port),timeout=.1):break + except OSError:time.sleep(.05) + a,b,c=[Client(port,root/'cert.pem',u,u+'-secret') for u in ['alice','bob','carol']];clients=[a,b,c] + for client in clients: + features=ET.fromstring(client.post_auth_features[client.post_auth_features.index(b'") + assert any(n.get('node')=='urn:xmpp:invite#invite' for n in a.iq('commands').iter()) + a.send(f"") + result=a.iq('invite');fields={n.get('var'):n.find('{jabber:x:data}value').text for n in result.iter('{jabber:x:data}field')} + uri=fields['uri'];assert uri.startswith('xmpp:alice@localhost?roster;preauth=') and 'ibr=y' not in uri + token=uri.split('preauth=',1)[1];assert len(token)==32 and fields['expire'] + b.send("");b.sync() + b.send(f"");b.sync();a.sync() + assert roster(a,'alice-after').get('bob@localhost')=='both' + assert roster(b,'bob-after').get('alice@localhost')=='both' + c.send(f"");c.sync();a.sync() + assert roster(a,'replay-denied').get('carol@localhost')=='none' + replay=a.take(lambda n:local(n)=='presence' and n.get('id')=='replay') + assert replay.find('{'+PARS+'}preauth').get('token')==token + # Unknown client-generated tokens must reach the owner even after offline replay. + a.close();clients.remove(a);time.sleep(.1) + c.send(f"");c.sync() + a=Client(port,root/'cert.pem','alice','alice-secret','again');clients.append(a);a.available() + pending=a.take(lambda n:local(n)=='presence' and n.get('id')=='manual') + assert pending.find('{'+PARS+'}preauth').get('token')=='client-generated-token' + # A different authenticated account cannot issue invitations in Alice's name. + c.send(f"");c.iq('foreign',error=True) + print('PASS: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation') + except BaseException: + log.flush();log.seek(0);print(log.read());raise + finally: + for client in clients:client.close() + server.terminate() + try:server.wait(timeout=5) + except subprocess.TimeoutExpired:server.kill();server.wait() + +if __name__=='__main__': + if len(sys.argv)>1:BIN=Path(sys.argv[1]).resolve() + run() diff --git a/tests/pubsub_native.rs b/tests/pubsub_native.rs new file mode 100644 index 0000000..908b7f6 --- /dev/null +++ b/tests/pubsub_native.rs @@ -0,0 +1,71 @@ +//! Exercises the actual supplied mod_pep/util.pubsub, not a duplicate storage decoder. +use rust_xmpp::{auth, pubsub}; +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn native_prosody_pep_reads_rust_and_rust_reads_native() { + let d = tempfile::tempdir().unwrap(); + auth::create(d.path(), "example.org", "alice", "test-only", "SHA-256").unwrap(); + let xml = ""; + let doc = roxmltree::Document::parse(xml).unwrap(); + let result = pubsub::handle( + d.path(), + "example.org", + "alice@example.org/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(result.replies[0].contains("type='result'")); + let bootstrap = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/prosody_compat/oracle.lua" + )) + .unwrap(); + let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap(); + let script = format!( + "{bootstrap}\n{}", + r#" +assert(mm.unload('example.org','auth_internal_hashed')); +require('prosody.core.usermanager').initialize_host('example.org'); +assert(require('prosody.core.usermanager').user_exists('alice','example.org')); +assert(mm.load('example.org','pep')); +local env=assert(mm.get_module('example.org','pep')); +local service=env.get_pep_service('alice'); +local ok,items=service:get_items('urn:test:devices','alice@example.org');assert(ok,items); +assert(items[1]=='rust');local item=assert(items['rust']);assert(item.tags[1].attr.xmlns=='urn:test:devices');assert(item.tags[1].tags[1].attr.id=='42'); +local published=st.stanza('item',{xmlns='http://jabber.org/protocol/pubsub',id='native',publisher='alice@example.org'}):tag('devices',{xmlns='urn:test:devices'}):tag('device',{id='99'}):up():up(); +local ok,err=service:publish('urn:test:devices','alice@example.org','native',published);assert(ok,err); +print('NATIVE_PEP_ROUNDTRIP_OK'); +"# + ); + let path = d.path().join("native.lua"); + std::fs::write(&path, script).unwrap(); + let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap(); + let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()); + let out = std::process::Command::new(lua) + .arg(path) + .arg(source) + .arg(d.path()) + .arg("pubsub") + .output() + .unwrap(); + assert!( + out.status.success(), + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let get = ""; + let doc = roxmltree::Document::parse(get).unwrap(); + let out = pubsub::handle( + d.path(), + "example.org", + "alice@example.org/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(out.replies[0].contains("id='native'"), "{:?}", out.replies); + assert!(out.replies[0].contains("id='99'")); + assert!(!out.replies[0].contains("id='rust'")); +} diff --git a/tests/pubsub_persistence.rs b/tests/pubsub_persistence.rs new file mode 100644 index 0000000..ee2b38c --- /dev/null +++ b/tests/pubsub_persistence.rs @@ -0,0 +1,951 @@ +use rust_xmpp::{ + pubsub, + storage::{self, LuaValue as V}, +}; +use std::path::Path; +fn account(root: &Path, user: &str) { + let mut data = V::table(); + data.set("password", "test-only".into()); + storage::save(root, "example.test", Some(user), "accounts", &data).unwrap(); +} +fn request(root: &Path, actor: &str, body: &str) -> String { + let doc = roxmltree::Document::parse(body).unwrap(); + pubsub::handle(root, "example.test", actor, doc.root_element()) + .unwrap() + .unwrap() + .replies + .join("") +} +fn iq(typ: &str, to: &str, content: &str) -> String { + format!( + "{content}" + ) +} +#[test] +fn pep_persists_namespaces_and_enforces_owner() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "bob"); + let publish = iq( + "set", + "alice@example.test", + "", + ); + assert!(request(d.path(), "bob@example.test/r", &publish).contains("forbidden")); + assert!(request(d.path(), "alice@example.test/r", &publish).contains("type='result'")); + let get = iq( + "get", + "alice@example.test", + "", + ); + assert!(request(d.path(), "bob@example.test/r", &get).contains("forbidden")); + let response = request(d.path(), "alice@example.test/r", &get); + let doc = roxmltree::Document::parse(&response).unwrap(); + let device = doc + .descendants() + .find(|n| n.has_tag_name(("urn:xmpp:omemo:2", "device"))) + .unwrap(); + assert_eq!(device.attribute(("urn:extension", "label")), Some("phone")); + let nodes = storage::load(d.path(), "example.test", Some("alice"), "pep") + .unwrap() + .unwrap(); + assert!(nodes.get("urn:xmpp:omemo:2:devices").is_some()); +} +#[test] +fn open_model_and_notifications() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "bob"); + let create = iq( + "set", + "alice@example.test", + "http://jabber.org/protocol/pubsub#node_configopen", + ); + assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'")); + let sub = iq( + "set", + "alice@example.test", + "", + ); + assert!(request(d.path(), "bob@example.test/r", &sub).contains("subscribed")); + let p = iq( + "set", + "alice@example.test", + "abc", + ); + let doc = roxmltree::Document::parse(&p).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(result.deliveries.len(), 1); + assert!( + result + .deliveries + .iter() + .any(|d| d.0 == "bob@example.test/r") + ); + assert!( + request( + d.path(), + "bob@example.test/r", + &iq("get", "alice@example.test", "") + ) + .contains("abc") + ); +} +#[test] +fn roster_presence_access_and_foreign_subscription_denied() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let p = iq( + "set", + "alice@example.test", + "", + ); + request(d.path(), "alice@example.test/r", &p); + let mut roster = V::table(); + let mut contact = V::table(); + contact.set("subscription", "from".into()); + roster.set("bob@example.test", contact); + storage::save(d.path(), "example.test", Some("alice"), "roster", &roster).unwrap(); + assert!( + request( + d.path(), + "bob@example.test/r", + &iq("get", "alice@example.test", "") + ) + .contains("type='result'") + ); + assert!( + request( + d.path(), + "bob@example.test/r", + &iq( + "set", + "alice@example.test", + "" + ) + ) + .contains("forbidden") + ); +} +#[test] +fn configure_requires_owner() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + request( + d.path(), + "alice@example.test/r", + &iq("set", "alice@example.test", ""), + ); + let x = ""; + assert!(request(d.path(), "bob@example.test/r", x).contains("forbidden")); + assert!(request(d.path(), "alice@example.test/r", x).contains("type='result'")); + assert!( + request( + d.path(), + "alice@example.test/r", + &iq("get", "alice@example.test", "") + ) + .contains("item-not-found") + ); +} +#[test] +fn reads_prosody_legacy_fixture() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let p = storage::path(d.path(), "example.test", Some("alice"), "pep"); + storage::atomic_write(&p,b"return { ['urn:old']={name='urn:old';config={access_model='open';max_items=1};subscribers={};affiliations={['alice@example.test']='owner'}}};").unwrap(); + let archive = storage::path(d.path(), "example.test", Some("alice"), "pep_urn:old") + .with_extension("list"); + storage::atomic_write(&archive,b"item({name='payload';attr={xmlns='urn:old';stamp='2020-01-01T00:00:00Z'};[1]='persisted';key='current';when=1577836800;with='alice@example.test'});\n").unwrap(); + let out = request( + d.path(), + "bob@example.test/r", + &iq("get", "alice@example.test", ""), + ); + assert!(out.contains("persisted"), "{out}"); + let doc = roxmltree::Document::parse(&out).unwrap(); + assert!( + doc.descendants() + .any(|n| n.has_tag_name(("urn:old", "payload"))) + ); +} +#[test] +fn configurable_limits_component_creation_and_publisher() { + let d = tempfile::tempdir().unwrap(); + let options = pubsub::Options { + max_items: 10000, + restrict_node_creation: false, + autocreate_on_publish: false, + }; + let run = |actor: &str, xml: &str| { + let doc = roxmltree::Document::parse(xml).unwrap(); + pubsub::handle_component_with_options( + d.path(), + "pubsub.example.test", + actor, + &[], + doc.root_element(), + &options, + ) + .unwrap() + .unwrap() + .replies + .join("") + }; + assert!(run("alice@example.test/r",&iq("set","pubsub.example.test","10000")).contains("type='result'")); + let set = ""; + assert!(run("alice@example.test/r", set).contains("type='result'")); + let publish = iq( + "set", + "pubsub.example.test", + "", + ); + assert!(run("bob@example.test/r", &publish).contains("type='result'")); + assert!(run("eve@example.test/r", &publish).contains("forbidden")); +} +#[test] +fn capability_discovery_replays_and_filters_notifications() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "bob"); + let mut a = V::table(); + let mut c = V::table(); + c.set("subscription", "from".into()); + a.set("bob@example.test", c); + storage::save(d.path(), "example.test", Some("alice"), "roster", &a).unwrap(); + let mut b = V::table(); + let mut c = V::table(); + c.set("subscription", "to".into()); + b.set("alice@example.test", c); + storage::save(d.path(), "example.test", Some("bob"), "roster", &b).unwrap(); + let publish = iq( + "set", + "alice@example.test", + "hello", + ); + request(d.path(), "alice@example.test/r", &publish); + let presence=roxmltree::Document::parse("").unwrap(); + let query = pubsub::on_presence( + d.path(), + "example.test", + "bob@example.test/r", + presence.root_element(), + ) + .unwrap(); + assert_eq!(query.deliveries.len(), 1); + let querydoc = roxmltree::Document::parse(&query.deliveries[0].1).unwrap(); + let id = querydoc.root_element().attribute("id").unwrap(); + let response = format!( + "" + ); + let doc = roxmltree::Document::parse(&response).unwrap(); + assert!( + pubsub::on_disco_result( + d.path(), + "example.test", + "eve@example.test/r", + doc.root_element() + ) + .unwrap() + .is_none() + ); + let replay = pubsub::on_disco_result( + d.path(), + "example.test", + "bob@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(replay.deliveries.len(), 1); + assert!(replay.deliveries[0].1.contains("hello")); + let doc = roxmltree::Document::parse(&publish).unwrap(); + let events = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!( + events + .deliveries + .iter() + .any(|d| d.0 == "bob@example.test/r") + ); + storage::save( + d.path(), + "example.test", + Some("alice"), + "roster", + &V::table(), + ) + .unwrap(); + let events = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!( + !events + .deliveries + .iter() + .any(|d| d.0 == "bob@example.test/r") + ); +} +#[test] +fn subscription_options_disable_events_and_require_subscriber() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + request( + d.path(), + "alice@example.test/r", + &iq( + "set", + "alice@example.test", + "open", + ), + ); + let sub = iq( + "set", + "alice@example.test", + "http://jabber.org/protocol/pubsub#subscribe_options0", + ); + assert!(request(d.path(), "bob@example.test/r", &sub).contains("subscribed")); + let get = iq( + "get", + "alice@example.test", + "", + ); + assert!(request(d.path(), "bob@example.test/r", &get).contains("0")); + assert!(request(d.path(), "eve@example.test/r", &get).contains("forbidden")); + let pubxml = iq( + "set", + "alice@example.test", + "", + ); + let doc = roxmltree::Document::parse(&pubxml).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!( + !result + .deliveries + .iter() + .any(|d| d.0 == "bob@example.test/r") + ); +} +#[test] +fn nonpersistent_items_stay_in_memory_and_can_be_made_persistent() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let create = iq( + "set", + "alice@example.test", + "0", + ); + assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'")); + let p = iq( + "set", + "alice@example.test", + "memory-only", + ); + assert!(request(d.path(), "alice@example.test/r", &p).contains("type='result'")); + let get = iq("get", "alice@example.test", ""); + assert!(request(d.path(), "alice@example.test/r", &get).contains("memory-only")); + assert!( + rust_xmpp::archive::load_store(d.path(), "example.test", Some("alice"), "pep_n") + .unwrap() + .is_empty() + ); + let config = "1"; + assert!(request(d.path(), "alice@example.test/r", config).contains("type='result'")); + assert_eq!( + rust_xmpp::archive::load_store(d.path(), "example.test", Some("alice"), "pep_n") + .unwrap() + .len(), + 1 + ); +} +#[test] +fn modern_bookmarks_notify_legacy_capability() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let presence=roxmltree::Document::parse("").unwrap(); + let q = pubsub::on_presence( + d.path(), + "example.test", + "alice@example.test/old", + presence.root_element(), + ) + .unwrap(); + let qdoc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap(); + let id = qdoc.root_element().attribute("id").unwrap(); + let answer = format!( + "" + ); + let doc = roxmltree::Document::parse(&answer).unwrap(); + pubsub::on_disco_result( + d.path(), + "example.test", + "alice@example.test/old", + doc.root_element(), + ) + .unwrap(); + let publish = iq( + "set", + "alice@example.test", + "Alice", + ); + let doc = roxmltree::Document::parse(&publish).unwrap(); + let events = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/new", + doc.root_element(), + ) + .unwrap() + .unwrap(); + let legacy = events + .deliveries + .iter() + .find(|d| d.0 == "alice@example.test/old") + .unwrap(); + assert!(legacy.1.contains("storage:bookmarks")); + assert!(legacy.1.contains("jid='room@conference.example.test'")); + assert!(legacy.1.contains("Alice")); +} +#[test] +fn remote_caps_replay_and_open_node_privacy_revoke() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let create = iq( + "set", + "alice@example.test", + "open", + ); + request(d.path(), "alice@example.test/r", &create); + let p = iq( + "set", + "alice@example.test", + "remote payload", + ); + request(d.path(), "alice@example.test/r", &p); + let presence=roxmltree::Document::parse("").unwrap(); + assert!( + pubsub::on_remote_presence( + d.path(), + "example.test", + "bob@remote.test/r", + presence.root_element() + ) + .unwrap() + .deliveries + .is_empty() + ); + let mut roster = V::table(); + let mut c = V::table(); + c.set("subscription", "from".into()); + roster.set("bob@remote.test", c); + storage::save(d.path(), "example.test", Some("alice"), "roster", &roster).unwrap(); + let q = pubsub::on_remote_presence( + d.path(), + "example.test", + "bob@remote.test/r", + presence.root_element(), + ) + .unwrap(); + assert_eq!(q.deliveries.len(), 1); + let doc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap(); + let id = doc.root_element().attribute("id").unwrap(); + let answer = format!( + "" + ); + let doc = roxmltree::Document::parse(&answer).unwrap(); + assert!( + pubsub::on_disco_result( + d.path(), + "example.test", + "mallory@remote.test/r", + doc.root_element() + ) + .unwrap() + .is_none() + ); + let result = pubsub::on_disco_result( + d.path(), + "example.test", + "bob@remote.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(result.deliveries.len(), 1); + assert!(result.deliveries[0].1.contains("remote payload")); + let doc = roxmltree::Document::parse(&p).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(result.deliveries.iter().any(|e| e.0 == "bob@remote.test/r")); + storage::save( + d.path(), + "example.test", + Some("alice"), + "roster", + &V::table(), + ) + .unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(!result.deliveries.iter().any(|e| e.0 == "bob@remote.test/r")); +} +#[test] +fn component_default_retention_matches_prosody_twenty_items() { + let d = tempfile::tempdir().unwrap(); + let options = pubsub::Options { + restrict_node_creation: false, + ..Default::default() + }; + let run = |xml: String| { + let doc = roxmltree::Document::parse(&xml).unwrap(); + pubsub::handle_component_with_options( + d.path(), + "pubsub.example.test", + "alice@example.test/r", + &[], + doc.root_element(), + &options, + ) + .unwrap() + .unwrap() + }; + run(iq("set", "pubsub.example.test", "")); + for i in 0..22 { + run(iq( + "set", + "pubsub.example.test", + &format!(""), + )); + } + let entries = + rust_xmpp::archive::load_store(d.path(), "pubsub.example.test", None, "pubsub_n").unwrap(); + assert_eq!(entries.len(), 20); + assert_eq!(entries[0].id, "2"); +} +#[test] +fn remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending() { + let d = tempfile::tempdir().unwrap(); + for owner in ["alice", "carol"] { + account(d.path(), owner); + let mut roster = V::table(); + let mut c = V::table(); + c.set("subscription", "from".into()); + roster.set("bob@remote.test", c); + storage::save(d.path(), "example.test", Some(owner), "roster", &roster).unwrap(); + } + let mut ids = vec![]; + for owner in ["alice", "carol"] { + let p = format!( + "" + ); + let doc = roxmltree::Document::parse(&p).unwrap(); + let q = pubsub::on_remote_presence( + d.path(), + "example.test", + "bob@remote.test/r", + doc.root_element(), + ) + .unwrap(); + let qdoc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap(); + ids.push(qdoc.root_element().attribute("id").unwrap().to_owned()); + } + let unavailable = roxmltree::Document::parse( + "", + ) + .unwrap(); + pubsub::on_remote_presence( + d.path(), + "example.test", + "bob@remote.test", + unavailable.root_element(), + ) + .unwrap(); + for (i, id) in ids.iter().enumerate() { + let answer = format!( + "" + ); + let doc = roxmltree::Document::parse(&answer).unwrap(); + let result = pubsub::on_disco_result( + d.path(), + "example.test", + "bob@remote.test/r", + doc.root_element(), + ) + .unwrap(); + assert_eq!(result.is_some(), i == 1); + } + for owner in ["alice", "carol"] { + let p = iq( + "set", + &format!("{owner}@example.test"), + "", + ); + let doc = roxmltree::Document::parse(&p).unwrap(); + let r = pubsub::handle( + d.path(), + "example.test", + &format!("{owner}@example.test/r"), + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!( + r.deliveries.iter().any(|e| e.0 == "bob@remote.test/r"), + owner == "carol" + ); + } +} + +#[test] +fn payload_policy_applies_to_subscription_and_caps_replays() { + for (node, advertised, payload) in [ + ( + "urn:test:private", + "urn:test:private", + "payload-secret", + ), + ( + "urn:xmpp:bookmarks:1", + "storage:bookmarks", + "", + ), + ] { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let create = iq( + "set", + "alice@example.test", + &format!( + "0" + ), + ); + assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'")); + let publish = iq( + "set", + "alice@example.test", + &format!( + "{payload}" + ), + ); + assert!(request(d.path(), "alice@example.test/r", &publish).contains("type='result'")); + let subscribe = iq( + "set", + "alice@example.test", + &format!(""), + ); + let doc = roxmltree::Document::parse(&subscribe).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(result.deliveries.len(), 1); + assert!( + result.deliveries[0] + .1 + .contains(""), + "{:?}", + result.deliveries + ); + assert!(!result.deliveries[0].1.contains("payload-secret")); + + let presence = roxmltree::Document::parse( + "", + ) + .unwrap(); + let query = pubsub::on_presence( + d.path(), + "example.test", + "alice@example.test/caps", + presence.root_element(), + ) + .unwrap(); + let query_doc = roxmltree::Document::parse(&query.deliveries[0].1).unwrap(); + let id = query_doc.root_element().attribute("id").unwrap(); + let answer = format!( + "" + ); + let doc = roxmltree::Document::parse(&answer).unwrap(); + let result = pubsub::on_disco_result( + d.path(), + "example.test", + "alice@example.test/caps", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(result.deliveries.len(), 1); + assert!( + !result.deliveries[0].1.contains("payload-secret"), + "{:?}", + result.deliveries + ); + let doc = roxmltree::Document::parse(&publish).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert_eq!(result.deliveries.len(), 2); + assert!( + result + .deliveries + .iter() + .all(|(_, xml)| !xml.contains("payload-secret")) + ); + // Omitting notification payload does not discard the stored item. + let get = iq( + "get", + "alice@example.test", + &format!(""), + ); + assert!(request(d.path(), "alice@example.test/r", &get).contains("payload-secret")); + } +} + +#[test] +fn publish_options_conflict_is_recoverable_and_preserves_item() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let node = "urn:xmpp:omemo:2:devices"; + let publish = |value: &str, access: &str| { + iq( + "set", + "alice@example.test", + &format!( + "{value}http://jabber.org/protocol/pubsub#publish-options{access}" + ), + ) + }; + assert!( + request( + d.path(), + "alice@example.test/r", + &publish("original", "presence") + ) + .contains("type='result'") + ); + let reply = request( + d.path(), + "alice@example.test/r", + &publish("replacement", "open"), + ); + let doc = roxmltree::Document::parse(&reply).unwrap(); + assert!( + doc.descendants() + .any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "conflict"))) + ); + assert!(doc.descendants().any(|n| n.has_tag_name(( + "http://jabber.org/protocol/pubsub#errors", + "precondition-not-met" + )))); + let get = iq( + "get", + "alice@example.test", + &format!(""), + ); + let reply = request(d.path(), "alice@example.test/r", &get); + assert!(reply.contains("original")); + assert!(!reply.contains("replacement")); +} + +#[test] +fn full_jid_block_applies_to_reads_subscription_replays_and_events() { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + account(d.path(), "bob"); + let create = iq( + "set", + "alice@example.test", + "open", + ); + assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'")); + let subscribe = iq( + "set", + "alice@example.test", + "", + ); + assert!(request(d.path(), "bob@example.test/blocked", &subscribe).contains("subscribed")); + let publish = iq( + "set", + "alice@example.test", + "secret", + ); + request(d.path(), "alice@example.test/r", &publish); + let mut blocked = V::table(); + blocked.set("bob@example.test/blocked", V::Bool(true)); + storage::save( + d.path(), + "example.test", + Some("alice"), + "blocklist", + &blocked, + ) + .unwrap(); + let get = iq("get", "alice@example.test", ""); + assert!(request(d.path(), "bob@example.test/blocked", &get).contains("forbidden")); + assert!(request(d.path(), "bob@example.test/allowed", &get).contains("secret")); + // A different resource must not cause a replay to the blocked resource. + assert!(request(d.path(), "bob@example.test/allowed", &subscribe).contains("forbidden")); + let doc = roxmltree::Document::parse(&publish).unwrap(); + let result = pubsub::handle( + d.path(), + "example.test", + "alice@example.test/r", + doc.root_element(), + ) + .unwrap() + .unwrap(); + assert!(result.deliveries.is_empty(), "{:?}", result.deliveries); +} + +#[test] +fn native_string_max_items_satisfies_numeric_publish_precondition() { + for maximum in ["1", "max"] { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let mut config = V::table(); + config.set("max_items", maximum.into()); + let mut node = V::table(); + node.set("name", "urn:test:devices".into()); + node.set("config", config); + let mut metadata = V::table(); + metadata.set("urn:test:devices", node); + storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap(); + let publish = iq( + "set", + "alice@example.test", + &format!( + "http://jabber.org/protocol/pubsub#publish-options{maximum}" + ), + ); + let result = request(d.path(), "alice@example.test/r", &publish); + assert!(result.contains("type='result'"), "{result}"); + } +} + +#[test] +fn native_notification_fields_and_retract_policy_are_independent() { + for component in [false, true] { + let d = tempfile::tempdir().unwrap(); + account(d.path(), "alice"); + let target = if component { + "pubsub.example.test" + } else { + "alice@example.test" + }; + let options = pubsub::Options { + restrict_node_creation: false, + ..Default::default() + }; + let run = |actor: &str, xml: &str| { + let doc = roxmltree::Document::parse(xml).unwrap(); + if component { + pubsub::handle_component_with_options( + d.path(), + target, + actor, + &[], + doc.root_element(), + &options, + ) + } else { + pubsub::handle(d.path(), "example.test", actor, doc.root_element()) + } + .unwrap() + .unwrap() + }; + let create = iq( + "set", + target, + "open001", + ); + assert!(run("alice@example.test/r", &create).replies[0].contains("type='result'")); + run( + "bob@example.test/r", + &iq( + "set", + target, + "", + ), + ); + let publish = iq( + "set", + target, + "secret", + ); + let retract = iq( + "set", + target, + "", + ); + assert!(run("alice@example.test/r", &publish).deliveries.is_empty()); + let result = run("alice@example.test/r", &retract); + assert_eq!(result.deliveries.len(), 1, "{:?}", result); + assert!( + result.deliveries[0] + .1 + .contains("") + ); + + let configure = format!( + "10" + ); + run("alice@example.test/r", &configure); + let result = run("alice@example.test/r", &publish); + assert_eq!(result.deliveries.len(), 1); + assert!(!result.deliveries[0].1.contains("secret")); + assert!(run("alice@example.test/r", &retract).deliveries.is_empty()); + let get = format!( + "" + ); + let result = run("alice@example.test/r", &get); + assert!(result.replies[0].contains("pubsub#deliver_notifications")); + assert!(result.replies[0].contains("pubsub#deliver_payloads")); + assert!(!result.replies[0].contains("pubsub#notify_items")); + assert!(!result.replies[0].contains("pubsub#include_payload")); + } +} diff --git a/tests/push.rs b/tests/push.rs new file mode 100644 index 0000000..bae3c0b --- /dev/null +++ b/tests/push.rs @@ -0,0 +1,434 @@ +use rust_xmpp::{auth, config::Config, push::Push, storage}; +fn config() -> Config { + serde_json::from_value(serde_json::json!({"global":{"modules_enabled":["cloud_notify"],"push_notification_with_body":false,"push_notification_with_sender":true,"push_notification_important_body":"New message"},"hosts":{"example.test":{"options":{}}}})).unwrap() +} +#[test] +fn registrations_notifications_and_authenticated_errors() { + let dir = tempfile::tempdir().unwrap(); + auth::create(dir.path(), "example.test", "alice", "secret", "SHA-256").unwrap(); + let mut push = Push::default(); + let enable=roxmltree::Document::parse("token").unwrap(); + assert!( + push.handle( + dir.path(), + "example.test", + "alice@example.test/mobile", + enable.root_element(), + 50 + ) + .unwrap() + .unwrap() + .replies[0] + .contains("type='result'") + ); + let data = storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify") + .unwrap() + .unwrap(); + assert!(data.get("push.testprivate text").unwrap(); + for (i, sender) in ["forged.test", "push.test", "push.test"].iter().enumerate() { + let notifications = push + .notify( + dir.path(), + "example.test", + "alice", + message.root_element(), + &config(), + ) + .unwrap(); + assert_eq!(notifications.len(), 1); + let xml = ¬ifications[0].1; + assert!(!xml.contains("private text")); + assert!(xml.contains("New message") && xml.contains("token")); + let d = roxmltree::Document::parse(xml).unwrap(); + let id = d.root_element().attribute("id").unwrap(); + let reply = + format!(""); + let d = roxmltree::Document::parse(&reply).unwrap(); + assert_eq!( + push.result(dir.path(), "example.test", d.root_element(), 2) + .unwrap(), + i > 0 + ); + } + assert!( + storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify") + .unwrap() + .is_none() + ); +} +#[test] +fn foreign_account_cannot_register() { + let dir = tempfile::tempdir().unwrap(); + let mut push = Push::default(); + let d=roxmltree::Document::parse("").unwrap(); + let r = push + .handle( + dir.path(), + "example.test", + "alice@example.test/r", + d.root_element(), + 50, + ) + .unwrap() + .unwrap(); + assert!(r.replies[0].contains("forbidden")); +} + +#[test] +fn stable_client_registration_and_targeted_notification() { + use rust_xmpp::push::client_device; + let dir = tempfile::tempdir().unwrap(); + let mut push = Push::default(); + for (node, resource, client) in [ + ("phone", "old", Some("stable-phone")), + ("tablet", "tablet", None), + ] { + let xml = format!( + "" + ); + let document = roxmltree::Document::parse(&xml).unwrap(); + push.handle_with_client( + dir.path(), + "example.test", + &format!("alice@example.test/{resource}"), + document.root_element(), + 50, + client, + ) + .unwrap(); + } + let table = storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify") + .unwrap() + .unwrap(); + let phone = table.get("push.testhello").unwrap(); + let sent = push + .notify_for_identifier( + dir.path(), + "example.test", + "alice", + message.root_element(), + &config(), + Some("push.test", + ) + .unwrap(); + push.handle( + dir.path(), + "example.test", + "alice@example.test/r", + enable.root_element(), + 5, + ) + .unwrap(); + let messages = vec![ + "not important".into(), + "".into(), + "important".into(), + "second important".into(), + ]; + let result = push + .notify_queue( + dir.path(), + "example.test", + "alice", + &messages, + &config(), + "push.testTopictopic text", + false, + ), + ("", true), + ( + "", + false, + ), + ( + "", + true, + ), + ( + "outgoing", + false, + ), + ] { + let doc = roxmltree::Document::parse(xml).unwrap(); + assert_eq!( + rust_xmpp::push::is_important(doc.root_element()), + expected, + "{xml}" + ); + } +} +#[test] +fn reenable_cancels_old_errors_and_failure_counts_are_per_account() { + let dir = tempfile::tempdir().unwrap(); + let mut push = Push::default(); + let enable = roxmltree::Document::parse( + "", + ) + .unwrap(); + for user in ["alice", "bob"] { + push.handle( + dir.path(), + "example.test", + &format!("{user}@example.test/r"), + enable.root_element(), + 5, + ) + .unwrap(); + } + let message = roxmltree::Document::parse("hello").unwrap(); + let fail = |push: &mut Push, user: &str| { + let out = push + .notify( + dir.path(), + "example.test", + user, + message.root_element(), + &config(), + ) + .unwrap(); + let doc = roxmltree::Document::parse(&out[0].1).unwrap(); + let id = doc.root_element().attribute("id").unwrap(); + let error = + format!(""); + let doc = roxmltree::Document::parse(&error).unwrap(); + push.result(dir.path(), "example.test", doc.root_element(), 2) + .unwrap(); + }; + fail(&mut push, "alice"); + fail(&mut push, "bob"); + assert!(push.take_removed().is_empty()); + fail(&mut push, "alice"); + assert_eq!( + push.take_removed(), + vec![("alice".into(), "push.test"); + push.handle( + dir.path(), + "example.test", + "bob@example.test/r", + enable.root_element(), + 5, + ) + .unwrap(); + let doc = roxmltree::Document::parse(&error).unwrap(); + assert!( + !push + .result(dir.path(), "example.test", doc.root_element(), 1) + .unwrap() + ); + assert!(push.take_removed().is_empty()); +} + +#[test] +fn push_summary_never_copies_foreign_namespace_body() { + let dir = tempfile::tempdir().unwrap(); + let mut push = Push::default(); + let enable = roxmltree::Document::parse( + "", + ) + .unwrap(); + push.handle( + dir.path(), + "example.test", + "alice@example.test/r", + enable.root_element(), + 5, + ) + .unwrap(); + let mut settings = config(); + settings.global.insert( + "push_notification_with_body".into(), + serde_json::json!(true), + ); + for (message, expected) in [ + ( + "extension-secret", + None, + ), + ( + "nested-secret", + None, + ), + ( + "extension-secretvisible text", + Some("visible text"), + ), + ( + "nested-secret", + None, + ), + ( + "visible text", + Some("visible text"), + ), + ( + "extension-secretvisible text", + Some("visible text"), + ), + ] { + let doc = roxmltree::Document::parse(message).unwrap(); + let notifications = push + .notify( + dir.path(), + "example.test", + "alice", + doc.root_element(), + &settings, + ) + .unwrap(); + assert_eq!(notifications.len(), 1); + let xml = ¬ifications[0].1; + assert!( + !xml.contains("extension-secret") && !xml.contains("nested-secret"), + "foreign body leaked: {xml}" + ); + let result = roxmltree::Document::parse(xml).unwrap(); + let summary = result.descendants().find(|n| { + n.has_tag_name(("jabber:x:data", "field")) + && n.attribute("var") == Some("last-message-body") + }); + let body = summary + .and_then(|n| { + n.children() + .find(|n| n.has_tag_name(("jabber:x:data", "value"))) + }) + .and_then(|n| n.text()); + assert_eq!(body, expected); + assert_eq!( + rust_xmpp::push::is_important(doc.root_element()), + expected.is_some() + ); + } +} + +#[test] +#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"] +fn native_push_body_lookup_excludes_extensions() { + use std::io::Write; + use std::process::{Command, Stdio}; + let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap(); + let mut lua = Command::new(std::env::var("PROSODY_REFERENCE_LUA").unwrap()) + .args(["-", &source]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + lua.stdin.take().unwrap().write_all(br#" +local source=assert(arg[1]); +package.path=source..'/?.lua;'..package.path; +package.cpath=source..'/?.so;'..package.cpath; +dofile(source..'/loader.lua'); +local st=require 'prosody.util.stanza'; +-- mod_cloud_notify uses this real helper for notification bodies and importance. +local foreign=st.message({xmlns='jabber:client'}):tag('body',{xmlns='urn:private'}):text('extension-secret'):reset(); +assert(foreign:get_child_text('body') == nil); +local mixed=foreign:tag('body'):text('visible text'):reset(); +assert(mixed:get_child_text('body') == 'visible text'); +local nested=st.message({xmlns='jabber:client'}):tag('private',{xmlns='urn:private'}):tag('body'):text('nested-secret'):reset(); +assert(nested:get_child_text('body') == nil); +local embedded=st.message({xmlns='jabber:client'}):tag('body'):tag('private',{xmlns='urn:private'}):text('nested-secret'):reset(); +assert(embedded:get_child_text('body') == nil); +"#).unwrap(); + let output = lua.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/push_discovery.py b/tests/push_discovery.py new file mode 100644 index 0000000..9ff22e4 --- /dev/null +++ b/tests/push_discovery.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""TLS wire regression for XEP-0357 account discovery and registrations. + +Usage: python3 tests/push_discovery.py target/debug/rust-xmpp +Uses only Python's standard library and openssl; creates isolated server/data. +Actual notification serialization/delivery lifecycle is covered by tests/push.rs. +""" +import base64 +import json +from pathlib import Path +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +DISCO = 'http://jabber.org/protocol/disco#info' +PUSH = 'urn:xmpp:push:0' +STREAM = "" + + +class Client: + def __init__(self, port, cert): + self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) + self.buffer = b'' + self.send(STREAM) + self.until(b'') + self.send("") + assert b'proceed' in self.until(b'>') + self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock, server_hostname='localhost') + self.send(STREAM) + self.until(b'') + credentials = base64.b64encode(b'\0alice\0secret').decode() + self.send(f"{credentials}") + assert b'success' in self.until(b'>') + self.send(STREAM) + self.until(b'') + response = self.iq('bind', "phone", typ='set') + assert response.get('type') == 'result', ET.tostring(response) + + def send(self, xml): + self.sock.sendall(xml.encode()) + + def until(self, marker): + while marker not in self.buffer: + chunk = self.sock.recv(65536) + assert chunk, self.buffer + self.buffer += chunk + end = self.buffer.index(marker) + len(marker) + result, self.buffer = self.buffer[:end], self.buffer[end:] + return result + + def iq(self, identifier, payload, to=None, typ='get'): + target = f" to='{to}'" if to is not None else '' + self.send(f"{payload}") + while True: + response = ET.fromstring(self.until(b'')) + if response.get('id') == identifier: + return response + + def disco(self, identifier, to=None): + response = self.iq(identifier, f"", to) + assert response.get('type') == 'result', ET.tostring(response) + query = response.find('{' + DISCO + '}query') + assert query is not None, ET.tostring(response) + features = {node.get('var') for node in query.findall('{' + DISCO + '}feature')} + identities = {(node.get('category'), node.get('type')) for node in query.findall('{' + DISCO + '}identity')} + assert ('pubsub', 'push') not in identities, identities + return features, identities + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-push-disco-') 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) + for pep in (False, True): + for push in (False, True): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + data = root / f'data-pep{pep}-push{push}' + modules = ['tls', 'saslauth'] + (['pep'] if pep else []) + (['cloud_notify'] if push else []) + cfg = {'global': {'data_path': str(data), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': modules}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + 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, stdout=subprocess.DEVNULL) + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + client = None + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + client = Client(port, root/'cert.pem') + features, identities = client.disco('own-account', 'alice@localhost') + assert (PUSH in features) == push, (pep, push, features) + assert ('account', 'registered') in identities, identities + monal_pep_features = {'http://jabber.org/protocol/pubsub#' + name for name in ['filtered-notifications','publish-options','publish','subscribe','create-nodes','delete-items','delete-nodes','persistent-items','retrieve-items','auto-create']} + assert ((('pubsub','pep') in identities) and monal_pep_features <= features) == pep, (pep, features, identities) + if pep: + assert {'http://jabber.org/protocol/pubsub#item-ids','http://jabber.org/protocol/pubsub#multi-items','http://jabber.org/protocol/pubsub#config-node-max'} <= features + + # Legacy clients query no-to/domain as well. These remain server discovery. + for identifier, target in [('implicit-domain', None), ('domain', 'localhost')]: + _, identities = client.disco(identifier, target) + assert ('server', 'im') in identities, identities + other = client.iq('other-account', f"", 'bob@localhost') + assert all(node.get('var') != PUSH for node in other.iter('{' + DISCO + '}feature')), ET.tostring(other) + for node in ('phone', 'tablet'): + response = client.iq('enable-' + node, f"http://jabber.org/protocol/pubsub#publish-optionscredential-{node}", 'alice@localhost', 'set') + assert response.get('type') == ('result' if push else 'error'), ET.tostring(response) + if push: + stored = next(data.glob('*/cloud_notify/*.dat')) + text = stored.read_text() + assert 'phone' in text and 'tablet' in text and 'credential-phone' in text, text + response = client.iq('disable-one', f"", 'alice@localhost', 'set') + assert response.get('type') == 'result', ET.tostring(response) + text = stored.read_text() + assert 'push.example.test", 'alice@localhost', 'set') + assert response.get('type') == 'result', ET.tostring(response) + assert not stored.exists() or 'push.example.test' not in stored.read_text() + finally: + if client is not None: + client.sock.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + if sys.exc_info()[0] is not None: + log.seek(0) + print(log.read(), file=sys.stderr) + print('PASS: TLS account/domain discovery with PEP and push enabled/disabled; real push enable, publish options, per-node and service-wide disable') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/reference/COPYING b/tests/reference/COPYING new file mode 100644 index 0000000..3519dcc --- /dev/null +++ b/tests/reference/COPYING @@ -0,0 +1,44 @@ +All source code in this project is released under the below MIT license. Some +components are not authored by the Prosody maintainers, but such code is +itself either released under a MIT license or declared public domain. + +--- + +Copyright (C) 2008-2022 Matthew Wild +Copyright (C) 2008-2020 Waqas Hussain +Copyright (C) 2010-2022 Kim Alvefur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +util-src/encodings.c: + Parts included from Lua 5.3. Copyright (C) 1994-2015 Lua.org, PUC-Rio. + +util-src/signal.c: + Copyright (C) 2007 Patrick J. Donnelly (batrick@batbytes.com) + See full copyright notice in the source file. + +util-src/struct.c: + Copyright (C) 2010-2018 Lua.org, PUC-Rio. All rights reserved. + See full copyright notice in the source file. + +net/dns.lua: + public domain 20080404 lua@ztact.com + diff --git a/tests/reference/configmanager.lua b/tests/reference/configmanager.lua new file mode 100644 index 0000000..1d4b40d --- /dev/null +++ b/tests/reference/configmanager.lua @@ -0,0 +1,428 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local _G = _G; +local getmetatable, setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs = + getmetatable, setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs; +local format, math_max, t_insert = string.format, math.max, table.insert; + +local envload = require"prosody.util.envload".envload; +local deps = require"prosody.util.dependencies"; +local it = require"prosody.util.iterators"; +local resolve_relative_path = require"prosody.util.paths".resolve_relative_path; +local glob_to_pattern = require"prosody.util.paths".glob_to_pattern; +local path_sep = package.config:sub(1,1); +local get_traceback_table = require "prosody.util.debug".get_traceback_table; +local errors = require "prosody.util.error"; +local log = require "prosody.util.logger".init("config"); + +local encodings = deps.softreq"prosody.util.encodings"; +local nameprep = encodings and encodings.stringprep.nameprep or function (host) return host:lower(); end + +local _M = {}; +local _ENV = nil; +-- luacheck: std none + +_M.resolve_relative_path = resolve_relative_path; -- COMPAT + +local parser = nil; + +local config_mt = { __index = function (t, _) return rawget(t, "*"); end}; +local config = setmetatable({ ["*"] = { } }, config_mt); +local files = {}; +local credentials_directory = nil; +local credential_fallback_fatal = true; + +-- When host not found, use global +local host_mt = { __index = function(_, k) return config["*"][k] end } + +function _M.getconfig() + return config; +end + +function _M.get(host, key) + local v = config[host][key]; + if v and errors.is_error(v) then + log("warn", "%s:%d: %s", v.context.filename, v.context.fileline, v.text); + return nil; + end + return v; +end +function _M.rawget(host, key) + local hostconfig = rawget(config, host); + if hostconfig then + return rawget(hostconfig, key); + end +end + +local function set(config_table, host, key, value) + if host and key then + local hostconfig = rawget(config_table, host); + if not hostconfig then + hostconfig = rawset(config_table, host, setmetatable({}, host_mt))[host]; + end + hostconfig[key] = value; + return true; + end + return false; +end + +local function rawget_option(config_table, host, key) + if host and key then + local hostconfig = rawget(config_table, host); + if not hostconfig then + return nil; + end + return rawget(hostconfig, key); + end +end + +function _M.set(host, key, value) + return set(config, host, key, value); +end + +function _M.load(filename, config_format) + config_format = config_format or filename:match("%w+$"); + + if config_format == "lua" then + local f, err, errno = io.open(filename); + if f then + local new_config = setmetatable({ ["*"] = { } }, config_mt); + local ok, err = parser.load(f:read("*a"), filename, new_config); + f:close(); + if ok then + config = new_config; + end + return ok, "parser", err; + end + return f, "file", err, errno; + end + + if not config_format then + return nil, "file", "no parser specified"; + else + return nil, "file", "no parser for "..(config_format); + end +end + +function _M.files() + return files; +end + +-- Built-in Lua parser +do + local pcall = _G.pcall; + local function get_line_number(config_file) + local tb = get_traceback_table(nil, 2); + for i = 1, #tb do + if tb[i].info.short_src == config_file then + return tb[i].info.currentline; + end + end + end + + local config_option_proxy_mt = { + __index = setmetatable({ + append = function (self, value) + local original_option = self:value(); + if original_option == nil then + original_option = {}; + end + if type(value) ~= "table" then + error("'append' operation expects a list of values to append to the existing list", 2); + end + if value[1] ~= nil then + for _, v in ipairs(value) do + t_insert(original_option, v); + end + else + for k, v in pairs(value) do + original_option[k] = v; + end + end + set(self.config_table, self.host, self.option_name, original_option); + return self; + end; + value = function (self) + return rawget_option(self.config_table, self.host, self.option_name); + end; + values = function (self) + return it.values(self:value()); + end; + }, { + __index = function (t, k) --luacheck: ignore 212/t + error("Unknown config option operation: '"..k.."'", 2); + end; + }); + + __call = function (self, v2) + local v = self:value() or {}; + if type(v) == "table" and type(v2) == "table" then + return self:append(v2); + end + + error("Invalid syntax - missing '=' perhaps?", 2); + end; + }; + local function is_config_option_proxy(v) + return getmetatable(v) == config_option_proxy_mt; + end + + -- For reading config values out of files. + local function filereader(basepath, defaultmode) + return function(filename, mode) + local f, err = io.open(resolve_relative_path(basepath, filename)); + if not f then error(err, 2); end + local content, err = f:read(mode or defaultmode); + f:close(); + if not content then error(err, 2); end + return content; + end + end + + -- Collect lines into an array + local function linereader(basepath) + return function(filename) + local ret = {}; + for line in io.lines(resolve_relative_path(basepath, filename)) do + t_insert(ret, line); + end + return ret; + end + end + + parser = {}; + function parser.load(data, config_file, config_table) + local set_options = {}; -- set_options[host.."/"..option_name] = true (when the option has been set already in this file) + local warnings = {}; + local env; + local config_path = config_file:gsub("[^"..path_sep.."]+$", ""); + + -- The ' = true' are needed so as not to set off __newindex when we assign the functions below + env = setmetatable({ + Host = true, host = true, VirtualHost = true, + Component = true, component = true, + FileContents = true, + FileLine = true, + FileLines = true, + Credential = true, + Include = true, include = true, RunScript = true }, { + __index = function (_, k) + if k:match("^ENV_") then + return os.getenv(k:sub(5)); + end + if k == "Lua" then + return _G; + end + local val = rawget_option(config_table, env.__currenthost or "*", k); + + local g_val = rawget(_G, k); + + if val ~= nil or g_val == nil then + if type(val) == "table" then + return setmetatable({ + config_table = config_table; + host = env.__currenthost or "*"; + option_name = k; + }, config_option_proxy_mt); + elseif val == nil then + t_insert( + warnings, + ("%s: %d: unrecognized value: %s (you may be missing quotes around it)"):format( + config_file, + get_line_number(config_file), + k + ) + ); + end + + return val; + end + + if g_val ~= nil then + t_insert( + warnings, + ("%s:%d: direct usage of the Lua API is deprecated - replace `%s` with `Lua.%s`"):format( + config_file, + get_line_number(config_file), + k, + k + ) + ); + end + + return g_val; + end, + __newindex = function (_, k, v) + local host = env.__currenthost or "*"; + local option_path = host.."/"..k; + if set_options[option_path] then + t_insert(warnings, ("%s:%d: Duplicate option '%s'"):format(config_file, get_line_number(config_file), k)); + end + set_options[option_path] = true; + if is_config_option_proxy(v) then + setmetatable(v, nil); + end + set(config_table, env.__currenthost or "*", k, v); + end + }); + + rawset(env, "__currenthost", "*") -- Default is global + function env.VirtualHost(name) + if not name then + error("Host must have a name", 2); + end + local prepped_name = nameprep(name); + if not prepped_name then + error(format("Name of Host %q contains forbidden characters", name), 0); + end + name = prepped_name; + if rawget(config_table, name) and rawget(config_table[name], "component_module") then + error(format("Host %q clashes with previously defined %s Component %q, for services use a sub-domain like conference.%s", + name, config_table[name].component_module:gsub("^%a+$", { component = "external", muc = "MUC"}), name, name), 0); + end + rawset(env, "__currenthost", name); + -- Needs at least one setting to logically exist :) + set(config_table, name or "*", "defined", true); + return function (config_options) + rawset(env, "__currenthost", "*"); -- Return to global scope + if type(config_options) == "string" then + error(format("VirtualHost entries do not accept a module name (module '%s' provided for host '%s')", config_options, name), 2); + elseif type(config_options) ~= "table" then + error("Invalid syntax following VirtualHost, expected options but received a "..type(config_options), 2); + end + for option_name, option_value in pairs(config_options) do + set(config_table, name or "*", option_name, option_value); + end + end; + end + env.Host, env.host = env.VirtualHost, env.VirtualHost; + + function env.Component(name) + if not name then + error("Component must have a name", 2); + end + local prepped_name = nameprep(name); + if not prepped_name then + error(format("Name of Component %q contains forbidden characters", name), 0); + end + name = prepped_name; + if rawget(config_table, name) and rawget(config_table[name], "defined") + and not rawget(config_table[name], "component_module") then + error(format("Component %q clashes with previously defined VirtualHost %q, for services use a sub-domain like conference.%s", + name, name, name), 0); + end + set(config_table, name, "component_module", "component"); + -- Don't load the global modules by default + set(config_table, name, "load_global_modules", false); + rawset(env, "__currenthost", name); + local function handle_config_options(config_options) + rawset(env, "__currenthost", "*"); -- Return to global scope + for option_name, option_value in pairs(config_options) do + set(config_table, name or "*", option_name, option_value); + end + end + + return function (module) + if type(module) == "string" then + set(config_table, name, "component_module", module); + return handle_config_options; + end + return handle_config_options(module); + end + end + env.component = env.Component; + + function env.Include(file) + -- Check whether this is a wildcard Include + if file:match("[*?]") then + local lfs = deps.softreq "lfs"; + if not lfs then + error(format("Error expanding wildcard pattern in Include %q - LuaFileSystem not available", file)); + end + local path_pos, glob = file:match("()([^"..path_sep.."]+)$"); + local path = file:sub(1, math_max(path_pos-2,0)); + if #path > 0 then + path = resolve_relative_path(config_path, path); + else + path = config_path; + end + local patt = glob_to_pattern(glob); + for f in lfs.dir(path) do + if f:sub(1,1) ~= "." and f:match(patt) then + env.Include(path..path_sep..f); + end + end + return; + end + -- Not a wildcard, so resolve (potentially) relative path and run through config parser + file = resolve_relative_path(config_path, file); + local f, err = io.open(file); + if f then + local ret, err = parser.load(f:read("*a"), file, config_table); + if not ret then error(err:gsub("%[string.-%]", file), 0); end + if err then + for _, warning in ipairs(err) do + t_insert(warnings, warning); + end + end + end + if not f then error("Error loading included "..file..": "..err, 0); end + return f, err; + end + env.include = env.Include; + + function env.RunScript(file) + return dofile(resolve_relative_path(config_path, file)); + end + + env.FileContents = filereader(config_path, "*a"); + env.FileLine = filereader(config_path, "*l"); + env.FileLines = linereader(config_path); + + if credentials_directory then + env.Credential = filereader(credentials_directory, "*a"); + elseif credential_fallback_fatal then + env.Credential = function() error("Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set", 2) end + else + env.Credential = function() + return errors.new({ + type = "continue"; + text = "Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set"; + }, { filename = config_file; fileline = get_line_number(config_file) }); + end + end + + local chunk, err = envload(data, "@"..config_file, env); + + if not chunk then + return nil, err; + end + + local ok, err = pcall(chunk); + + if not ok then + return nil, err; + end + + t_insert(files, config_file); + + return true, warnings; + end + +end + +function _M.set_credentials_directory(directory) + credentials_directory = directory; +end + +function _M.set_credential_fallback_mode(mode) + credential_fallback_fatal = mode == "error"; +end + +return _M; diff --git a/tests/reference_prosody.rs b/tests/reference_prosody.rs new file mode 100644 index 0000000..4f9eefd --- /dev/null +++ b/tests/reference_prosody.rs @@ -0,0 +1,102 @@ +//! Real upstream implementation oracle. Requires a built exact Prosody source and Lua 5.4. +use rust_xmpp::{ + archive, auth, + storage::{self, LuaValue as V}, +}; +use std::{path::Path, process::Command}; +fn oracle(root: &Path, phase: &str, hash: &str) { + let src = std::env::var("PROSODY_REFERENCE_SOURCE") + .expect("set PROSODY_REFERENCE_SOURCE to built source"); + let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or_else(|_| "lua5.4".into()); + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/prosody_compat/oracle.lua"); + let output = Command::new(lua) + .args([ + script.to_str().unwrap(), + &src, + root.to_str().unwrap(), + phase, + hash, + ]) + .output() + .expect("run actual Lua interpreter"); + assert!( + output.status.success(), + "native Prosody oracle failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + print!("{}", String::from_utf8_lossy(&output.stdout)); +} +#[test] +#[ignore = "requires native supplied Prosody source; run scripts/prosody_compat/verify.sh"] +fn real_prosody_storage_and_auth_roundtrip() { + for hash in ["SHA-1", "SHA-256"] { + let t = tempfile::tempdir().unwrap(); + let root = t.path(); + let host = "example.org"; + let mut v = V::default(); + v.set_key(V::Bool(false), "roster".into()); + v.set_key(V::Integer(3), "sparse".into()); + v.set("binary", V::String(vec![0, 1, 10, 255])); + v.set("text", "Привет & ".into()); + storage::save(root, host, Some("alice"), "oracle", &v).unwrap(); + let mut prefs = V::default(); + prefs.set_key(V::Bool(false), "roster".into()); + storage::save(root, host, Some("alice"), "archive_prefs", &prefs).unwrap(); + auth::create(root, host, "alice", "пароль-Original", hash).unwrap(); + for (i, id) in ["rust-one", "rust-two"].iter().enumerate() { + archive::append_new(root,host,Some("alice"),"archive",archive::Entry{id:id.to_string(),when:1767225600.+i as f64,peer:"bob@example.org".into(),xml:"Rust body".into()}).unwrap(); + } + oracle(root, "roundtrip", hash); + let v = storage::load(root, host, Some("alice"), "oracle") + .unwrap() + .unwrap(); + assert_eq!(v.get("binary"), Some(&V::String(vec![0, 1, 10, 255]))); + assert_eq!( + v.get("native").unwrap().get("number"), + Some(&V::Number(1.25)) + ); + let prefs = storage::load(root, host, Some("alice"), "archive_prefs") + .unwrap() + .unwrap(); + assert_eq!(prefs.get_key(&V::Bool(false)), Some(&V::Bool(false))); + assert!(auth::verify(root, host, "alice", "пароль-Native", hash).unwrap()); + assert!(!auth::verify(root, host, "alice", "пароль-Original", hash).unwrap()); + assert!(auth::verify(root, host, "bob", "native-bob", hash).unwrap()); + assert!(auth::verify(root, host, "binary", "binary-password", hash).unwrap()); + let p = storage::path(root, host, Some("alice"), "archive").with_extension("lidx"); + assert!(p.exists(), "original Prosody must create real binary index"); + let rows = archive::load(root, host, "alice").unwrap(); + assert_eq!( + rows.iter().map(|v| v.id.as_str()).collect::>(), + vec!["rust-one", "native-three", "rust-two"] + ); + let native = roxmltree::Document::parse(&rows[1].xml).unwrap(); + assert_eq!( + native.root_element().attribute(("urn:oracle", "flag")), + Some("native") + ); + assert!(rows[2].xml.contains("Replaced by native")); + storage::update(root, host, Some("alice"), "oracle", |v| { + v.set("rust_final", V::Bool(true)); + Ok(()) + }) + .unwrap(); + auth::set_password(root, host, "bob", "rust-bob-new", hash).unwrap(); + archive::append_new( + root, + host, + Some("alice"), + "archive", + archive::Entry { + id: "rust-final".into(), + when: 1767225604., + peer: "bob@example.org".into(), + xml: "Final".into(), + }, + ) + .unwrap(); + assert!(!p.exists()); + oracle(root, "verify-final", hash); + } +} diff --git a/tests/resource_rebind.py b/tests/resource_rebind.py new file mode 100644 index 0000000..233c5d7 --- /dev/null +++ b/tests/resource_rebind.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Isolated TLS resource-conflict and stream-management replacement regression.""" +import json +import socket +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path +import xml.etree.ElementTree as ET + +from _wire_client import BIN, Client, S + +BIND = 'urn:ietf:params:xml:ns:xmpp-bind' +SM = 'urn:xmpp:sm:3' +STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas' +STREAMS = 'urn:ietf:params:xml:ns:xmpp-streams' + + +def bind(client, resource='phone', error=False): + client.send(f"{resource}") + result = client.iq('bind', error=error) + if error: + assert result.find(f'{{jabber:client}}error/{{{STANZAS}}}conflict') is not None, ET.tostring(result) + return None + jid = result.find(f'{{{BIND}}}bind/{{{BIND}}}jid') + assert jid is not None and jid.text, ET.tostring(result) + client.jid = jid.text + return jid.text + + +def enable_resume(client): + client.send(f"") + enabled = client.take(lambda n: n.tag == f'{{{SM}}}enabled') + assert enabled.get('resume') in ('true', '1'), ET.tostring(enabled) + assert enabled.get('id'), ET.tostring(enabled) + return enabled.get('id') + + +def resume(client, token, invalid): + client.send(f"") + result = client.take(lambda n: n.tag in (f'{{{SM}}}failed', f'{{{SM}}}resumed')) + if invalid: + assert result.tag == f'{{{SM}}}failed', ET.tostring(result) + assert result.find(f'{{{STANZAS}}}item-not-found') is not None, ET.tostring(result) + else: + assert result.tag == f'{{{SM}}}resumed' and result.get('previd') == token, ET.tostring(result) + client.sync() + + +def check_policy(port, cert, policy, clients): + def connect(user): + client = Client(port, cert, user, user + '-secret', bind=False) + clients.append(client) + return client + + if policy is None: + old = connect('carol') + assert bind(old) == 'carol@localhost/phone' + token = enable_resume(old) + # Resume can take over a transport which has not noticed its loss yet. + new = connect('carol') + resume(new, token, invalid=False) + conflict = old.take(lambda n: n.tag == f'{{{S}}}error') + assert conflict.find(f'{{{STREAMS}}}conflict') is not None, ET.tostring(conflict) + old.close() + new.sync() + time.sleep(.05) + new.sync() + + for sleeping, user in [(False, 'alice'), (True, 'bob')]: + old = connect(user) + expected = f'{user}@localhost/phone' + assert bind(old) == expected + old.sync() + token = enable_resume(old) + if sleeping: + # An abrupt TLS transport loss leaves the resumable resource reserved. + old.close() + time.sleep(.2) + + new = connect(user) + replacing = policy in (None, 'kick_old') + assigned = bind(new, error=policy == 'kick_new') + if replacing: + assert assigned == expected, assigned + if not sleeping: + conflict = old.take(lambda n: n.tag == f'{{{S}}}error') + assert conflict.find(f'{{{STREAMS}}}conflict') is not None, ET.tostring(conflict) + old.close() + # Closing the displaced transport must not remove the new session. + new.sync() + time.sleep(.05) + new.sync() + rejected = connect(user) + resume(rejected, token, invalid=True) + new.sync() + elif policy == 'kick_new': + if sleeping: + survivor = connect(user) + resume(survivor, token, invalid=False) + else: + old.sync() + # The rejected stream remains usable and can bind another resource. + assert bind(new, 'tablet') == f'{user}@localhost/tablet' + new.sync() + else: + if policy == 'increment': + assert assigned == expected + '#1', assigned + else: + resource = assigned.split('/', 1)[1] + assert assigned.startswith(f'{user}@localhost/') and resource != 'phone', assigned + assert str(uuid.UUID(resource)) == resource, assigned + new.sync() + another = connect(user) + second = bind(another) + if policy == 'increment': + assert second == expected + '#2', second + else: + assert second not in (expected, assigned), second + assert str(uuid.UUID(second.split('/', 1)[1])) == second.split('/', 1)[1], second + another.sync() + # Keeping the original resource includes keeping its resumable session. + if sleeping: + survivor = connect(user) + resume(survivor, token, invalid=False) + else: + old.sync() + + +def run_policy(policy): + # Same isolated certificate/config/account/server fixture as preauth_network.py. + with tempfile.TemporaryDirectory(prefix='xmpp-rebind-') 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] + options = {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', + 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}} + if policy is not None: + options['conflict_resolve'] = policy + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], + 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], + 'modules_enabled': ['tls', 'saslauth', 'ping', 'smacks']}, + 'hosts': {'localhost': {'options': options}}} + config = root/'config.json' + config.write_text(json.dumps(cfg)) + for user in ['alice', 'bob', 'carol']: + subprocess.run([str(BIN), 'create-user', str(config), user, '--password-stdin'], + input=user + '-secret\n', text=True, check=True, capture_output=True) + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([str(BIN), 'serve', str(config), '--experimental'], stdout=log, stderr=log) + clients = [] + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + check_policy(port, root/'cert.pem', policy, clients) + print(f'PASS: TLS conflict_resolve={policy or "default"}, live and hibernating SM resources') + except BaseException: + log.flush() + log.seek(0) + print(log.read()) + raise + finally: + for client in clients: + client.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +if __name__ == '__main__': + if len(sys.argv) > 1: + BIN = Path(sys.argv[1]).resolve() + for policy in [None, 'kick_old', 'kick_new', 'increment', 'random']: + run_policy(policy) diff --git a/tests/resource_sessions.rs b/tests/resource_sessions.rs new file mode 100644 index 0000000..392fc47 --- /dev/null +++ b/tests/resource_sessions.rs @@ -0,0 +1,230 @@ +use rust_xmpp::sessions::{ConflictPolicy, Hub}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; +use tokio::sync::mpsc; + +const JID: &str = "alice@example.test/Monal"; +const TTL: Duration = Duration::from_secs(300); + +#[test] +fn concurrent_default_binds_replace_atomically() { + let hub = Hub::default(); + let barrier = Arc::new(Barrier::new(16)); + let handles: Vec<_> = (0..16) + .map(|i| { + let hub = hub.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let (tx, _rx) = mpsc::channel(8); + barrier.wait(); + hub.bind_resolving( + JID, + &format!("connection-{i}"), + tx, + TTL, + ConflictPolicy::from_config(None), + ) + .unwrap() + }) + }) + .collect(); + let outcomes: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert_eq!(hub.all().len(), 1); + assert_eq!(outcomes.iter().filter(|(_, old)| old.is_some()).count(), 15); + let current = hub.get(JID).unwrap(); + for (session, replaced) in outcomes { + let state = session.lock().unwrap(); + assert_eq!(state.closed, !Arc::ptr_eq(&session, ¤t)); + if let Some(old) = replaced { + let old = old.lock().unwrap(); + assert!(old.closed); + assert!(old.sender.is_none()); + assert!(old.resume_id.is_none()); + assert!(old.expires.is_none()); + } + } +} + +#[test] +fn kick_new_leaves_existing_session_unchanged() { + let hub = Hub::default(); + let (tx, mut rx) = mpsc::channel(8); + let old = hub.bind(JID, "old", tx, TTL).unwrap(); + let id = { + let mut state = old.lock().unwrap(); + state.enable(true).unwrap(); + state.resume_id.clone().unwrap() + }; + let (tx, _rx) = mpsc::channel(8); + assert!( + hub.bind_resolving(JID, "new", tx, TTL, ConflictPolicy::KickNew) + .is_err() + ); + assert!(Arc::ptr_eq(&old, &hub.get(JID).unwrap())); + let mut state = old.lock().unwrap(); + assert!(!state.closed); + assert_eq!(state.connection, "old"); + assert_eq!(state.resume_id.as_deref(), Some(id.as_str())); + state.transmit("still-connected".into()).unwrap(); + assert_eq!(rx.try_recv().unwrap(), "still-connected"); +} + +#[test] +fn increment_skips_occupied_suffixes_and_preserves_sessions() { + let hub = Hub::default(); + for suffix in ["", "#1", "#2", "#4"] { + let (tx, _rx) = mpsc::channel(8); + hub.bind(&format!("{JID}{suffix}"), suffix, tx, TTL) + .unwrap(); + } + let (tx, _rx) = mpsc::channel(8); + let (session, replaced) = hub + .bind_resolving(JID, "new", tx, TTL, ConflictPolicy::Increment) + .unwrap(); + assert_eq!(session.lock().unwrap().jid, format!("{JID}#3")); + assert!(replaced.is_none()); + assert_eq!(hub.all().len(), 5); + assert!(hub.all().iter().all(|s| !s.lock().unwrap().closed)); +} + +#[test] +fn random_conflicts_get_distinct_resources_for_same_owner() { + let hub = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let original = hub.bind(JID, "original", tx, TTL).unwrap(); + let mut names = std::collections::BTreeSet::new(); + names.insert(JID.to_owned()); + for i in 0..32 { + let (tx, _rx) = mpsc::channel(8); + let (session, replaced) = hub + .bind_resolving(JID, &format!("new-{i}"), tx, TTL, ConflictPolicy::Random) + .unwrap(); + let state = session.lock().unwrap(); + assert_eq!(state.owner, "alice@example.test"); + assert!(names.insert(state.jid.clone())); + assert!(replaced.is_none()); + } + assert!(Arc::ptr_eq(&original, &hub.get(JID).unwrap())); + assert_eq!(hub.all().len(), 33); +} + +#[tokio::test] +async fn active_resume_preserves_state_and_replaces_transport_notifier() { + let hub = Hub::default(); + let (tx, _old_rx) = mpsc::channel(8); + let original = hub.bind(JID, "old", tx, TTL).unwrap(); + let (id, old_cancel) = { + let mut state = original.lock().unwrap(); + state.enable(true).unwrap(); + state.handled = 7; + state.client_id = Some("device".into()); + state.transmit("one".into()).unwrap(); + state.transmit("two".into()).unwrap(); + (state.resume_id.clone().unwrap(), state.cancel.clone()) + }; + let (tx, mut new_rx) = mpsc::channel(8); + let (resumed, replay) = hub.resume("alice@example.test", &id, 1, "new", tx).unwrap(); + assert!(!Arc::ptr_eq(&original, &resumed)); + assert!(original.lock().unwrap().closed); + assert_eq!(replay.len(), 2); + assert!(replay[0].contains("h='7'")); + assert_eq!(replay[1], "two"); + { + let mut retired = original.lock().unwrap(); + assert!(retired.acknowledge(2).is_err()); + assert!(retired.transmit("stale".into()).is_err()); + // Even in-flight code holding the retired Arc cannot touch the new counters. + retired.handled = 999; + retired.pending.push_back("stale".into()); + } + let new_cancel = { + let state = resumed.lock().unwrap(); + assert_eq!((state.handled, state.sent, state.acked), (7, 2, 1)); + assert_eq!( + state.pending.iter().map(String::as_str).collect::>(), + vec!["two"] + ); + assert_eq!(state.client_id.as_deref(), Some("device")); + assert_eq!(state.resume_id.as_deref(), Some(id.as_str())); + assert_eq!(state.connection, "new"); + assert!(state.expires.is_none()); + assert!(!state.closed); + assert!(!Arc::ptr_eq(&old_cancel, &state.cancel)); + state.cancel.clone() + }; + tokio::time::timeout(Duration::from_secs(1), old_cancel.notified()) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(10), new_cancel.notified()) + .await + .is_err() + ); + resumed.lock().unwrap().transmit("three".into()).unwrap(); + assert_eq!(new_rx.try_recv().unwrap(), "three"); + for graceful in [false, true] { + assert!(hub.disconnect("old", graceful).is_empty()); + assert!(Arc::ptr_eq(&resumed, &hub.get(JID).unwrap())); + } +} + +#[test] +fn invalid_resume_owner_or_ack_does_not_evict_active_session() { + let hub = Hub::default(); + let (tx, mut old_rx) = mpsc::channel(8); + let original = hub.bind(JID, "old", tx, TTL).unwrap(); + let (id, cancel) = { + let mut state = original.lock().unwrap(); + state.enable(true).unwrap(); + state.transmit("one".into()).unwrap(); + (state.resume_id.clone().unwrap(), state.cancel.clone()) + }; + for (owner, h) in [("mallory@example.test", 0), ("alice@example.test", 2)] { + let (tx, _rx) = mpsc::channel(8); + assert!(hub.resume(owner, &id, h, "intruder", tx).is_err()); + let state = original.lock().unwrap(); + assert_eq!(state.connection, "old"); + assert_eq!((state.sent, state.acked, state.pending.len()), (1, 0, 1)); + assert!(Arc::ptr_eq(&cancel, &state.cancel)); + assert!(!state.closed); + assert!(Arc::ptr_eq(&original, &hub.get(JID).unwrap())); + } + assert_eq!(old_rx.try_recv().unwrap(), "one"); + original.lock().unwrap().transmit("two".into()).unwrap(); + assert_eq!(old_rx.try_recv().unwrap(), "two"); +} + +#[test] +fn replacement_revokes_hibernating_resume_and_survives_old_cleanup() { + let hub = Hub::default(); + let (tx, _rx) = mpsc::channel(8); + let old = hub.bind(JID, "old", tx, TTL).unwrap(); + let id = { + let mut state = old.lock().unwrap(); + state.enable(true).unwrap(); + state.resume_id.clone().unwrap() + }; + assert!(hub.disconnect("old", false).is_empty()); + assert!(old.lock().unwrap().expires.is_some()); + let (tx, _rx) = mpsc::channel(8); + let (new, replaced) = hub + .bind_resolving(JID, "new", tx, TTL, ConflictPolicy::KickOld) + .unwrap(); + assert!(Arc::ptr_eq(&old, &replaced.unwrap())); + { + let state = old.lock().unwrap(); + assert!(state.closed); + assert!(state.resume_id.is_none()); + assert!(state.expires.is_none()); + } + for graceful in [false, true] { + assert!(hub.disconnect("old", graceful).is_empty()); + assert!(Arc::ptr_eq(&new, &hub.get(JID).unwrap())); + } + let (tx, _rx) = mpsc::channel(8); + assert!( + hub.resume("alice@example.test", &id, 0, "resurrect", tx) + .is_err() + ); + assert!(Arc::ptr_eq(&new, &hub.get(JID).unwrap())); +} diff --git a/tests/sasl2_bind.py b/tests/sasl2_bind.py new file mode 100644 index 0000000..942350b --- /dev/null +++ b/tests/sasl2_bind.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Independent TLS/SASL2 Bind2 network regression. Usage: python3 tests/sasl2_bind.py BINARY.""" +import base64 +import json +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import uuid +import xml.etree.ElementTree as ET +from pathlib import Path + +SASL2 = 'urn:xmpp:sasl:2' +BIND2 = 'urn:xmpp:bind:0' +SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' +STREAM = "" + + +class Client: + def __init__(self, port, cert): + self.sock = socket.create_connection(('127.0.0.1', port), timeout=5) + self.buffer = b'' + self.send(STREAM) + self.until(b'') + self.send("") + assert b'proceed' in self.until(b'/>') + self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock, server_hostname='localhost') + self.send(STREAM) + features = self.until(b'') + features = features[features.index(b'" + features + b'') + bind = root.find('.//{' + SASL2 + '}authentication/{' + SASL2 + '}inline/{' + BIND2 + '}bind') + assert bind is not None, features + advertised = {node.get('var') for node in bind.findall('{' + BIND2 + '}inline/{' + BIND2 + '}feature')} + assert advertised == {'urn:xmpp:carbons:2', 'urn:xmpp:csi:0', 'urn:xmpp:sm:3'}, advertised + + def send(self, xml): + self.sock.sendall(xml.encode()) + + def until(self, marker): + while marker not in self.buffer: + chunk = self.sock.recv(65536) + assert chunk, self.buffer + self.buffer += chunk + end = self.buffer.index(marker) + len(marker) + result, self.buffer = self.buffer[:end], self.buffer[end:] + return result + + def auth(self, bind='', password='secret', good=True, user='alice', agent='private-device-id'): + payload = base64.b64encode(('\0' + user + '\0' + password).encode()).decode() + self.send(f"{payload}{bind}") + return ET.fromstring(self.until(b'' if good else b'')) + + def close(self): + self.sock.close() + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-bind2-') as tmp: + root = Path(tmp) + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + 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) + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'mam', 'carbons', 'csi_simple', 'smacks']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + config.write_text(json.dumps(cfg)) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL) + subprocess.run([binary, 'create-user', str(config), 'bob', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL) + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + resources = [] + for _ in range(2): + c = Client(port, root/'cert.pem') + try: + response = c.auth(f"Phone&Tablet") + jid = response.find('{' + SASL2 + '}authorization-identifier').text + assert jid.startswith('alice@localhost/Phone&Tablet/'), jid + assert 'private-device-id' not in jid + resource = jid.split('/Phone&Tablet/')[1] + assert uuid.UUID(resource).version == 4 + resources.append(resource) + bound = response.find('{' + BIND2 + '}bound') + assert bound is not None + assert bound.find('{urn:xmpp:mam:2}metadata') is not None + assert bound.find('{urn:xmpp:carbons:2}enabled') is not None + assert bound.find('{urn:xmpp:sm:3}enabled').get('resume') == 'true' + assert b'xmpp-bind' not in c.until(b'') + # No legacy bind IQ: the session is already usable and SM enabled. + c.send("") + assert ET.fromstring(c.until(b'/>')).tag == '{urn:xmpp:sm:3}a' + finally: + c.close() + assert resources[0] != resources[1] + invalid = [f"", f"ab", f"{'x'*987}", f"bad\ue000", f"", f""] + for request in invalid: + c = Client(port, root/'cert.pem') + try: + response = c.auth(request, good=False) + assert response.find('{' + SASL + '}malformed-request') is not None, ET.tostring(response) + # Malformed inline requests cannot leave a partially bound session. + response = c.auth() + assert response.find('{' + BIND2 + '}bound') is None + assert response.find('{' + SASL2 + '}authorization-identifier').text == 'alice@localhost' + assert b'xmpp-bind' in c.until(b'') + finally: + c.close() + c = Client(port, root/'cert.pem') + try: + response = c.auth(f"", password='wrong', good=False) + assert response.find('{' + SASL + '}not-authorized') is not None + assert response.find('{' + BIND2 + '}bound') is None + response = c.auth(f"") + jid = response.find('{' + SASL2 + '}authorization-identifier').text + assert uuid.UUID(jid.split('/', 1)[1]).version == 4 + finally: + c.close() + # Device replacement affects only this account and valid UUIDv4 ids. + connections = [] + device = 'd4565fa7-4d72-4749-b3d3-740edbf87770' + def bound_client(user, agent): + client = Client(port, root/'cert.pem') + connections.append(client) + response = client.auth(f"", user=user, agent=agent) + jid = response.find('{' + SASL2 + '}authorization-identifier').text + assert agent not in jid + client.until(b'') + return client + def ping(client): + client.send("") + assert ET.fromstring(client.until(b'/>')).tag == '{urn:xmpp:sm:3}a' + try: + other = bound_client('bob', device) + old = bound_client('alice', device.upper()) + invalid_first = bound_client('alice', 'not-a-uuid') + invalid_second = bound_client('alice', 'not-a-uuid') + ping(invalid_first) + fresh = bound_client('alice', device) + old.sock.settimeout(2) + old.until(b'') + assert old.sock.recv(1) == b'', 'old device stream remained open' + for client in [fresh, other, invalid_first, invalid_second]: + ping(client) + finally: + for client in connections: + client.close() + print('PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback') + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + if server.returncode not in (0, -15): + log.seek(0) + print(log.read(), file=sys.stderr) + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/sasl2_cached_inline.py b/tests/sasl2_cached_inline.py new file mode 100644 index 0000000..79a7a5f --- /dev/null +++ b/tests/sasl2_cached_inline.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Cached Conversations/monocles SASL2 inline requests over real STARTTLS. + +Usage: python3 tests/sasl2_cached_inline.py BINARY [--expect-070-failure] +Independent SCRAM-SHA-1 proof and server-signature validation; no client +assertion that current features advertise the previously cached Bind2 feature. +""" +import base64 +import hashlib +import hmac +import json +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +from android_sasl_network import Connection, SASL, SASL2, b64 + +BIND2 = 'urn:xmpp:bind:0' +SM = 'urn:xmpp:sm:3' +CB = 'urn:xmpp:sasl-cb:0' +CACHED_BIND = (f"monocles chat" + "" + f"") +CACHED_RESUME = f"" + + +def auth(client, mechanism, inline, password='secret', ssdp=False): + parent = client.features.find('.//{' + SASL2 + '}authentication') + mechanisms = [n.text for n in parent.findall('{' + SASL2 + '}mechanism')] + assert mechanism in mechanisms, mechanisms + if mechanism == 'PLAIN': + initial = b64(('\0alice\0' + password).encode()) + else: + gs2 = 'n,,' + bare = 'n=alice,r=independent-cached-inline-nonce' + initial = b64((gs2 + bare).encode()) + client.send(f"" + f"{initial}" + "" + "monocles chat" + f"{inline}") + result = client.stanza() + if mechanism == 'PLAIN' or result.tag != '{' + SASL2 + '}challenge': + return result + first = base64.b64decode(result.text).decode() + attrs = dict(item.split('=', 1) for item in first.split(',')) + if ssdp: + bindings = [n.get('type') for n in client.features.findall('.//{' + CB + '}channel-binding')] + protected = '\x1e'.join(sorted(mechanisms)) + '\x1f' + '\x1e'.join(sorted(bindings)) + assert attrs['h'] == b64(hashlib.sha1(protected.encode()).digest()) + else: + assert 'h' not in attrs + assert attrs['r'].startswith('independent-cached-inline-nonce') + assert int(attrs['i']) >= 4096 + final = 'c=' + b64(gs2.encode()) + ',r=' + attrs['r'] + salted = hashlib.pbkdf2_hmac('sha1', password.encode(), base64.b64decode(attrs['s']), int(attrs['i'])) + mac = lambda key, data: hmac.new(key, data, hashlib.sha1).digest() + key = mac(salted, b'Client Key') + transcript = (bare + ',' + first + ',' + final).encode() + signature = mac(hashlib.sha1(key).digest(), transcript) + proof = bytes(a ^ b for a, b in zip(key, signature)) + client.send(f"{b64((final + ',p=' + b64(proof)).encode())}") + result = client.stanza() + if result.tag == '{' + SASL2 + '}success': + expected = 'v=' + b64(mac(mac(salted, b'Server Key'), transcript)) + assert base64.b64decode(result.findtext('{' + SASL2 + '}additional-data')).decode() == expected + return result + + +def finish(client, result, bound): + assert result.tag == '{' + SASL2 + '}success', ET.tostring(result) + jid = result.findtext('{' + SASL2 + '}authorization-identifier') + assert (result.find('{' + BIND2 + '}bound') is not None) == bound + features = client.until(b'') + if bound: + assert jid.startswith('alice@localhost/monocles chat/'), jid + assert b'xmpp-bind' not in features + else: + assert jid == 'alice@localhost', jid + assert b'xmpp-bind' in features + client.send("cached-fallback") + reply = client.stanza() + assert reply.get('type') == 'result', ET.tostring(reply) + assert reply.findtext('.//{urn:ietf:params:xml:ns:xmpp-bind}jid') == 'alice@localhost/cached-fallback' + client.send("") + reply = client.stanza() + assert reply.get('id') == 'ping' and reply.get('type') == 'result', ET.tostring(reply) + + +def run(binary, old=False): + cases = [('bind2-disabled', False, True), ('bind2-enabled', True, True), ('sm-disabled', False, False)] + for label, bind2, sm in cases: + for ssdp in (False, True): + with tempfile.TemporaryDirectory(prefix='xmpp-cached-inline-') as tmp: + root = Path(tmp) + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-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] + modules = ['tls', 'saslauth', 'sasl2', 'carbons', 'ping'] + modules += (['smacks'] if sm else []) + (['sasl2_bind2'] if bind2 else []) + (['sasl_ssdp'] if ssdp else []) + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': modules}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-1', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + config.write_text(json.dumps(cfg)) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + inline = CACHED_BIND + (CACHED_RESUME if not sm else '') + for mechanism in ('SCRAM-SHA-1', 'PLAIN'): + for wrong in (False, True): + c = Connection(port, root/'cert.pem', ssl.TLSVersion.TLSv1_3) + try: + advertised = c.features.find('.//{' + BIND2 + '}bind') + assert (advertised is not None) == bind2 + result = auth(c, mechanism, inline, 'wrong' if wrong else 'secret', ssdp) + if old: + assert result.tag == '{' + SASL2 + '}failure', ET.tostring(result) + assert result.find('{' + SASL + '}malformed-request') is not None + print('PASS: reproduced 0.7.0 cached Bind2 rejection before SASL') + return + if wrong: + assert result.tag == '{' + SASL2 + '}failure', ET.tostring(result) + assert result.find('{' + SASL + '}not-authorized') is not None + assert result.find('{' + BIND2 + '}bound') is None + else: + finish(c, result, bind2) + finally: + c.sock.close() + log.flush() + log.seek(0) + logs = log.read() + if not bind2: + assert 'inline-request-ignored' in logs and 'bind2-disabled' in logs, logs + if not sm: + assert 'sm-disabled' in logs, logs + assert 'inline-request-malformed' not in logs, logs + print(f'PASS: {label}, SSDP={ssdp}, SCRAM-SHA-1/PLAIN success and wrong-password rejection') + except BaseException: + log.flush() + log.seek(0) + print(log.read()) + raise + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve()), '--expect-070-failure' in sys.argv[2:]) diff --git a/tests/sasl2_resume.py b/tests/sasl2_resume.py new file mode 100644 index 0000000..3d8065f --- /dev/null +++ b/tests/sasl2_resume.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Independent TLS regression for SASL2 inline SM resume. Usage: script BINARY.""" +import json +import socket +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +from sasl2_bind import Client as BindClient, SASL2, BIND2, SASL + +SM = 'urn:xmpp:sm:3' + + +class Client(BindClient): + def until(self, marker): + xml = super().until(marker) + if b'" + features + b'') + assert root.find('.//{' + SASL2 + '}inline/{' + SM + '}sm') is not None, features + return xml + + def ping(self, ident): + self.send(f"") + result = self.until(b'') + assert b"type='result'" in result and f"id='{ident}'".encode() in result, result + return result + + +def run(binary): + with tempfile.TemporaryDirectory(prefix='xmpp-sasl2-resume-') as tmp: + root = Path(tmp) + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + 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) + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'carbons', 'csi_simple', 'smacks', 'ping']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + 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, stdout=subprocess.DEVNULL) + clients = [] + def connect(): + client = Client(port, root/'cert.pem') + clients.append(client) + return client + def request(token, h=0): + return f"" + bind = f"Phone" + def assert_resume(response, token, jid): + assert response.find(f'{{{SASL2}}}authorization-identifier').text == jid, ET.tostring(response) + resumed = response.find(f'{{{SM}}}resumed') + assert resumed is not None and resumed.get('previd') == token, ET.tostring(response) + assert response.find(f'{{{BIND2}}}bound') is None, ET.tostring(response) + assert response.find(f'{{{SM}}}failed') is None, ET.tostring(response) + with (root/'server.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server did not start') + old = connect() + response = old.auth(bind) + jid = response.find(f'{{{SASL2}}}authorization-identifier').text + token = response.find(f'{{{BIND2}}}bound/{{{SM}}}enabled').get('id') + old.until(b'') + old.ping('acknowledged') + old.ping('pending') + # Wrong credentials must not take over an active session. + bad = connect() + failure = bad.auth(request(token) + bind, password='wrong', good=False) + assert failure.find(f'{{{SASL}}}not-authorized') is not None + old.send(f"") + assert ET.fromstring(old.until(b'/>')).tag == f'{{{SM}}}a' + # Wrong owner, unknown id and impossible acknowledgement fail inside + # success and retain optional Bind2 fallback. Failed requests leave + # the original session and its pending queue untouched. + for user, ident, h, fallback in [('bob', token, 0, True), ('alice', 'missing', 0, True), ('alice', token, 99, False)]: + failed = connect() + response = failed.auth(request(ident, h) + (bind if fallback else ''), user=user) + assert response.find(f'{{{SM}}}failed') is not None, ET.tostring(response) + assert response.find(f'{{{SM}}}resumed') is None + assert (response.find(f'{{{BIND2}}}bound') is not None) == fallback + authjid = response.find(f'{{{SASL2}}}authorization-identifier').text + assert authjid.startswith(user + '@localhost/') if fallback else authjid == user + '@localhost' + features = failed.until(b'') + assert (b'xmpp-bind' in features) != fallback + # Active transport takeover; Bind2 is ignored after successful resume. + fresh = connect() + response = fresh.auth(request(token, 1) + bind) + assert_resume(response, token, jid) + assert response.find(f'{{{SM}}}resumed').get('h') == '2' + # auth() parses success first: replay can never precede it. Only the + # second response remains unacknowledged and must be replayed once. + replay = fresh.until(b'') + assert b"id='pending'" in replay and b'acknowledged' not in replay, replay + features = fresh.until(b'') + assert b'xmpp-bind' not in features and b"') + assert b'conflict' in conflict, conflict + fresh.send(f"") + assert ET.fromstring(fresh.until(b'/>')).tag == f'{{{SM}}}a' + # EOF suspension and replay-free second resume, without Bind2. + fresh.close() + again = connect() + response = again.auth(request(token, 2)) + assert_resume(response, token, jid) + assert b'xmpp-bind' not in again.until(b'') + again.ping('after-resume') + malformed = [request(token) * 2, f"", f"", f""] + for inline in malformed: + client = connect() + failure = client.auth(inline, good=False) + assert failure.find(f'{{{SASL}}}malformed-request') is not None, ET.tostring(failure) + response = client.auth() + assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost' + client.until(b'') + again.ping('survives-malformed') + print('PASS: SASL2 inline SM active/EOF resume, authenticated owner/id/h checks, replay ordering, full JID, Bind2 fallback and malformed requests') + finally: + for client in clients: + client.close() + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + if server.returncode not in (0, -15): + log.seek(0) + print(log.read(), file=sys.stderr) + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/sasl_accounts.rs b/tests/sasl_accounts.rs new file mode 100644 index 0000000..ee22dcb --- /dev/null +++ b/tests/sasl_accounts.rs @@ -0,0 +1,37 @@ +use rust_xmpp::{ + auth, + storage::{self, LuaValue}, +}; +#[test] +fn disabled_legacy_and_unicode_accounts() { + let root = tempfile::tempdir().unwrap(); + let path = root.path(); + let mut disabled = LuaValue::table(); + disabled.set("disabled", LuaValue::Bool(true)); + storage::save(path, "example.com", Some("disabled"), "accounts", &disabled).unwrap(); + assert!( + auth::load_scram(path, "example.com", "disabled", "SHA-256") + .unwrap() + .is_none() + ); + assert!(!auth::verify(path, "example.com", "disabled", "password", "SHA-256").unwrap()); + let mut legacy = LuaValue::table(); + legacy.set("password", "pencil".into()); + legacy.set("custom-field", "preserve".into()); + storage::save(path, "example.com", Some("legacy"), "accounts", &legacy).unwrap(); + assert!(auth::verify(path, "example.com", "legacy", "pencil", "SHA-256").unwrap()); + auth::set_password(path, "example.com", "legacy", "changed", "SHA-256").unwrap(); + assert!(auth::verify(path, "example.com", "legacy", "changed", "SHA-256").unwrap()); + assert!(!auth::verify(path, "example.com", "legacy", "pencil", "SHA-256").unwrap()); + let data = storage::load(path, "example.com", Some("legacy"), "accounts") + .unwrap() + .unwrap(); + assert!(data.get("password").is_none()); + assert_eq!( + data.get("custom-field").and_then(LuaValue::as_str), + Some("preserve") + ); + assert!(auth::set_password(path, "example.com", "missing", "password", "SHA-256").is_err()); + auth::create(path, "example.com", "пользователь", "пароль", "SHA-256").unwrap(); + assert!(auth::verify(path, "example.com", "пользователь", "пароль", "SHA-256").unwrap()); +} diff --git a/tests/sasl_network.py b/tests/sasl_network.py new file mode 100644 index 0000000..5932501 --- /dev/null +++ b/tests/sasl_network.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Independent stdlib SCRAM client; --spawn creates an isolated TLS test server. +Tests SHA-1/SHA-256 with SASL1, SASL2, SSDP and server signature validation. +""" +import argparse,base64,hashlib,hmac,secrets,socket,ssl,xml.etree.ElementTree as ET +import json,subprocess,tempfile,signal,time,sys +from pathlib import Path +p=argparse.ArgumentParser();p.add_argument('--port',type=int,default=55222);p.add_argument('--cafile',default=str(Path(__file__).resolve().parents[1]/'demo/cert.pem'));p.add_argument('--user',default='alice');p.add_argument('--password',default='alice-test-only');p.add_argument('--spawn',action='store_true');p.add_argument('--hash',choices=['sha1','sha256'],default='sha256');p.add_argument('--require-ssdp',action='store_true');p.add_argument('--test-push',action='store_true');p.add_argument('--binary',default=str(Path(__file__).resolve().parents[1]/'target/release/rust-xmpp'));args=p.parse_args() +mechanism='SCRAM-SHA-1' if args.hash=='sha1' else 'SCRAM-SHA-256' +if args.spawn: + with tempfile.TemporaryDirectory(prefix='xmpp-sasl-') as tmp: + root=Path(tmp);probe=socket.socket();probe.bind(('127.0.0.1',0));port=probe.getsockname()[1];probe.close() + 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) + config={'global':{'data_path':str(root/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[port],'s2s_ports':[],'http_ports':[],'https_ports':[],'modules_enabled':['tls','saslauth','sasl2','sasl_ssdp','cloud_notify','smacks']},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-1' if args.hash=='sha1' else 'SHA-256','ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}}}} + (root/'config.json').write_text(json.dumps(config)) + subprocess.run([args.binary,'create-user',str(root/'config.json'),args.user,'--password-stdin'],input=args.password+'\n',text=True,check=True,stdout=subprocess.DEVNULL) + server=subprocess.Popen([args.binary,'serve',str(root/'config.json'),'--experimental'],stdout=subprocess.PIPE,stderr=subprocess.PIPE) + try: + for _ in range(100): + if server.poll() is not None:raise RuntimeError(server.stderr.read().decode()) + try:probe=socket.create_connection(('127.0.0.1',port),timeout=.1);probe.close();break + except OSError:time.sleep(.05) + else:raise RuntimeError('SASL server did not start') + subprocess.run([sys.executable,__file__,'--port',str(port),'--cafile',str(root/'cert.pem'),'--user',args.user,'--password',args.password,'--hash',args.hash,'--require-ssdp','--test-push'],check=True) + stored=next((root/'data').glob('*/cloud_notify/*.dat')).read_text() + assert 'test-stable-device' in stored and 'client_id' in stored + assert 'resource' not in stored,stored + finally: + server.send_signal(signal.SIGINT) + try:server.wait(timeout=5) + except subprocess.TimeoutExpired:server.kill();server.wait() + sys.exit(0) +b64=lambda x:base64.b64encode(x).decode() +stream="" +class Client: + def __init__(self): + self.s=socket.create_connection(('127.0.0.1',args.port),timeout=5);self.buf=b'' + self.send(stream);self.until(b'') + self.send("");assert b'proceed' in self.until(b'/>') + self.s=ssl.create_default_context(cafile=args.cafile).wrap_socket(self.s,server_hostname='localhost') + self.send(stream);features=self.until(b'');assert mechanism.encode() in features + part=features[features.index(b'"+part+b'') + self.mechs=sorted({n.text for n in tree.iter() if n.tag=='{urn:ietf:params:xml:ns:xmpp-sasl}mechanism'}) + self.bindings=sorted({n.get('type') for n in tree.iter() if n.tag=='{urn:xmpp:sasl-cb:0}channel-binding'}) + def send(self,data):self.s.sendall(data.encode()) + def until(self,mark): + while mark not in self.buf: + chunk=self.s.recv(65536) + if not chunk:raise RuntimeError('Unexpected EOF: '+repr(self.buf)) + self.buf+=chunk + end=self.buf.index(mark)+len(mark);out,self.buf=self.buf[:end],self.buf[end:];return out + def auth(self,sasl2=False,bad=False): + ns='urn:xmpp:sasl:2' if sasl2 else 'urn:ietf:params:xml:ns:xmpp-sasl' + nonce=secrets.token_hex(18);bare=f'n={args.user},r={nonce}';initial=b64(('n,,'+bare).encode()) + if sasl2:self.send(f"{initial}") + else:self.send(f"{initial}") + node=ET.fromstring(self.until(b''));server_first=base64.b64decode(node.text).decode();attrs=dict(x.split('=',1) for x in server_first.split(',')) + assert attrs['r'].startswith(nonce) and len(attrs['r'])>len(nonce) + if args.require_ssdp:assert 'h' in attrs,'SSDP challenge missing' + if 'h' in attrs: + source='\x1e'.join(self.mechs)+('\x1f'+'\x1e'.join(self.bindings) if self.bindings else '') + assert attrs['h']==b64(hashlib.new(args.hash,source.encode()).digest()),'SSDP mechanism hash mismatch' + final=f"c=biws,r={attrs['r']}";authmsg=f'{bare},{server_first},{final}'.encode() + salted=hashlib.pbkdf2_hmac(args.hash,('wrong-password' if bad else args.password).encode(),base64.b64decode(attrs['s']),int(attrs['i'])) + mac=lambda k,s:hmac.digest(k,s,args.hash) + client_key=mac(salted,b'Client Key');signature=mac(hashlib.new(args.hash,client_key).digest(),authmsg) + proof=bytes(a^b for a,b in zip(client_key,signature));final+=',p='+b64(proof) + self.send(f"{b64(final.encode())}") + if bad: + failure=ET.fromstring(self.until(b'')) + assert failure.tag=='{'+ns+'}failure' + assert failure.find('{urn:ietf:params:xml:ns:xmpp-sasl}not-authorized') is not None, ET.tostring(failure) + return + result=ET.fromstring(self.until(b'')) + data=result.find('{urn:xmpp:sasl:2}additional-data').text if sasl2 else result.text + expected='v='+b64(mac(mac(salted,b'Server Key'),authmsg));assert base64.b64decode(data).decode()==expected + if sasl2:assert result.find('{urn:xmpp:sasl:2}authorization-identifier').text==args.user+'@localhost' + else:self.send(stream) + assert b'xmpp-bind' in self.until(b'') + if sasl2 and args.test_push: + self.send("mobile") + assert b"type='result'" in self.until(b'') + self.send("") + assert b"type='result'" in self.until(b'') + self.send("") + enabled=ET.fromstring(self.until(b'/>'));assert enabled.get('max')=='259200',ET.tostring(enabled) + + def close(self):self.s.close() +for sasl2,bad in [(False,False),(True,False),(False,True),(True,True)]: + c=Client() + try:c.auth(sasl2,bad) + finally:c.close() +print(f'PASS: independent {mechanism} over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection') diff --git a/tests/sasl_retry_network.py b/tests/sasl_retry_network.py new file mode 100644 index 0000000..f8d0de4 --- /dev/null +++ b/tests/sasl_retry_network.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Real STARTTLS regression for SASL identity preparation and exchange retries. + +Usage: python3 tests/sasl_retry_network.py BINARY +""" +import json +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +from android_sasl_network import Connection, OPEN, SASL, SASL2, b64 + +BIND = 'urn:ietf:params:xml:ns:xmpp-bind' + + +def authenticate(connection, sasl2, mechanism, payload): + ns = SASL2 if sasl2 else SASL + encoded = b64(payload.encode()) + if sasl2: + connection.send(f"{encoded}") + else: + connection.send(f"{encoded}") + + +def plain(connection, sasl2, username='alice', authz=''): + authenticate(connection, sasl2, 'PLAIN', f'{authz}\0{username}\0secret') + + +def expect_failure(connection, sasl2, condition): + result = connection.stanza() + ns = SASL2 if sasl2 else SASL + assert result.tag == '{' + ns + '}failure', ET.tostring(result) + assert result.find('{' + SASL + '}' + condition) is not None, ET.tostring(result) + + +def expect_canonical_bind(connection, sasl2): + result = connection.stanza() + ns = SASL2 if sasl2 else SASL + assert result.tag == '{' + ns + '}success', ET.tostring(result) + if sasl2: + assert result.findtext('{' + SASL2 + '}authorization-identifier') == 'alice@localhost', ET.tostring(result) + else: + connection.send(OPEN) + connection.until(b'') + connection.send(f"retry-test") + result = connection.stanza() + assert result.get('type') == 'result' and result.get('id') == 'bind', ET.tostring(result) + assert result.findtext('.//{' + BIND + '}jid') == 'alice@localhost/retry-test', ET.tostring(result) + + +def start_scram(connection, sasl2): + authenticate(connection, sasl2, 'SCRAM-SHA-256', 'n,,n=alice,r=retry-regression-nonce') + result = connection.stanza() + ns = SASL2 if sasl2 else SASL + assert result.tag == '{' + ns + '}challenge', ET.tostring(result) + + +def check_case(connection, sasl2, case): + if case in ('uppercase', 'fullwidth'): + user = 'ALICE' if case == 'uppercase' else 'Alice' + plain(connection, sasl2, user, user + '@localhost') + elif case == 'invalid-authz': + plain(connection, sasl2, 'ALICE', 'bob@localhost') + expect_failure(connection, sasl2, 'not-authorized') + plain(connection, sasl2) + elif case == 'abort': + start_scram(connection, sasl2) + ns = SASL2 if sasl2 else SASL + connection.send(f"") + expect_failure(connection, sasl2, 'aborted') + plain(connection, sasl2) + elif case == 'restart': + start_scram(connection, sasl2) + plain(connection, sasl2, 'ALICE') + else: + raise AssertionError(case) + expect_canonical_bind(connection, sasl2) + + +def run(binary): + cases = ('uppercase', 'fullwidth', 'invalid-authz', 'abort', 'restart') + with tempfile.TemporaryDirectory(prefix='xmpp-sasl-retry-') as tmp: + root = Path(tmp) + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-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] + cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}} + config = root/'config.json' + config.write_text(json.dumps(cfg)) + subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) + with (root/'server.log').open('w+') as log: + process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) + active = None + try: + for _ in range(100): + assert process.poll() is None, 'server stopped' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + for version in (ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3): + for sasl2 in (False, True): + for case in cases: + active = (version.name, 'SASL2' if sasl2 else 'SASL1', case) + connection = Connection(port, root/'cert.pem', version) + try: + check_case(connection, sasl2, case) + finally: + connection.sock.close() + except BaseException: + print('FAILED case:', active) + log.flush(); log.seek(0); print(log.read()) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill(); process.wait() + print('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') + + +if __name__ == '__main__': + run(str(Path(sys.argv[1]).resolve())) diff --git a/tests/sasl_web_network.py b/tests/sasl_web_network.py new file mode 100644 index 0000000..e957e14 --- /dev/null +++ b/tests/sasl_web_network.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Real WSS SCRAM regression: SASL1/SASL2, GS2 y, SSDP and session use. + +Requires websockets and openssl. Runs an isolated loopback-only server. +Usage: python3 tests/sasl_web_network.py [--binary target/release/rust-xmpp] +""" +import argparse +import asyncio +import base64 +import hashlib +import hmac +import json +from pathlib import Path +import secrets +import socket +import ssl +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + +import websockets + +CLIENT = 'jabber:client' +SASL = 'urn:ietf:params:xml:ns:xmpp-sasl' +SASL2 = 'urn:xmpp:sasl:2' +CB = 'urn:xmpp:sasl-cb:0' +BIND = 'urn:ietf:params:xml:ns:xmpp-bind' +FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing' +PASSWORD = 'integration-password' +OPEN = f'' + + +def b64(data): + return base64.b64encode(data).decode() + + +def port(): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + return probe.getsockname()[1] + + +async def receive(ws, predicate): + async with asyncio.timeout(8): + while True: + node = ET.fromstring(await ws.recv()) + assert node.tag not in (f'{{{SASL}}}failure', f'{{{SASL2}}}failure'), ET.tostring(node) + if predicate(node): + return node + + +async def authenticate(ws, sasl2): + await ws.send(OPEN) + features = await receive(ws, lambda n: n.tag.endswith('}features')) + assert features.find(f'.//{{{CB}}}sasl-channel-binding') is None, ET.tostring(features) + assert features.find(f'.//{{{CB}}}channel-binding') is None, ET.tostring(features) + sasl1_mechs = sorted(n.text for n in features.findall(f'{{{SASL}}}mechanisms/{{{SASL}}}mechanism')) + sasl2_mechs = sorted(n.text for n in features.findall(f'{{{SASL2}}}authentication/{{{SASL2}}}mechanism')) + for mechs in (sasl1_mechs, sasl2_mechs): + assert mechs == ['PLAIN', 'SCRAM-SHA-256'], mechs + assert not any(m.endswith('-PLUS') for m in mechs), mechs + mechs = sasl2_mechs if sasl2 else sasl1_mechs + ns = SASL2 if sasl2 else SASL + nonce = secrets.token_hex(18) + bare = f'n=alice,r={nonce}' + # Client supports CB but WSS cannot expose the TLS binding to XMPP. + gs2 = b'y,,' + initial = b64(gs2 + bare.encode()) + if sasl2: + await ws.send(f'{initial}') + else: + await ws.send(f'{initial}') + challenge = await receive(ws, lambda n: n.tag == f'{{{ns}}}challenge') + server_first = base64.b64decode(challenge.text, validate=True).decode() + attrs = dict(item.split('=', 1) for item in server_first.split(',')) + assert attrs['r'].startswith(nonce) and len(attrs['r']) > len(nonce), server_first + # No CB advertisement means the SSDP input ends after the mechanism list; + # appending even an empty CB separator (0x1f) produces a different hash. + ssdp_source = '\x1e'.join(mechs).encode() + expected_ssdp = b64(hashlib.sha256(ssdp_source).digest()) + assert attrs.get('h') == expected_ssdp, (attrs.get('h'), expected_ssdp) + assert attrs['h'] != b64(hashlib.sha256(ssdp_source + b'\x1f').digest()) + final_bare = f"c={b64(gs2)},r={attrs['r']}" + auth_message = f'{bare},{server_first},{final_bare}'.encode() + salted = hashlib.pbkdf2_hmac('sha256', PASSWORD.encode(), base64.b64decode(attrs['s'], validate=True), int(attrs['i'])) + mac = lambda key, data: hmac.digest(key, data, 'sha256') + client_key = mac(salted, b'Client Key') + signature = mac(hashlib.sha256(client_key).digest(), auth_message) + proof = bytes(a ^ b for a, b in zip(client_key, signature)) + final = final_bare + ',p=' + b64(proof) + await ws.send(f'{b64(final.encode())}') + success = await receive(ws, lambda n: n.tag == f'{{{ns}}}success') + data = success.find(f'{{{SASL2}}}additional-data').text if sasl2 else success.text + expected_signature = 'v=' + b64(mac(mac(salted, b'Server Key'), auth_message)) + assert base64.b64decode(data, validate=True).decode() == expected_signature + if sasl2: + assert success.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost' + else: + await ws.send(OPEN) + await receive(ws, lambda n: n.tag.endswith('}features')) + resource = 'sasl2-wss' if sasl2 else 'sasl1-wss' + await ws.send(f'{resource}') + bound = await receive(ws, lambda n: n.get('id') == 'bind') + assert bound.get('type') == 'result', ET.tostring(bound) + assert bound.find(f'.//{{{BIND}}}jid').text == f'alice@localhost/{resource}' + await ws.send(f'') + pong = await receive(ws, lambda n: n.get('id') == 'ping') + assert pong.get('type') == 'result', ET.tostring(pong) + await ws.send(f'') + await receive(ws, lambda n: n.tag == f'{{{FRAMING}}}close') + + +async def exercise(https_port, context): + for sasl2 in (False, True): + async with websockets.connect(f'wss://localhost:{https_port}/xmpp-websocket', ssl=context, + subprotocols=['xmpp'], open_timeout=8) as ws: + await authenticate(ws, sasl2) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='xmpp-sasl-web-') as temporary: + root = Path(temporary) + cert, key = root / 'cert.pem', root / 'key.pem' + 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(key), '-out', str(cert)], + check=True, capture_output=True) + tcp_port, https_port = port(), port() + config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], + 'https_interfaces': ['127.0.0.1'], 'c2s_ports': [tcp_port], 's2s_ports': [], + 'http_ports': [], 'https_ports': [https_port], 'consider_websocket_secure': True, + 'modules_enabled': ['tls', 'websocket', 'sasl2', 'saslauth', 'sasl_ssdp', 'ping'], + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}} + path = root / 'config.json' + path.write_text(json.dumps(config)) + subprocess.run([args.binary, 'create-user', str(path), 'alice', '--password-stdin'], + input=PASSWORD + '\n', text=True, check=True, capture_output=True) + with (root / 'server.log').open('w+') as log: + server = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'server stopped during startup' + try: + with socket.create_connection(('127.0.0.1', tcp_port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('server startup timed out') + asyncio.run(exercise(https_port, ssl.create_default_context(cafile=str(cert)))) + except BaseException: + log.flush() + log.seek(0) + print('SERVER LOG:\n' + log.read()) + raise + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + print('PASS: WSS SCRAM-SHA-256 SASL1/SASL2 with GS2 y, absent CB/PLUS, SSDP without CB separator, server signatures, bind and ping') + + +if __name__ == '__main__': + main() diff --git a/tests/storage_accounts.rs b/tests/storage_accounts.rs new file mode 100644 index 0000000..cf14c28 --- /dev/null +++ b/tests/storage_accounts.rs @@ -0,0 +1,605 @@ +use rust_xmpp::{ + account_modules as a, + storage::{self, LuaValue as V}, +}; +fn iq(root: &std::path::Path, s: &str) -> rust_xmpp::protocol::ModuleResult { + let d = roxmltree::Document::parse(s).unwrap(); + a::handle( + root, + "example.test", + "alice@example.test/r", + d.root_element(), + ) + .unwrap() + .unwrap() +} +#[test] +fn private_and_vcard_authorization() { + let t = tempfile::tempdir().unwrap(); + let r = iq( + t.path(), + "secret", + ); + assert!(r.replies[0].contains("forbidden")); + assert!(!t.path().join("example%2etest").exists()); + let r = iq( + t.path(), + "secret", + ); + assert!(r.replies[0].contains("result")); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("secret")); + let r = iq( + t.path(), + "Alice", + ); + assert!(r.replies[0].contains("forbidden")); +} +#[test] +fn roster_preserves_subscription_and_metadata() { + let t = tempfile::tempdir().unwrap(); + let v=storage::parse(b"return { [false]={version=9;pending={['foo@example.test']=true}};['bob@example.test']={subscription='both';ask='subscribe';groups={Old=true}}}").unwrap(); + storage::save(t.path(), "example.test", Some("alice"), "roster", &v).unwrap(); + let r = iq( + t.path(), + "New", + ); + assert_eq!(r.deliveries.len(), 1); + let v = storage::load(t.path(), "example.test", Some("alice"), "roster") + .unwrap() + .unwrap(); + assert_eq!( + v.get("bob@example.test") + .unwrap() + .get("subscription") + .and_then(V::as_str), + Some("both") + ); + assert_eq!( + v.get_key(&V::Bool(false)).unwrap().get("version"), + Some(&V::Integer(10)) + ); + assert!(v.get_key(&V::Bool(false)).unwrap().get("pending").is_some()); + let r = iq( + t.path(), + "", + ); + assert!(!r.replies[0].contains("", + ); + assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/device").unwrap()); + assert!(!a::is_blocked(t.path(), "example.test", "alice", "carol@example.test").unwrap()); + iq( + t.path(), + "", + ); + assert!(!a::is_blocked(t.path(), "example.test", "alice", "bob@example.test").unwrap()); +} +#[test] +fn subscription_handshake_and_cancel() { + let t = tempfile::tempdir().unwrap(); + for u in ["alice", "bob"] { + let mut v = V::table(); + v.set("test", V::Bool(true)); + storage::save(t.path(), "example.test", Some(u), "accounts", &v).unwrap(); + } + let p = + roxmltree::Document::parse("").unwrap(); + a::subscription( + t.path(), + "example.test", + "alice@example.test/r", + p.root_element(), + ) + .unwrap(); + assert_eq!( + a::pending_subscriptions(t.path(), "example.test", "bob") + .unwrap() + .len(), + 1 + ); + let p = roxmltree::Document::parse("") + .unwrap(); + a::subscription( + t.path(), + "example.test", + "bob@example.test/r", + p.root_element(), + ) + .unwrap(); + assert_eq!( + a::subscriptions(t.path(), "example.test", "alice").unwrap(), + vec!["bob@example.test"] + ); + assert_eq!( + a::subscribers(t.path(), "example.test", "bob").unwrap(), + vec!["alice@example.test"] + ); + assert!( + a::pending_subscriptions(t.path(), "example.test", "bob") + .unwrap() + .is_empty() + ); + let p = + roxmltree::Document::parse("").unwrap(); + a::subscription( + t.path(), + "example.test", + "alice@example.test/r", + p.root_element(), + ) + .unwrap(); + assert!( + a::subscriptions(t.path(), "example.test", "alice") + .unwrap() + .is_empty() + ); + assert!( + a::subscribers(t.path(), "example.test", "bob") + .unwrap() + .is_empty() + ); +} +#[test] +fn reads_timestamp_blocklist_and_keeps_metadata() { + let t = tempfile::tempdir().unwrap(); + let v = + storage::parse(b"return {[false]={created=42};['bob@example.test']=1729000000}").unwrap(); + storage::save(t.path(), "example.test", Some("alice"), "blocklist", &v).unwrap(); + assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/r").unwrap()); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("bob@example.test")); + iq( + t.path(), + "", + ); + let v = storage::load(t.path(), "example.test", Some("alice"), "blocklist") + .unwrap() + .unwrap(); + assert_eq!( + v.get_key(&V::Bool(false)).unwrap().get("created"), + Some(&V::Integer(42)) + ); +} +fn legacy(root: &std::path::Path, jid: &str, s: &str) -> rust_xmpp::protocol::ModuleResult { + let user = jid.split('@').next().unwrap(); + if storage::load(root, "example.test", Some(user), "accounts") + .unwrap() + .is_none() + { + let mut v = V::table(); + v.set("test", V::Bool(true)); + storage::save(root, "example.test", Some(user), "accounts", &v).unwrap(); + } + let d = roxmltree::Document::parse(s).unwrap(); + a::handle_legacy(root, "example.test", jid, d.root_element()) + .unwrap() + .unwrap() +} +#[test] +fn vcard_pep_conversion_and_avatar() { + let t = tempfile::tempdir().unwrap(); + let r = legacy( + t.path(), + "alice@example.test/r", + "AliceTesta@example.testimage/pngaGVsbG8=", + ); + assert!(r.replies[0].contains("result")); + let r = legacy( + t.path(), + "bob@example.test/r", + "", + ); + assert!(r.replies[0].contains("Alice")); + assert!(r.replies[0].contains("aGVsbG8=")); + let r = legacy( + t.path(), + "alice@example.test/r", + "", + ); + assert!(r.replies[0].contains("surname")); + let p = roxmltree::Document::parse("").unwrap(); + let s = a::avatar_presence( + t.path(), + "example.test", + "alice@example.test/r", + p.root_element(), + ) + .unwrap(); + assert!(s.contains("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")); +} +#[test] +fn bookmarks_private_pep_bidirectional_and_private() { + let t = tempfile::tempdir().unwrap(); + let r = legacy( + t.path(), + "alice@example.test/r", + "Alicesecret", + ); + assert!(r.replies[0].contains("result")); + let r = legacy( + t.path(), + "alice@example.test/r", + "", + ); + assert!(r.replies[0].contains("secret")); + let d=roxmltree::Document::parse("").unwrap(); + let r = rust_xmpp::pubsub::handle( + t.path(), + "example.test", + "bob@example.test/r", + d.root_element(), + ) + .unwrap() + .unwrap(); + assert!(r.replies[0].contains("forbidden")); + legacy( + t.path(), + "alice@example.test/r", + "", + ); + let r = legacy( + t.path(), + "alice@example.test/r", + "", + ); + assert!(!r.replies[0].contains("room@")); +} +#[test] +fn inbound_subscription_requires_pending_to_grant_to() { + let t = tempfile::tempdir().unwrap(); + let mut v = V::table(); + v.set("test", V::Bool(true)); + storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap(); + let d = roxmltree::Document::parse("") + .unwrap(); + a::inbound_subscription( + t.path(), + "example.test", + "evil@other.test", + d.root_element(), + ) + .unwrap(); + assert!( + a::subscriptions(t.path(), "example.test", "alice") + .unwrap() + .is_empty() + ); +} +#[test] +fn legacy_migration_clears_source_after_success() { + let t = tempfile::tempdir().unwrap(); + let mut account = V::table(); + account.set("test", V::Bool(true)); + storage::save( + t.path(), + "example.test", + Some("alice"), + "accounts", + &account, + ) + .unwrap(); + let d = + roxmltree::Document::parse("Old Alice").unwrap(); + storage::save( + t.path(), + "example.test", + Some("alice"), + "vcard", + &storage::stanza_value(d.root_element()), + ) + .unwrap(); + let d=roxmltree::Document::parse("").unwrap(); + let mut private = V::table(); + private.set( + "storage:storage:bookmarks", + storage::stanza_value(d.root_element()), + ); + private.set("unrelated", "keep".into()); + storage::save(t.path(), "example.test", Some("alice"), "private", &private).unwrap(); + a::legacy_on_bind(t.path(), "example.test", "alice@example.test/r").unwrap(); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "vcard") + .unwrap() + .is_none() + ); + let v = storage::load(t.path(), "example.test", Some("alice"), "private") + .unwrap() + .unwrap(); + assert!(v.get("storage:storage:bookmarks").is_none()); + assert_eq!(v.get("unrelated").and_then(V::as_str), Some("keep")); + a::legacy_on_bind(t.path(), "example.test", "alice@example.test/r").unwrap(); + let r = legacy( + t.path(), + "alice@example.test/r", + "", + ); + assert!(r.replies[0].contains("Old Alice")); +} +#[test] +fn domain_roster_and_block_jid_normalization() { + let t = tempfile::tempdir().unwrap(); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("result")); + let d = storage::load(t.path(), "example.test", Some("alice"), "roster") + .unwrap() + .unwrap(); + assert!(d.get("transport.example.test").is_some()); + let r = iq( + t.path(), + "", + ); + assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/Phone").unwrap()); + assert!(r.deliveries[0].1.contains("bob@example.test/Phone")); +} +#[test] +fn purge_account_exact_files_preserves_others_and_extras() { + let t = tempfile::tempdir().unwrap(); + let root = t.path(); + for store in ["accounts", "roster", "pep_aabbcc", "custom_store"] { + for user in ["alice", "bob"] { + for ext in ["dat", "list", "lidx", "rappend"] { + let p = storage::path(root, "example.test", Some(user), store).with_extension(ext); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b"fixture").unwrap(); + } + } + } + let extra = + storage::path(root, "example.test", Some("alice"), "custom_store").with_extension("backup"); + std::fs::write(&extra, b"keep").unwrap(); + let hostwide = storage::path(root, "example.test", None, "hostwide"); + std::fs::write(&hostwide, b"keep").unwrap(); + a::remove_account(root, "example.test", "alice").unwrap(); + for store in ["accounts", "roster", "pep_aabbcc", "custom_store"] { + for ext in ["dat", "list", "lidx", "rappend"] { + assert!( + !storage::path(root, "example.test", Some("alice"), store) + .with_extension(ext) + .exists() + ); + assert!( + storage::path(root, "example.test", Some("bob"), store) + .with_extension(ext) + .exists() + ); + } + } + assert!(extra.exists()); + assert!(hostwide.exists()); + a::remove_account(root, "example.test", "alice").unwrap(); +} +#[test] +fn privacy_migration_only_default_deny_jids() { + let t = tempfile::tempdir().unwrap(); + let mut v = V::table(); + v.set("test", V::Bool(true)); + storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap(); + let old=storage::parse(b"return {default='main';lists={main={items={{type='jid';action='deny';value='BOB@EXAMPLE.TEST'};{type='jid';action='allow';value='carol@example.test'};{type='group';action='deny';value='Friends'}}};other={items={{type='jid';action='deny';value='other@example.test'}}}}}").unwrap(); + storage::save(t.path(), "example.test", Some("alice"), "privacy", &old).unwrap(); + assert!( + a::load_blocklist(t.path(), "example.test", "alice", false) + .unwrap() + .entries() + .is_empty() + ); + assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/r").unwrap()); + assert!(!a::is_blocked(t.path(), "example.test", "alice", "carol@example.test").unwrap()); + assert!(!a::is_blocked(t.path(), "example.test", "alice", "other@example.test").unwrap()); + let b = a::load_blocklist(t.path(), "example.test", "alice", true).unwrap(); + assert_eq!( + b.get_key(&V::Bool(false)) + .unwrap() + .get("migrated") + .and_then(V::as_str), + Some("privacy") + ); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "privacy") + .unwrap() + .is_some() + ); +} +#[cfg(unix)] +#[test] +fn account_purge_refuses_symlink_store_without_touching_other_files() { + let t = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let mut v = V::table(); + v.set("test", V::Bool(true)); + storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap(); + std::fs::write(other.path().join("alice.dat"), b"external").unwrap(); + std::os::unix::fs::symlink(other.path(), t.path().join("example%2etest/linked")).unwrap(); + assert!(a::remove_account(t.path(), "example.test", "alice").is_err()); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "accounts") + .unwrap() + .is_some() + ); + assert!(other.path().join("alice.dat").exists()); +} +#[test] +fn legacy_bookmarks_respect_host_limit_and_read_over_256() { + let t = tempfile::tempdir().unwrap(); + a::set_legacy_pep_limit(t.path(), "example.test", 500).unwrap(); + legacy( + t.path(), + "alice@example.test/r", + "", + ); + let meta = storage::load(t.path(), "example.test", Some("alice"), "pep") + .unwrap() + .unwrap(); + assert_eq!( + meta.get("urn:xmpp:bookmarks:1") + .unwrap() + .get("config") + .unwrap() + .get("max_items"), + Some(&V::Integer(500)) + ); + let store = "pep_urn:xmpp:bookmarks:1"; + let entries = (0..300) + .map(|i| rust_xmpp::archive::Entry { + id: format!("room{i}@conference.example.test"), + when: 1700000000.0 + i as f64, + peer: "alice@example.test".into(), + xml: "".into(), + }) + .collect::>(); + rust_xmpp::archive::save_store(t.path(), "example.test", Some("alice"), store, &entries) + .unwrap(); + let s = a::legacy_bookmarks(t.path(), "example.test", "alice@example.test/r").unwrap(); + assert_eq!(s.matches("", + "", + "", + ] { + assert!(iq(t.path(), xml).replies[0].contains("bad-request")); + } + assert!( + storage::load(t.path(), "example.test", Some("alice"), "roster") + .unwrap() + .is_none() + ); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("item-not-found")); + assert!(r.deliveries.is_empty()); +} +#[test] +fn broken_roster_is_not_overwritten() { + let t = tempfile::tempdir().unwrap(); + let p = storage::path(t.path(), "example.test", Some("alice"), "roster"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + let data=b"return {[false]={broken='read-failed';version=7};['bob@example.test']={subscription='both';groups={}}}"; + std::fs::write(&p, data).unwrap(); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("internal-server-error")); + assert_eq!(std::fs::read(&p).unwrap(), data); + std::fs::write(&p, b"this is corrupt").unwrap(); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("internal-server-error")); + assert_eq!(std::fs::read(&p).unwrap(), b"this is corrupt"); +} +#[test] +fn roster_empty_name_and_foreign_group_ignored() { + let t = tempfile::tempdir().unwrap(); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("ver='1'")); + iq( + t.path(), + "HiddenFriends", + ); + let v = storage::load(t.path(), "example.test", Some("alice"), "roster") + .unwrap() + .unwrap(); + let item = v.get("bob@example.test").unwrap(); + assert!(item.get("name").is_none()); + let groups = item.get("groups").unwrap(); + assert_eq!(groups.entries().len(), 1); + assert!(groups.get("Friends").is_some()); +} +#[test] +fn creation_and_purge_are_serialized() { + use rust_xmpp::auth; + use std::sync::{Arc, Barrier}; + for _ in 0..5 { + let t = tempfile::tempdir().unwrap(); + auth::create(t.path(), "example.test", "alice", "old", "SHA-256").unwrap(); + let mut v = V::table(); + v.set("old", "private-data".into()); + storage::save(t.path(), "example.test", Some("alice"), "private", &v).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + std::thread::scope(|scope| { + let b = barrier.clone(); + let p = t.path(); + let new = scope.spawn(move || { + b.wait(); + auth::create(p, "example.test", "alice", "new", "SHA-256") + }); + barrier.wait(); + a::remove_account(t.path(), "example.test", "alice").unwrap(); + let created = new.join().unwrap(); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "private") + .unwrap() + .is_none() + ); + if created.is_ok() { + assert!(auth::verify(t.path(), "example.test", "alice", "new", "SHA-256").unwrap()); + } + }); + } +} +#[test] +fn password_change_after_purge_never_recreates_account() { + use rust_xmpp::auth; + let t = tempfile::tempdir().unwrap(); + auth::create(t.path(), "example.test", "alice", "old", "SHA-256").unwrap(); + a::remove_account(t.path(), "example.test", "alice").unwrap(); + assert!(auth::set_password(t.path(), "example.test", "alice", "new", "SHA-256").is_err()); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "accounts") + .unwrap() + .is_none() + ); +} +#[test] +fn malformed_legacy_vcard_and_private_bookmarks_rejected() { + let t = tempfile::tempdir().unwrap(); + let r = legacy( + t.path(), + "alice@example.test/r", + "Bad", + ); + assert!(r.replies[0].contains("bad-request")); + assert!( + storage::load(t.path(), "example.test", Some("alice"), "pep") + .unwrap() + .is_none() + ); + let r = legacy( + t.path(), + "alice@example.test/r", + "", + ); + assert!(r.replies[0].contains("bad-request")); + let r = iq( + t.path(), + "", + ); + assert!(r.replies[0].contains("bad-request")); + assert!(!a::is_blocked(t.path(), "example.test", "alice", "bob@example.test").unwrap()); +} diff --git a/tests/storage_compat.rs b/tests/storage_compat.rs new file mode 100644 index 0000000..600a7fe --- /dev/null +++ b/tests/storage_compat.rs @@ -0,0 +1,119 @@ +use rust_xmpp::storage::{self, LuaValue as V}; +#[test] +fn mixed_binary_roundtrip() { + let src=b"return { [false]={version=4;pending={};}; [1]='one'; ['a\\000\\255']='\\001\\254'; ['alice@example.test']={subscription='both';groups={Friends=true}} }"; + let a = storage::parse(src).unwrap(); + let b = storage::parse(storage::serialize(&a).unwrap().as_bytes()).unwrap(); + assert_eq!(a.get_key(&V::Bool(false)), b.get_key(&V::Bool(false))); + assert_eq!( + a.get_key(&V::String(vec![b'a', 0, 255])), + Some(&V::String(vec![1, 254])) + ); + assert_eq!(a.get_key(&V::Integer(1)), b.get_key(&V::Integer(1))); + let lua = mlua::Lua::new(); + let t: mlua::Table = lua.load(storage::serialize(&a).unwrap()).eval().unwrap(); + assert_eq!( + t.get::(false) + .unwrap() + .get::("version") + .unwrap(), + 4 + ); +} +#[test] +fn path_and_atomic_update() { + let t = tempfile::tempdir().unwrap(); + assert_eq!( + storage::path(t.path(), "example.test", Some("a_b"), "pep_node") + .strip_prefix(t.path()) + .unwrap() + .to_str() + .unwrap(), + "example%2etest/pep_node/a%5fb.dat" + ); + storage::update(t.path(), "example.test", Some("alice"), "roster", |v| { + v.set("a", "old".into()); + Ok(()) + }) + .unwrap(); + let result = storage::update(t.path(), "example.test", Some("alice"), "roster", |v| { + v.set("a", "new".into()); + anyhow::bail!("abort") + }); + assert!(result.is_err()); + assert_eq!( + storage::load(t.path(), "example.test", Some("alice"), "roster") + .unwrap() + .unwrap() + .get("a") + .and_then(V::as_str), + Some("old") + ); +} +#[test] +fn rejects_cycles_code_and_bad_files() { + assert!(storage::parse(b"return os.execute('true')").is_err()); + assert!(storage::parse(b"local t={};t.self=t;return t").is_err()); + assert!(storage::parse(b"while true do end").is_err()); + assert!(storage::parse(b"return {x=function()end}").is_err()); +} +#[test] +fn stanza_roundtrip() { + let d=roxmltree::Document::parse("A & B").unwrap(); + let v = storage::stanza_value(d.root_element()); + assert_eq!( + v.get("attr").unwrap().get("xml:lang").and_then(V::as_str), + Some("ru") + ); + let s = + storage::stanza_xml(&storage::parse(storage::serialize(&v).unwrap().as_bytes()).unwrap()) + .unwrap(); + let doc = roxmltree::Document::parse(&s).unwrap(); + assert_eq!( + doc.root_element() + .attribute(("http://www.w3.org/XML/1998/namespace", "lang")), + Some("ru") + ); + assert_eq!( + doc.root_element().first_element_child().unwrap().text(), + Some("A & B") + ); +} +#[test] +fn concurrent_updates_do_not_lose_entries() { + let t = tempfile::tempdir().unwrap(); + std::thread::scope(|scope| { + for n in 0..12 { + let p = t.path(); + scope.spawn(move || { + storage::update(p, "d", Some("a"), "test", |v| { + v.set(&n.to_string(), V::Integer(n)); + Ok(()) + }) + .unwrap(); + }); + } + }); + assert_eq!( + storage::load(t.path(), "d", Some("a"), "test") + .unwrap() + .unwrap() + .entries() + .len(), + 12 + ); +} +#[test] +fn empty_namespace_preserved() { + let d = roxmltree::Document::parse("").unwrap(); + let s = storage::stanza_xml(&storage::stanza_value(d.root_element())).unwrap(); + let d = roxmltree::Document::parse(&s).unwrap(); + assert_eq!( + d.root_element() + .first_element_child() + .unwrap() + .tag_name() + .namespace(), + Some("") + ); +} diff --git a/tests/tls_binding.rs b/tests/tls_binding.rs new file mode 100644 index 0000000..2900e39 --- /dev/null +++ b/tests/tls_binding.rs @@ -0,0 +1,179 @@ +#[path = "../src/tls_binding.rs"] +mod tls_binding; + +use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; +use std::{path::Path, process::Command}; +use tls_binding::server_end_point; + +fn openssl(dir: &Path, args: &[&str]) { + let output = Command::new("openssl") + .current_dir(dir) + .args(args) + .output() + .expect("openssl must be installed for TLS certificate tests"); + assert!( + output.status.success(), + "openssl {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn certificate(dir: &Path, digest: &str, pss: bool) -> Vec { + let mut args = vec![ + "req", + "-new", + "-x509", + "-key", + "key.pem", + "-subj", + "/CN=binding.test", + "-days", + "1", + "-outform", + "DER", + "-out", + "cert.der", + digest, + ]; + if pss { + args.extend([ + "-sigopt", + "rsa_padding_mode:pss", + "-sigopt", + "rsa_pss_saltlen:digest", + ]); + } + openssl(dir, &args); + std::fs::read(dir.join("cert.der")).unwrap() +} + +#[test] +fn rsa_signature_digest_and_weak_hash_upgrade() { + let dir = tempfile::tempdir().unwrap(); + openssl( + dir.path(), + &[ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-out", + "key.pem", + ], + ); + for digest in ["-md5", "-sha1", "-sha224", "-sha256", "-sha384", "-sha512"] { + let cert = certificate(dir.path(), digest, false); + let expected = match digest { + "-sha224" => Sha224::digest(&cert).to_vec(), + "-sha384" => Sha384::digest(&cert).to_vec(), + "-sha512" => Sha512::digest(&cert).to_vec(), + _ => Sha256::digest(&cert).to_vec(), + }; + assert_eq!(server_end_point(&cert).unwrap(), expected, "{digest}"); + } + for digest in ["-sha1", "-sha224", "-sha256", "-sha384", "-sha512"] { + let cert = certificate(dir.path(), digest, true); + let expected = match digest { + "-sha224" => Sha224::digest(&cert).to_vec(), + "-sha384" => Sha384::digest(&cert).to_vec(), + "-sha512" => Sha512::digest(&cert).to_vec(), + _ => Sha256::digest(&cert).to_vec(), + }; + assert_eq!(server_end_point(&cert).unwrap(), expected, "PSS {digest}"); + } +} + +#[test] +fn ecdsa_uses_certificate_signature_hash() { + let dir = tempfile::tempdir().unwrap(); + openssl( + dir.path(), + &[ + "genpkey", + "-algorithm", + "EC", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-out", + "key.pem", + ], + ); + let cert = certificate(dir.path(), "-sha384", false); + assert_eq!( + server_end_point(&cert).unwrap(), + Sha384::digest(&cert).to_vec() + ); + for end in 0..cert.len() { + assert!( + server_end_point(&cert[..end]).is_err(), + "accepted truncation at {end}" + ); + } + let mut trailing = cert; + trailing.push(0); + assert!(server_end_point(&trailing).is_err()); +} + +fn tlv(tag: u8, value: &[u8]) -> Vec { + assert!(value.len() < 128); + let mut result = vec![tag, value.len() as u8]; + result.extend(value); + result +} +fn envelope(algorithm: &[u8]) -> Vec { + let mut contents = tlv(0x30, &[]); + contents.extend(tlv(0x30, algorithm)); + contents.extend(tlv(0x03, &[0, 1])); + tlv(0x30, &contents) +} + +#[test] +fn unknown_and_eddsa_algorithms_fail_closed() { + // Ed25519/Ed448 do not get a guessed SHA-256 binding. + for oid in [&[0x2b, 0x65, 0x70][..], &[0x2b, 0x65, 0x71], &[0x2a, 3, 4]] { + assert!(server_end_point(&envelope(&tlv(0x06, oid))).is_err()); + } +} + +#[test] +fn malformed_der_and_pss_parameters_fail_closed() { + for bytes in [ + vec![], + vec![0x30, 0x80, 0, 0], + vec![0x30, 0x81, 0], + vec![0x30, 0xff], + vec![0x30, 0x88, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + ] { + assert!(server_end_point(&bytes).is_err()); + } + let pss_oid = tlv(0x06, b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0a"); + assert!(server_end_point(&envelope(&pss_oid)).is_err()); + let mut defaults = pss_oid.clone(); + defaults.extend(tlv(0x30, &[])); + let cert = envelope(&defaults); + assert_eq!( + server_end_point(&cert).unwrap(), + Sha256::digest(&cert).to_vec() + ); + let hash = tlv(0x30, &tlv(0x06, b"\x60\x86\x48\x01\x65\x03\x04\x02\x02")); + let sha384 = tlv(0xa0, &hash); + // SHA384 with default MGF1-SHA1 has multiple hash functions. + let mut mismatch = pss_oid.clone(); + mismatch.extend(tlv(0x30, &sha384)); + assert!(server_end_point(&envelope(&mismatch)).is_err()); + for params in [ + vec![0xa4, 0], + vec![0xa0, 0], + vec![0xa2, 3, 2, 1, 0xff], + vec![0xa3, 3, 2, 1, 2], + [sha384.clone(), sha384].concat(), + ] { + let mut algorithm = pss_oid.clone(); + algorithm.extend(tlv(0x30, ¶ms)); + assert!(server_end_point(&envelope(&algorithm)).is_err()); + } + let mut invalid_oid = tlv(0x06, &[0x80, 0x2a]); + invalid_oid.extend([5, 0]); + assert!(server_end_point(&envelope(&invalid_oid)).is_err()); +} diff --git a/tests/tls_plus_network.rs b/tests/tls_plus_network.rs new file mode 100644 index 0000000..06a3a31 --- /dev/null +++ b/tests/tls_plus_network.rs @@ -0,0 +1,445 @@ +//! Independent SCRAM-PLUS client against the actual STARTTLS listener. +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use hmac::{Hmac, Mac}; +use rust_xmpp::{auth, wire::Framer}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + io::BufReader, + process::{Child, Command, Stdio}, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt}, + net::TcpStream, +}; +use tokio_rustls::{ + TlsConnector, + rustls::{self, RootCertStore, pki_types::ServerName}, +}; + +const STREAM: &str = ""; +const SASL1: &str = "urn:ietf:params:xml:ns:xmpp-sasl"; +const SASL2: &str = "urn:xmpp:sasl:2"; +const CB_NS: &str = "urn:xmpp:sasl-cb:0"; +const WAIT: Duration = Duration::from_secs(5); +const PASSWORD: &str = "loopback-plus-password"; + +struct Process(Child); +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct Server { + process: Process, + _dir: tempfile::TempDir, + port: u16, + cert: rustls::pki_types::CertificateDer<'static>, +} +impl Server { + async fn start(disable_plus: bool) -> Self { + let dir = tempfile::tempdir().unwrap(); + let log = fs::File::create(dir.path().join("openssl.log")).unwrap(); + let mut openssl = Process( + Command::new("openssl") + .current_dir(dir.path()) + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-nodes", + "-days", + "1", + "-keyout", + "key.pem", + "-out", + "cert.pem", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost", + "-addext", + "basicConstraints=critical,CA:FALSE", + "-addext", + "extendedKeyUsage=serverAuth", + ]) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .expect("openssl must be installed"), + ); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if let Some(status) = openssl.0.try_wait().unwrap() { + assert!( + status.success(), + "openssl failed: {}", + fs::read_to_string(dir.path().join("openssl.log")).unwrap() + ); + break; + } + assert!(Instant::now() < deadline, "openssl timed out"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let cert = rustls_pemfile::certs(&mut BufReader::new( + fs::File::open(dir.path().join("cert.pem")).unwrap(), + )) + .next() + .unwrap() + .unwrap(); + let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = reservation.local_addr().unwrap().port(); + let data = dir.path().join("data"); + auth::create(&data, "localhost", "alice", PASSWORD, "SHA-256").unwrap(); + let mut config = serde_json::json!({ + "global": {"data_path": data, "interfaces": ["127.0.0.1"], "c2s_ports": [port], + "s2s_ports": [], "http_ports": [], "https_ports": [], + "modules_enabled": ["tls", "saslauth", "sasl2", "sasl_ssdp"]}, + "hosts": {"localhost": {"options": {"authentication": "internal_hashed", "password_hash": "SHA-256", + "ssl": {"key": dir.path().join("key.pem"), "certificate": dir.path().join("cert.pem")}}}} + }); + if disable_plus { + config["hosts"]["localhost"]["options"]["disable_sasl_mechanisms"] = + serde_json::json!(["SCRAM-SHA-256-PLUS"]); + } + let path = dir.path().join("config.json"); + fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + let log = fs::File::create(dir.path().join("server.log")).unwrap(); + drop(reservation); + let process = Process( + Command::new(env!("CARGO_BIN_EXE_rust-xmpp")) + .arg("serve") + .arg(path) + .arg("--experimental") + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .unwrap(), + ); + let mut server = Self { + process, + _dir: dir, + port, + cert, + }; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + assert!( + server.process.0.try_wait().unwrap().is_none(), + "server exited: {}", + server.logs() + ); + if TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("server startup timed out: {}", server.logs())); + server + } + fn logs(&self) -> String { + fs::read_to_string(self._dir.path().join("server.log")).unwrap_or_default() + } +} + +async fn send(stream: &mut S, xml: &str) { + tokio::time::timeout(WAIT, stream.write_all(xml.as_bytes())) + .await + .expect("write timeout") + .unwrap(); +} +async fn next(framer: &mut Framer, stream: &mut S) -> String { + tokio::time::timeout(WAIT, framer.next(stream)) + .await + .expect("read timeout") + .unwrap() +} +async fn features( + stream: &mut S, + framer: &mut Framer, +) -> String { + send(stream, STREAM).await; + loop { + let xml = next(framer, stream).await; + if xml.contains(" Vec { + let mut h = Hmac::::new_from_slice(key).unwrap(); + h.update(data); + h.finalize().into_bytes().to_vec() +} + +async fn exchange( + server: &Server, + tls13: bool, + binding_name: &str, + sasl2: bool, + bad_binding: bool, +) { + let mut tcp = tokio::time::timeout(WAIT, TcpStream::connect(("127.0.0.1", server.port))) + .await + .unwrap() + .unwrap(); + let mut f = Framer::default(); + let plain = features(&mut tcp, &mut f).await; + assert!(plain.contains("starttls"), "{plain}"); + assert!( + !plain.contains("SCRAM-SHA-256-PLUS"), + "PLUS must not be offered before TLS" + ); + send( + &mut tcp, + "", + ) + .await; + let proceed = next(&mut f, &mut tcp).await; + assert!(proceed.contains("proceed"), "{proceed}"); + assert!(f.buffer.is_empty()); + let mut roots = RootCertStore::empty(); + roots.add(server.cert.clone()).unwrap(); + let versions = if tls13 { + &[&rustls::version::TLS13][..] + } else { + &[&rustls::version::TLS12][..] + }; + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(versions) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut tls = tokio::time::timeout( + WAIT, + TlsConnector::from(Arc::new(config)) + .connect(ServerName::try_from("localhost").unwrap(), tcp), + ) + .await + .expect("TLS timeout") + .unwrap(); + assert_eq!( + tls.get_ref().1.protocol_version(), + Some(if tls13 { + rustls::ProtocolVersion::TLSv1_3 + } else { + rustls::ProtocolVersion::TLSv1_2 + }) + ); + let plus = binding_name != "none"; + let mechanism = if plus { + "SCRAM-SHA-256-PLUS" + } else { + "SCRAM-SHA-256" + }; + let mut binding = if !plus { + Vec::new() + } else if binding_name == "tls-exporter" { + tls.get_ref() + .1 + .export_keying_material([0u8; 32], b"EXPORTER-Channel-Binding", Some(b"")) + .unwrap() + .to_vec() + } else { + Sha256::digest(server.cert.as_ref()).to_vec() + }; + let mut f = Framer::default(); + let advertised = features(&mut tls, &mut f).await; + let doc = roxmltree::Document::parse(&advertised).unwrap(); + let mechanism_ns = if sasl2 { SASL2 } else { SASL1 }; + let mut mechanisms: Vec<_> = doc + .descendants() + .filter(|n| n.has_tag_name((mechanism_ns, "mechanism"))) + .map(|n| n.text().unwrap().to_owned()) + .collect(); + mechanisms.sort(); + mechanisms.dedup(); + assert!(mechanisms.iter().any(|m| m == mechanism)); + assert_eq!(mechanisms.iter().any(|m| m == "SCRAM-SHA-256-PLUS"), plus); + let mut bindings: Vec<_> = doc + .descendants() + .filter(|n| n.has_tag_name((CB_NS, "channel-binding"))) + .map(|n| n.attribute("type").unwrap().to_owned()) + .collect(); + bindings.sort(); + bindings.dedup(); + assert_eq!( + bindings, + if !plus { + vec![] + } else if tls13 { + vec!["tls-exporter", "tls-server-end-point"] + } else { + vec!["tls-server-end-point"] + } + ); + let gs2 = if plus { + format!("p={binding_name},,") + } else { + "y,,".to_owned() + }; + let bare = "n=alice,r=independent-client-nonce"; + let first = B64.encode(format!("{gs2}{bare}")); + let ns = mechanism_ns; + if sasl2 { + send(&mut tls, &format!("{first}")).await; + } else { + send( + &mut tls, + &format!("{first}"), + ) + .await; + } + let xml = next(&mut f, &mut tls).await; + let doc = roxmltree::Document::parse(&xml).unwrap(); + assert!(doc.root_element().has_tag_name((ns, "challenge")), "{xml}"); + let challenge = + String::from_utf8(B64.decode(doc.root_element().text().unwrap()).unwrap()).unwrap(); + let attrs: BTreeMap<_, _> = challenge + .split(',') + .map(|a| a.split_once('=').unwrap()) + .collect(); + assert!(attrs["r"].starts_with("independent-client-nonce")); + assert!(attrs["r"].len() > "independent-client-nonce".len()); + let ssdp_source = if bindings.is_empty() { + mechanisms.join("\x1e") + } else { + format!("{}\x1f{}", mechanisms.join("\x1e"), bindings.join("\x1e")) + }; + assert_eq!( + attrs.get("h").copied(), + Some(B64.encode(Sha256::digest(ssdp_source.as_bytes())).as_str()), + "SSDP must authenticate the entire advertised mechanism and binding lists" + ); + let iterations: u32 = attrs["i"].parse().unwrap(); + assert!((1..=1_000_000).contains(&iterations)); + let mut salted = [0u8; 32]; + pbkdf2::pbkdf2_hmac::( + PASSWORD.as_bytes(), + &B64.decode(attrs["s"]).unwrap(), + iterations, + &mut salted, + ); + if bad_binding { + binding[0] ^= 1; + } + let mut cbind = gs2.into_bytes(); + cbind.extend_from_slice(&binding); + let without = format!("c={},r={}", B64.encode(cbind), attrs["r"]); + let auth_message = format!("{bare},{challenge},{without}"); + let client_key = mac(&salted, b"Client Key"); + let signature = mac(&Sha256::digest(&client_key), auth_message.as_bytes()); + let proof: Vec<_> = client_key + .iter() + .zip(signature) + .map(|(a, b)| a ^ b) + .collect(); + // Even the tampered-binding exchange has a valid password proof for its transcript. + let final_message = format!("{without},p={}", B64.encode(proof)); + send( + &mut tls, + &format!( + "{}", + B64.encode(final_message) + ), + ) + .await; + let xml = next(&mut f, &mut tls).await; + let doc = roxmltree::Document::parse(&xml).unwrap(); + let result = doc.root_element(); + if bad_binding { + assert!( + result.has_tag_name((ns, "failure")), + "tampered binding authenticated: {xml}" + ); + assert!( + result + .descendants() + .any(|n| n.has_tag_name((SASL1, "not-authorized"))), + "{xml}" + ); + return; + } + assert!(result.has_tag_name((ns, "success")), "{xml}"); + let final_data = if sasl2 { + assert_eq!( + result + .children() + .find(|n| n.has_tag_name((SASL2, "authorization-identifier"))) + .and_then(|n| n.text()), + Some("alice@localhost") + ); + result + .children() + .find(|n| n.has_tag_name((SASL2, "additional-data"))) + .unwrap() + .text() + .unwrap() + } else { + result.text().unwrap() + }; + let expected = format!( + "v={}", + B64.encode(mac(&mac(&salted, b"Server Key"), auth_message.as_bytes())) + ); + assert_eq!( + String::from_utf8(B64.decode(final_data).unwrap()).unwrap(), + expected, + "verify the full server signature" + ); + if !sasl2 { + f = Framer::default(); + } + let post_auth = if sasl2 { + let xml = next(&mut f, &mut tls).await; + f.wrap(&xml) + } else { + features(&mut tls, &mut f).await + }; + assert!( + post_auth.contains("urn:ietf:params:xml:ns:xmpp-bind"), + "{post_auth}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn starttls_scram_plus_binds_real_tls_and_authenticates_ssdp_sasl1_and_sasl2() { + tokio::time::timeout(Duration::from_secs(90), async { + let server = Server::start(false).await; + for (tls13, binding) in [ + (true, "tls-exporter"), + (true, "tls-server-end-point"), + (false, "tls-server-end-point"), + ] { + for sasl2 in [false, true] { + exchange(&server, tls13, binding, sasl2, false).await; + exchange(&server, tls13, binding, sasl2, true).await; + } + } + }) + .await + .expect("bounded STARTTLS integration test"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disabling_plus_hides_bindings_and_accepts_scram_y_with_mechanisms_only_ssdp() { + tokio::time::timeout(Duration::from_secs(45), async { + let server = Server::start(true).await; + for sasl2 in [false, true] { + exchange(&server, true, "none", sasl2, false).await; + } + }) + .await + .expect("bounded no-PLUS STARTTLS integration test"); +} diff --git a/tests/wss_interop.py b/tests/wss_interop.py new file mode 100644 index 0000000..628fe93 --- /dev/null +++ b/tests/wss_interop.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Verified-TLS browser-style RFC 7395 handshake regression. + +Requires Python 3.11+, websockets and openssl. Starts an HTTPS-only server +bound to loopback; no credentials, external network or system trust changes. +Usage: python3 tests/wss_interop.py [--binary target/release/rust-xmpp] +""" +import argparse +import asyncio +import json +from pathlib import Path +import socket +import ssl +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + +import websockets + +ORIGIN = 'https://conversejs.wthome.ru' +FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing' + + +def free_port(): + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + return probe.getsockname()[1] + + +def http_status(port, context, path='/xmpp-websocket', origin=ORIGIN, protocol='xmpp', address='127.0.0.1'): + """Send browser handshake headers over certificate-verified TLS.""" + headers = [f'GET {path} HTTP/1.1', f'Host: localhost:{port}', + 'Connection: keep-alive, Upgrade', 'Upgrade: websocket', + 'Sec-WebSocket-Version: 13', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='] + if origin is not None: + headers.append(f'Origin: {origin}') + if protocol is not None: + headers.append(f'Sec-WebSocket-Protocol: {protocol}') + with socket.create_connection((address, port), timeout=5) as tcp: + with context.wrap_socket(tcp, server_hostname='localhost') as stream: + stream.sendall(('\r\n'.join(headers) + '\r\n\r\n').encode()) + response = b'' + while b'\r\n\r\n' not in response: + chunk = stream.recv(4096) + assert chunk, response + response += chunk + return int(response.split(b' ', 2)[1]), response + + +async def exercise(port, context): + for path in ('/xmpp-websocket', '/xmpp-websocket/'): + async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context, + origin=ORIGIN, subprotocols=['xmpp'], open_timeout=5) as ws: + assert ws.subprotocol == 'xmpp', ws.subprotocol + await ws.send(f'') + opened = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5)) + assert opened.tag == f'{{{FRAMING}}}open', ET.tostring(opened) + features = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5)) + assert features.tag == '{http://etherx.jabber.org/streams}features', ET.tostring(features) + await ws.send(f'') + closed = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5)) + assert closed.tag == f'{{{FRAMING}}}close', ET.tostring(closed) + + + +def verify_original_origin_policy(binary, config, root, port, context): + """Reproduce the deployment mismatch before allowing the Converse page.""" + original = json.loads(json.dumps(config)) + original['global']['http_cors_override']['websocket']['origins'] = ['https://wthome.ru'] + path = root / 'original-origin-config.json' + path.write_text(json.dumps(original)) + with (root / 'original-origin.log').open('w+') as log: + server = subprocess.Popen([binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'original-origin server stopped during startup' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('original-origin HTTPS server startup timed out') + status, headers = http_status(port, context, origin='https://wthome.ru') + assert status == 101, headers + status, headers = http_status(port, context, origin=ORIGIN) + assert status == 403, headers + except BaseException: + log.flush() + log.seek(0) + print('ORIGINAL ORIGIN SERVER LOG:\n' + log.read()) + raise + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp')) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='xmpp-wss-interop-') as temporary: + root = Path(temporary) + cert, key = root / 'cert.pem', root / 'key.pem' + 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(key), '-out', str(cert)], + check=True, capture_output=True) + port = free_port() + interfaces = ['127.0.0.1'] + try: + with socket.socket(socket.AF_INET6) as probe: + probe.bind(('::1', 0)) + interfaces.append('::1') + except OSError: + pass + config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], + 'https_interfaces': interfaces, 'c2s_ports': [], 's2s_ports': [], + 'http_ports': [], 'https_ports': [port], + 'log_format': 'json', + 'modules_enabled': ['tls', 'websocket', 'saslauth'], + 'http_cors_override': {'websocket': {'origins': [ORIGIN]}}, + 'ssl': {'certificate': str(cert), 'key': str(key)}}, + 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}} + path = root / 'config.json' + path.write_text(json.dumps(config)) + context = ssl.create_default_context(cafile=str(cert)) + verify_original_origin_policy(args.binary, config, root, port, context) + with (root / 'server.log').open('w+') as log: + server = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log) + try: + for _ in range(100): + assert server.poll() is None, 'server stopped during startup' + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + else: + raise AssertionError('HTTPS-only server startup timed out') + # Normal browser Origin, with token-list Connection header. + status, headers = http_status(port, context) + assert status == 101, headers + assert b'sec-websocket-protocol: xmpp' in headers.lower(), headers + if '::1' in interfaces: + status, headers = http_status(port, context, address='::1') + assert status == 101, headers + for origin in ('https://PRIVATE-ORIGIN.example', ORIGIN + ':8443', 'null'): + status, headers = http_status(port, context, origin=origin) + assert status == 403, (origin, headers) + for protocol in (None, 'chat', 'XMPP'): + status, headers = http_status(port, context, protocol=protocol) + assert status == 400, (protocol, headers) + status, headers = http_status(port, context, path='/PRIVATE-PATH?token=PRIVATE-TOKEN') + assert status == 404, headers + # Native clients may omit Origin; they must still negotiate xmpp. + status, headers = http_status(port, context, origin=None, protocol='chat, xmpp') + assert status == 101, headers + try: + http_status(port, ssl.create_default_context()) + except ssl.SSLCertVerificationError: + pass + else: + raise AssertionError('untrusted TLS certificate was accepted') + asyncio.run(exercise(port, context)) + log.flush() + log.seek(0) + logged = log.read() + events = [json.loads(line) for line in logged.splitlines() if line.startswith('{')] + http_events = [e for e in events if e.get('module') == 'http'] + assert any(e['event'] == 'connection-open' for e in http_events), logged + listeners = [dict((f['key'], f['value']) for f in e['fields']) + for e in http_events if e['event'] == 'listener-ready'] + assert len(listeners) == len(interfaces), listeners + assert all(e.get('transport') == 'https' for e in listeners), listeners + assert any(e['event'] == 'tls-established' for e in http_events), logged + requests = [dict((f['key'], f['value']) for f in e['fields']) + for e in http_events if e['event'] == 'request-complete'] + for status in ('101', '400', '403', '404'): + assert any(r.get('status') == status for r in requests), (status, requests) + assert all(r.get('route') in ('websocket', 'bosh', 'other') for r in requests), requests + assert all(secret not in logged for secret in + ('PRIVATE-PATH', 'PRIVATE-TOKEN', 'PRIVATE-ORIGIN', 'dGhlIHNhbXBsZSBub25jZQ==')), logged + except BaseException: + log.flush() + log.seek(0) + print('SERVER LOG:\n' + log.read()) + raise + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + print('PASS: HTTPS-only configured interfaces (' + ','.join(interfaces) + '), 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') + + +if __name__ == '__main__': + main()