diff --git a/doc/dm_arch.md b/doc/dm_arch.md index 50535c6d..ec9135a1 100644 --- a/doc/dm_arch.md +++ b/doc/dm_arch.md @@ -44,7 +44,10 @@ AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная пор Автор подписывает все поля до sig. conv связывает автора с получателем. Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`. В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID. -Размер plaintext сообщения ограничен 1024 байтами. seq — положительное int64. +Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый +descriptor (56 байт), поэтому на зашифрованные метаданные остаётся до 968 байт. +Парсер входящего wire допускает до 1024 байт ciphertext plaintext отдельно от descriptor. +seq — положительное int64, ts — миллисекунды NTP-времени instance. ```text [kind:1][name_len:2][duration_ms:4][width:2][height:2][UTF8_name:name_len][waveform:100, только voice] @@ -74,6 +77,7 @@ tags, точный размер и хеш; владелец публикует ## Владение и данные - `dm_core`: беседы, история, outbox и подтверждение получателя. +- `dm_history`: поколения, hidden, точные отпечатки удалённых сообщений и манифест локальных путей. - `dm_mailbox`: промежуточные сообщения суперузла и квитанции. - `dm_crypto`: вывод conv_id, content_key и nonce сообщений без БД/сети. - `media_crypto`: общие CCM и потоковые файловые примитивы для DM и групповых вложений. @@ -91,6 +95,9 @@ dm_messages(conv_id, dir, seq, ts, author, ct, data, sig) dm_outbox(conv_id, seq, body, retry_at) dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at) dm_mail_receipts(recipient, sender, seq, receipt, group_id) +dm_history(conv_id, generation, hidden) +dm_deleted_messages(conv_id, dir, seq, body_hash, media_id, media_receipt) +dm_local_files(path, conv_id, generation, busy, retry_at) ``` Запись исходящего сообщения, увеличение seq и запись неизменяемого body в outbox — @@ -121,10 +128,21 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра беседы и сброса счётчиков. Полный снимок выдаётся только после успешной загрузки. Исправление и отказные проверки описаны в [dm_audit_findings.txt](dm_audit_findings.txt). -У каждой локальной очереди таймер 1с; порция обработки до 32 задач, повтор задачи -через 5с. retry_at предотвращает голодание хвоста очереди. Перед отправкой проверяется -backpressure router. Потеря пути сохраняет durable задачу; остановка отменяет таймеры, -но сохраняет БД. При старте очередь продолжает работу. +Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач; +локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый. +Обычный retry_at — через 5с; исходящий файл с квитанцией проверяется через 3600с, +неудачная уборка локального пути повторяется через 30с. Это время допуска следующей +попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router. +Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД. +При старте медиа сбрасывает retry_at/downloading; текстовые очереди используют сохранённый retry_at. + +Все DM API вызываются в uasync-потоке. Порядок запуска в `chat_service_start`: +mailbox (включая media coordinator), core/history, media/file_transfer; к этому времени +SQLite, router и media_async уже живы. Порядок остановки: media_quiesce прекращает +новые задачи и закрывает transfer/CM handles; media_async_destroy завершает workers +и callbacks с живыми owners; затем уничтожаются media, core и mailbox, после них группы/чат. +SQLite принадлежит ядру и остановкой DM не закрывается. `dm_media_destroy` при живой +работе логирует ошибку и оставляет owner; заменять drain таким вызовом нельзя. ## Подтверждения и протокол @@ -200,14 +218,39 @@ TTL текстовых сообщений не введён; недельный Текстовая история, подписи и сроки хранения M этим переходом не меняются. Подробности и регрессии: [dm_audit_findings.txt](dm_audit_findings.txt), раздел11. -Control `ETCP_RT_ID_DM_MEDIA=0x38`: OFFER, STORE, STORED, RECEIPT, DELETE, -DELETE_ACK, EXPIRED, REJECT; точные форматы заданы в `dm_media.h` и реализациях owners. +Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт команды. +Все многобайтные поля DM/control имеют native-endian порядок; исключение — +зашифрованный attachment_info с big-endian полями, описанный выше. + +| Команда | Body | +| --- | --- | +| OFFER=1 | recipient:8, holder:8, expires:8, file-message | +| STORE=2 | recipient:8, file-message | +| STORED=3 | UUID:16, expires:8 | +| RECEIPT=4 / DELETE=5 | файловая квитанция:152 | +| DELETE_ACK=6 / EXPIRED=7 | UUID:16 | +| REJECT=8 | UUID:16, reason:1 (1 — недостаток квоты) | + Подпись router обязательна. Media receipt содержит `DMFILE01`, conv, seq, author, recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт. Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`, `dm_media_holders` сохраняют состояния и tombstones. Все выданные grants M учитываются при удалении; повторный STORE не продлевает TTL. +A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes +в общей группе A/B, исключая A/B и все прежние назначения этого UUID. Назначение +фиксируется до STORE; S/M могут быть одним узлом, local control обрабатывается синхронно. +Успешная передача control в router или локальный обработчик не означает durable успех. + +EXPIRED относится к промежуточной копии. B помечает ещё не готовый файл expired, +но продолжает попытки получить оригинал у A. После проверки исходного ciphertext, +публикации и сохранения квитанции файл снова ready; поздний EXPIRED его не меняет. +Готовый файл A/B остаётся в локальной истории до явного удаления беседы. +При удалении busy-счётчик каждого пути удерживает его до callback worker/transfer; +уборщик работает по старым поколениям, использует busy=-1 для своего захвата +и синхронизирует каталог после удаления. Пользовательский исходник не входит в уборку, +если он не был явно передан как временный файл приложения. + Qt и Android получают историю через события ядра: 41 — conv + JSON сообщений, 45 — JSON бесед. Снимки ограничены 200 записями. Отправка вложения вызывает `dm_send_file` в uasync; длительная обработка выполняется в `media_async`. @@ -239,14 +282,16 @@ Android использует общие `ConversationComposer`/`ConversationMess ## Проверки -Аудит 26.10.2026 частичный: полностью сверены `dm_crypto.c/.h`, прочитаны `dm_core.c` -и `dm_history.c`; контракты остальных owners и платформенные callers ещё не проверены полностью. +Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ, +`pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers. +Это проверка соответствия документации; свежая интерактивная проверка клиентов и +новые платформенные сборки не выполнялись. Подробности и границы проверки — +[dm_audit_findings.txt](dm_audit_findings.txt), раздел 12. Первоначальная проверка отказа SQLite выявила сброс счётчиков; дефект исправлен. `test_dm_load` проверяет 79 отказных сценариев: чтение, bind/step/finalize, короткие ключи и X25519; открытие/приём/отправку/JSON/invite до и после удаления; отдельные отказы подготовки файла, отсутствие беседы, restart и продолжение seq. При отказе сравниваются все поля DM-таблиц и отсутствие SQL-записей/событий успеха. -Исправление не означает завершения аудита остальных owners. `test_dm_names`: 68 случаев с точным сравнением имени в SQL и JSON при создании, загрузке, штатной записи, fallback из nodes и перезапуске. ASCII и 2/3/4-байтовые @@ -278,6 +323,10 @@ SQL rollback регистрации, состояния JSON и отмена о обоих UPDATE и commit, точный rollback назначения/job, успешный повтор, дубликат и поздний STORED. Сетевой сценарий теряет EXPIRED, блокирует SQL-сброс, перезапускает S и проверяет восстановление из сохранённого expires, состояние M и отсутствие ложной квитанции. +На M проверяются 11 SQL-отказов retirement: rollback сохраняет custody и не выдаёт ACK; +после restart M/S потерянный DELETE_ACK восстанавливается по tombstone и повтору DELETE. +После EXPIRED и возврата A проверяются точный оригинал на B, настоящая файловая +квитанция, очистка S и невлияние позднего EXPIRED на готовый файл. `test_dm_worker`: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки; work выполняется вне uasync, done в его потоке, heartbeat продолжает работать. @@ -289,6 +338,12 @@ Destroy отменяет уже posted completion, callback вызывается PCM в worker, остановка до completion без регистрации и удаление отклонённого cache. Qt `test_voice_file`: реальные PCM/Opus, повреждённые файлы и отмена async decoder. +Заключительный прогон 27.10.2026: штатные `test_dm`, `test_dm_history`, `test_dm_names`, +`test_dm_load`, `test_dm_e2e`, `test_dm_invite`, `test_dm_media`, `test_dm_worker` — 8/8 PASS. +Отдельный строгий JSON-парсер прочитал все 273 снимка имён. Заголовки проверены в C99/C++20; +в заключительном этапе менялись только документация, комментарии и форматирование деклараций. +Результаты старых сборок ниже относятся к указанной дате, а не к этому прогону. + Проверка 01.10.2026: чистые сборки Qt и Android APK; полный `check.sh` — 117 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root), proxy/burst/load PASS. Qt CTest: 7 PASS, 0 FAIL; Android APK собран после `clean`. diff --git a/doc/pm.txt b/doc/pm.txt index 3052a56c..4da0009c 100644 --- a/doc/pm.txt +++ b/doc/pm.txt @@ -1,4 +1,4 @@ -Работа лички +Работа лички — требования и реализация 1. отдели логику лички от логики групповых чатов. не смешивай. @@ -9,4 +9,25 @@ время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов). для доставки медиа поднимаем линк (conn_mgr). -и посмотри реализацию медиа для групповых чатов. +Контракты реализации проверены 27.10.2026: src/dm/ и doc/dm_arch.md. + +«Напрямую» означает доставку endpoint через живой маршрут общей CHAT-группы, +в том числе с BGP-транзитом; физический линк между A и B не обязателен. +Беседа имеет собственные историю/seq/outbox и не создаёт отдельную TOPO_GROUP. +Общие с групповыми вложениями примитивы: media_async, шифрование потоков, +attachment_info, voice_file; DM не использует групповой HAVE_BLOCK и его хранилище. + +S выбирается по подписанному adm_tags supernode=yes, M — storage=yes в общей группе A/B. +Роли независимы и могут совмещаться на одном узле. A сохраняет исходящую задачу +до подписанной квитанции B; STORED от S/M не означает доставку получателю. +Текстовая квитанция подтверждает commit сообщения, файловая — проверку ciphertext, +публикацию файла и сохранение результата. S повторяет удаление у M до DELETE_ACK +либо завершения подтверждённого срока назначения; сохраняются служебные tombstones. + +На M настройки чата dm_media_ttl_sec=604800 и dm_media_storage_mb=1024 задают TTL +в секундах и квоту в МиБ. Повтор STORE не продлевает TTL; при нехватке квоты новое +размещение отклоняется, подтверждённая копия не вытесняется до срока. +Текстовые pending не имеют TTL. Истечение копии M не удаляет оригинал A и не мешает +B получить его после возвращения A; локальное удаление истории не отзывает копии S/M. + +Подробные wire-форматы, жизненный цикл и проверки: doc/dm_arch.md и src/dm/*.h. diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index 00f72fcf..68c5bb9f 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -117,8 +117,8 @@ int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content struct dm_delete_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро до завершения запроса. */ char conv_id[64]; /* Десятичный ID беседы с NUL. */ - uint64_t generation; /* Ненулевое ожидаемое поколение. */ - uint64_t request_id; /* Корреляция результата с запросом UI. */ + uint64_t generation, /* Ненулевое ожидаемое поколение. */ + request_id; /* Корреляция результата с запросом UI. */ }; void dm_delete_trampoline(void* arg); /* После commit удаления отменить ожидающий invite этой беседы; квитанции/другие беседы не меняет. */ @@ -174,10 +174,10 @@ enum dm_invite_result { }; struct dm_invite_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро до итогового callback. */ - uint64_t request_id; /* Ненулевой ID; cancel использует тот же ID. */ - uint64_t peer_node_id; /* Фиксированный получатель, не локальный узел. */ - uint64_t channel_id; /* Выбранная активная CHAT-группа отправителя. */ - uint64_t generation; /* Заполняет uasync до регистрации; проверяет callback. */ + uint64_t request_id, /* Ненулевой ID; cancel использует тот же ID. */ + peer_node_id, /* Фиксированный получатель, не локальный узел. */ + channel_id, /* Выбранная активная CHAT-группа отправителя. */ + generation; /* Заполняет uasync до регистрации; проверяет callback. */ char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */ char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */ int cancel; /* 0=создать, 1=отменить ожидающий request_id. */