diff --git a/doc/chat_audit_findings.txt b/doc/chat_audit_findings.txt index 8665139fc..dc9bf5e41 100644 --- a/doc/chat_audit_findings.txt +++ b/doc/chat_audit_findings.txt @@ -856,3 +856,789 @@ ACK/событие — после него. Нулевой INSERT при сущ результатов и решения автора сохранены. Runtime/wire/схема/штатные тесты не менялись; новые fullcheck/sanitizers/GUI/APK не запускались. Чеклист остаётся без новых отчётов, частичные отметки attachment/chat[~] сохранены. По AGENTS.md:10 дальнейший аудит остановлен. + +Исправление приёма REQUEST — 27.10.2026, runtime760398b9 + +ak_accept_request владеет BEGIN IMMEDIATE/COMMIT и отклоняет внешнюю транзакцию +до чтения/записи без её изменения. Проверки identity,подписанного членства и авторства +выполняются внутри транзакции. Сначала читается существующий запрос:при отсутствии +вставляется ровно одна строка;дубликат incoming1/state0,2,3,4 сохраняет created_at +и решение. State2/4 повторно ставит GRANT,state3 DENY,state0 ждёт решения. +Другие состояния/направление отклоняются. Проверяются prepare,все bind,step,finalize, +число записанных строк и commit. ak_body также проверяет SQL-этапы и размеры identity/key. +ACK и событие отправляются только после commit;отказ ACK не удаляет сохранённый запрос. +Ошибки содержат channel/peer/command/stage/rc,успех — duplicate/state/queued без секретов. +Общий ak_rollback отменяет операцию;если транзакция осталась открыта,модуль снимает +callback и запрашивает stop,владелец закрывает БД после poll. Wire/схема/API не менялись. + +Новый Linux test_attachment_requests использует настоящие core/SQLite,подписанного +мембера и историю через chat_core_submit_message. Локальная строка вложения — фикстура; +публикация/криптография/сетевая доставка дополнительно проверяются полным E2E-сценарием. +247 SQL-отказов с успешным повтором и дубликатом:новый запрос/state0/2/3/4,пустая или +существующая outbox,BEGIN/COMMIT,identity/member/author/request lookup,insert/outbox, +каждый prepare/bind/step/finalize/changes. Дополнительно настоящий commit veto, +ABORT/IGNORE вставки запроса и вставки/обновления очереди,внешняя транзакция с собственным +маркером,неверные state/direction/hash/flags,удалённый мембер,нет маршрута/backpressure/ +ошибка send. Readonly reader непосредственно в ACK и событии проверяет точные +key/error/request/state/created_at/outbox/command/retry и соседнюю запись. +Rollback fault не даёт ACK/события,снимает callback и запрашивает stop;destroy владельцем +откатывает открытую транзакцию. Баланс выделений восстановлен. + +test_encrypted_attachment получает первый настоящий REQUEST через проверенный router, +открывает внешнюю транзакцию перед сервисным callback и проверяет отказ без ACK/события +и без изменения внешнего маркера. Внешний владелец откатывает маркер и перезапускает +автора отдельным таймером вне callback сервиса. У B остаются state0 и REQUEST outbox1; +через штатные5 секунд повтор действительно проходит сеть. Перед настоящим REQUEST_ACK +независимый reader уже видит incoming1/state0 и autocommit1. Далее прежние проверки +решения автора,GRANT fault/retry,расшифровки,restart B,cache restore и удаления канала +завершаются phase10 PASS. Подписи/криптография/доставка не подменяются. +Первый лог:/tmp/utun_encrypted_attachment_ky1Lm2/test.log. + +Сборка native и первичный профиль4/4 PASS. Полный ./check.sh212/212 PASS,0 skipped, +proxy9/9,burst1/1,load8/8,134217728байт,173с. Прогон в частном netns с IPv6 loopback, +настройки сети хоста не менялись. Логи:/tmp/attachment-request-fix-full-check.log, +/tmp/attachment-request-fix-full-logs. ASAN/UBSan/LSan16/16 PASS,detect_leaks=1, +halt_on_error=1,core/libuasync/Opus инструментированы согласованно в отдельной копии. +Логи:/tmp/attachment-request-fix-asan-results.log,/tmp/attachment-request-fix-asan-logs. +Desktop vibechat и Android assembleDebug PASS;GUI/Android host11/11 PASS,включая +utun_node,редактирование/удаление/события/ответы и6 вариантов audio lifecycle. +Сборки и профиль:/tmp/attachment-request-fix-{build,desktop-build,android-build,gui-results}.log. + +Задача закрыта,заголовок и encrypted_attachments/service_lifecycle согласованы. +Полный аудит attachment/chat остаётся частичным. Продолжается сверка исходящего +создания запроса,файловых completion,удаления и восстановления;отметки[~] сохранены. +По новой инструкции пользователя дефекты исправляются и проверяются по мере аудита, +с последующим обновлением текстов,без остановки на обнаружении дефекта. + +Исходящий REQUEST — 27.10.2026, runtime d1bfd1d1 + +Подтверждён пропуск вставки attachment_key_requests при SQLITE_DONE/changes0: +RAISE(IGNORE) оставляет outbox REQUEST без запроса,после чего прежний таймер +сообщает об успехе. Новый постоянный тест до runtime-правки завершился ожидаемым +assert:reader в событии показал request0/state-1/command1/autocommit1. +Полный короткий лог:/tmp/attachment-outgoing-negative.log,исходная libutun не менялась. + +ak_request_file теперь владеет BEGIN IMMEDIATE/COMMIT,под блокировкой перечитывает +downloaded/wanted/ready/decrypting/error/key и отсутствие прежнего запроса. +Проверяет все prepare/bind/step/changes1/finalize запроса и общей ak_queue,затем +commit;только после него идут событие и отправка. Общий rollback сохраняет прежние +строки. Сам tick откладывает файлы и outbox при внешней транзакции без её изменения. +Если rollback потребовал quiesce/stop,tick больше не вызывает pump и не ставит таймер. +Чтение выбранного файла проверяет размеры identity/key,указатели,пути и finalize. +Wire/схема/API не менялись. Ошибки содержат этап/код/канал/автора,успех — committed. + +test_attachment_requests дополнен52 SQL-отказами с успешным повтором и дубликатом: +пустая/существующая очередь,BEGIN/COMMIT,pending/eligibility read,insert request, +outbox,каждый prepare/bind/step/finalize и число изменённых строк. +Дополнительно ABORT/IGNORE запроса/INSERT/UPDATE outbox,настоящий commit veto, +сохранность внешней транзакции до создания и при уже готовой к отправке очереди, +rollback fault с проверкой stop/unbind/отсутствия таймера и откатом при destroy. +Readonly reader проверяет обе строки перед событием и REQUEST,соседний ключ не меняется. +Нет маршрута/backpressure/send fault→настоящий restart→запрос,очередь и дата сохранены; +init сбрасывает retry_at,следующий проход повторяет REQUEST без нового события/запроса. +Расширенный тест PASS,баланс выделений восстановлен:/tmp/attachment-outgoing-unit.log. +Первичный native-профиль requests/decisions/results/chat_attachment/encrypted_attachment5/5 +PASS:/tmp/attachment-outgoing-profile.log и /tmp/attachment-outgoing-profile-logs. +Санитайзеры и общая итоговая проверка продолжаются;полный аудит attachment остаётся[~]. + +Перед этой правкой upstream4d87cab2 объединён merge8a0654e5:изменения Android-жестов +и документация DM сохранены без конфликтов;native runtime в upstream не менялся. +После merge Android assembleDebug PASS:/tmp/attachment-request-fix-merge-android-build.log. +Merge и проверенный REQUEST fix отправлены в origin обычным push8a0654e5. + +Завершение файлов — 27.10.2026, runtime4d19de7c/b71c3040 + +Захват расшифровки теперь проверяет prepare/три bind/step/changes1/finalize и commit +до worker. Файл принадлежит списку instance до окончания записи результата; +лимит16 включает workers и завершения,ожидающие SQLite. Completion владеет своей +транзакцией,различает отсутствующую запись и ошибку lookup,проверяет все SQL-этапы +метаданных/ready либо error. На SQL-отказе задача остаётся в списке до следующего tick, +криптография не повторяется. Внешняя транзакция остаётся нетронутой. Событие/публикация +следуют после commit. При stop после media_async_destroy освобождаются также ожидающие +завершения;delete отменяет их по отсутствию строки,rollback fault запрашивает stop. +Секреты очищаются при окончательном освобождении. Wire не менялся. + +test_chat_attachment:9 SQL-отказов claim,15 успешного completion,8 записи ошибки +настоящего worker с недоступным контейнером;ABORT/IGNORE,настоящий commit veto, +внешняя транзакция,удаление канала/stop/restart с ожидающим результатом,rollback fault. +Readonly reader проверяет точные key/info/type/ready/decrypting/error до worker и +в событии. Повтор completion сохраняет один worker;plaintext сверяется побайтно. +16 авторских workers действительно шифруют файлы,их завершения задерживаются отказом +lookup. Семнадцатая отправка не принимается и сохраняет source;снятие отказа публикует +все16 ровно по одному разу. Баланс выделений восстановлен. + +Отдельный отрицательный стенд собран со старой согласованной sanitizer-libutun и +headers из /tmp/utun-grant-key-sanitizers до обновления этой копии. Настоящая публикация +и расшифровка,один bind NOMEM при записи info. Reader в событии увидел ready1, +decrypting0,error0,info_bytes0,key_bytes32,autocommit1:подтверждён ложный успех. +Ожидаемый exit1,defects1,баланс выделений восстановлен,ASAN/UBSan/LSan ошибок нет. +Бинарник:/tmp/attachment-jobs-negative,исходник/.mk рядом;полный короткий лог: +/tmp/attachment-jobs-negative.log,сборка:/tmp/attachment-jobs-negative-build.log. +Рабочая копия для отрицательного запуска не менялась. Профиль native7/7 PASS: +/tmp/attachment-jobs-profile.log и /tmp/attachment-jobs-profile-logs. + +Первый расширенный sanitizer-профиль7/8 обнаружил ошибку самой фикстуры:после restart +отказ COMMIT/ROLLBACK мог попасть в фоновый hash commit db_sync вместо completion. +Лог:/tmp/attachment-jobs-asan-logs/test_chat_attachment.log. Транзакции completion +помечены SQL-комментарием назначения,инъекции привязаны к ним;commit veto также +относится только к нужному callback. Повторный итоговый профиль16/16 PASS ниже. + +Удаление канала — 27.10.2026, runtimecd6ca238/6a6f8db2/c3ac2059 + +До исправления chat_core_delete_channel удалял ключи,plaintext и ciphertext раньше +метаданных канала. Постоянная регрессия с настоящим BEFORE DELETE channels ABORT +показала reader_keys2 вместо3,plaintext_bytes-1,event0 при сохранённом канале. +Ожидаемый assert:/tmp/attachment-delete-negative.log;это отдельный подтверждённый +дефект удаления,не падение штатного сценария. Сетевые ресурсы уже были отменены. + +Модуль устанавливает attachment_channel_delete AFTER DELETE channels:ключи,запросы +и outbox удаляются той же SQLite-транзакцией. Проверка полного каскада отклоняет +также RAISE(IGNORE),не позволяя сохранить часть локальных записей. Низкоуровневое +удаление проверяет finalize и отсутствие channels/Merkle после DELETE;savepoint +откатывает в том числе DROP TABLE. Внешняя транзакция у этого низкого API сохраняется, +но chat_core_delete_channel отклоняет её до отмены ресурсов. Неуспешный rollback +с открытой БД отключает attachment и запрашивает stop/закрытие владельцем после poll. +NULL instance не разыменовывается. Новые форматы сети не вводились,БД получила trigger. + +После commit выполняются media_index cleanup и очистка собственных канонических +decrypted/channel/UUID.file. Другие имена,подкаталоги и temp сохраняются. I/O-отказ +логируется и допускает повтор очистки;он не возвращает канал после commit. +SQL-отказ сохраняет записи и файлы,но сетевые ресурсы уже могут быть отменены — +повтор команды завершает удаление. Completion активного worker сам убирает свой +результат,увидев отсутствие строки. Утверждение об общей транзакции ФС/БД не вводится. + +test_chat_attachment:14 отказов — ABORT/IGNORE channels/keys/requests/outbox/Merkle +и prepare/bind/step/finalize DELETE channels. Дополнительно настоящий commit veto, +внешняя транзакция с сохранением маркера и rollback fault с остановкой/закрытием БД. +Reader на отказе видит точные key/info/type/request/state/created/command/retry и +историю/производный Merkle namespace. Файлы сохраняются. В единственном событии +CHANNEL_DELETED reader уже видит отсутствие всех шести объектов. Соседние ключи +сохраняются,повтор успешен,баланс выделений восстановлен. Native профиль9/9 PASS: +/tmp/attachment-delete-profile.log,/tmp/attachment-delete-profile-logs. + +Общая проверка последующих исправлений — HEAD618f6ca0 + +./build.sh --no-gui PASS;./check.sh212/212 PASS,0 skipped,proxy9/9,burst1/1,load8/8, +134217728байт,177с. Изолированный netns с IPv6 loopback,настройки хоста не менялись. +ASAN/UBSan/LSan16/16 PASS,detect_leaks=1,halt_on_error=1;core/libuasync/Opus +согласованно инструментированы в отдельной копии. Профиль:requests/decisions/results/ +chat_attachment/encrypted_attachment/attachment_send/chat_admin/services/instance_contract/ +media_primitives/db_sync/db_sync_integrity/db_sync_schema/chat_feedback/dm_history/android_replies. +Desktop vibechat и Android assembleDebug PASS;GUI/Android host11/11 PASS,включая6 +вариантов audio lifecycle с core stop/restart. Логи и полные каталоги: +/tmp/chat-attachment-followup-{build,full-check,asan-results,desktop-build,android-build,gui-results}.log, +/tmp/chat-attachment-followup-full-logs,/tmp/chat-attachment-followup-asan-logs. + +Три задачи закрыты. Полный аудит chat/attachment остаётся частичным:привязка +message_id/hash,наблюдение manifest,запрос загрузки,очередь отправки и восстановление +требуют оставшейся сверки. Внешний checklist сохраняет[~],отчёты туда не добавляются. + +Идентичность и восстановление локального индекса — 27.10.2026,продолжение от6803369a + +Авторская привязка message_id/hash теперь неизменяема:собственная транзакция, +проверенные7bind/step/changes/finalize/commit,точный повтор разрешён. Подтверждённый +старый bind NOMEM возвращал ложный0:/tmp/attachment-binding-negative.log. +UUID-префикс принимает только канонический base64url;15 aliases завершающих битов, +ошибки padding и NULL отвергаются с сохранением выходного массива. + +Observe собственной транзакцией сверяет полную идентичность channel/UUID/message/ +author/hash. Точный дубликат не перезаписывает ключ,метаданные и состояние. Отказы +SQL/OOM возвращают-1 для повтора,неверный manifest/коллизия — -2 без повтора. +Wanted атомарно сохраняет намерение/сброс ошибки и ready отсутствующего cache; +downloaded сохраняет наличие и policy wanted. Новая проверенная передача очищает +ошибку,обнаружение прежнего cache сохраняет её;неизменный повтор не посылает событие. +Все эти операции отклоняют внешнюю транзакцию и уведомляют только после commit. + +Отказ производного индекса не отменяет MSG_RECEIVED уже сохранённой истории. +Backfill перечитывает подписанные сообщения,отделяет текущий batch перед работой, +повторяет SQL-ошибки через2с. Лимит16 каналов и полный scan при переполнении проверены +17 настоящими каналами. Startup включён до первичного backfill;manual download +восстанавливает индекс перед wanted. История не содержит и не восстанавливает ключ. +Настоящий сетевой A/S/B/C сценарий проверяет отказ первого observe наB,событие при +reader_history1/index0 и восстановленную точную запись перед первым REQUEST. + +Предположение об усечении достижимого manual manifest на4096байтах было неверным: +db_sync ограничивает весь подписанный JSON4000байтами. Лишнее динамическое выделение +убрано;обработчики используют DB_SYNC_MESSAGE_MAX. Постоянный тест передаёт3845байт +тела с23 настоящими подписанными блоками до порта загрузки;превышение общего лимита +отклоняется до публикации с точным сообщением. Диагностика повторной активной +передачи относится к media и явно сохраняет существующую задачу. + +Outbox отдельно фиксирует retry_at=now+5 до сети,проверяя чтение,команду и одну +обновлённую строку. Tick10с,до16 команд.12 инъекций,ABORT/IGNORE/veto и rollback +проверяют отсутствие отправки до commit,точную прежнюю дату и успешный повтор. +Idle scan освобождает транзакцию через ROLLBACK,не вызывает commit hook. +Первый профиль6/7 выявил лишний idle commit при настоящем veto;исправлен runtime, +исходное ожидание теста сохранено. Итог7/7:/tmp/attachment-pump-idle-profile.log. + +Recovery при открытой транзакции теперь откладывается до её завершения:непроверенное +удаление истории не даёт права удалять опубликованные файлы. Init также отклоняет +внешнюю транзакцию до изменения таблиц/таймера/router. Постоянная регрессия удаляет +историю внутри BEGIN,проверяет прежние файлы/readonly record и открытую транзакцию +после destroy,затем rollback/restart сохраняют тот же снимок. +Первые два запуска этой новой фикстуры остановились ДО операции из-за моей ошибки +sizeof(content)-1 вместо фактически записанных sizeof(content);это не отрицательные +доказательства runtime-дефекта. Фикстура исправлена,итог PASS: +/tmp/attachment-recovery-fixed.log. Подтверждение исходного риска здесь основано на +чтении recovery,а не на этих падениях фикстуры. + +Дополнены test_chat_attachment:13 SQL-отказов binding,25 observe,60 wanted/downloaded, +полные readonly-снимки до событий,ABORT/IGNORE/veto/rollback/foreign transaction, +recovery/startup/17-channel overflow/manual manifest. test_attachment_requests +сохраняет прежние247 входящих и52 исходящих инъекции,добавляет12 outbox. + +Настройки и локальные события — 27.10.2026 + +chat_setting_get_state писал в const state через общий int_buf и затирал предыдущее +числовое представление при следующем getter. Теперь int_text хранится по индексу, +обновляется только setter;чтение не меняет ни байта и не выделяет память. NULL-имя +безопасно,integer getter для строкового типа возвращает def. Валидация выполняется +до lazy init,ошибка не меняет нулевое/заполненное состояние. Пустой путь разрешён. +Сохранение state отделено от runtime apply;пустой общий apply удалён. LAN failure +возвращает-3,желание сохранено,идентичный повтор повторяет apply. Контракт уточнён +в chat_setting.h;существующие потребители читают настройки при обращении/старте. +test_chat_setting:const snapshot,ссылки двух чисел,копирование состояния,NULL/type +defaults,ошибки/переполнение,255байт/очистка пути,отказ LAN и повтор. Native PASS, +ASAN/UBSan/LSan PASS. Это дополнительная штатная цель,общее число стало213. + +chat_local.c полностью сверен с header:доставка ссылки не запускает JOIN,permissions +проверяются в callback,nonce/подпись обновляют адреса только offline-узла во всех +группах,desktop/mobile lifecycle uasync. Уточнены lifetime/события/apply в header. +chat_event дополнил отсутствовавшие диагностические имена48–54/58/60/65–67 и fallback +для незанятого ID;preparation отвергает NULL target/UUID,header уточняет dm: prefix +и запрет дампа LOCAL_INVITE. Полная сверка всех event payload ещё продолжается. + +Проверки этого блока до общей проверки: +Native7/7:/tmp/attachment-pump-idle-profile.log;settings/attachment2/2: +/tmp/chat-settings-profile.log. ASAN/UBSan/LSan17/17,detect_leaks=1,halt_on_error=1: +/tmp/chat-index-recovery-asan-results.log,/tmp/chat-index-recovery-asan-logs; +изолированная согласованная копия на40ae7827. Последующая правка имён событий0995a1ea +в этот sanitizer-профиль не входит. Полный check/desktop/Android запущены отдельно, +их результат не подразумевается этим промежуточным отчётом. Аудит остальных chat/ продолжается. + +Общая проверка блока — 27.10.2026, runtime0995a1ea,clock fixture3309bee7 + +Native build PASS. Первый полный прогон212/213 остановился на сравнении created_at +SQLite с libc time() у границы секунды. Отдельное измерение за3с подтвердило258 +расхождений:SQLite epoch1793067822,libc1793067821,точные часы1793067822000005775ns. +Runtime не менялся:фикстура сравнивает timestamps с SQLite unixepoch и логирует +точные границы при отказе. Повторный полный check.sh213/213 PASS,0 skipped; +proxy9/9,burst1/1,load8/8,134217728байт,195с. Приватный netns с IPv6 loopback, +сетевые настройки хоста не менялись. Логи: +/tmp/chat-index-recovery-final-full-check.log,/tmp/chat-index-recovery-final-full-logs; +первый отказ:/tmp/chat-index-recovery-full-check.log и его full-logs. + +Desktop vibechat и Android assembleDebug PASS;11/11 GUI/Android host PASS, +включая шесть вариантов audio lifecycle. ASAN/UBSan/LSan17/17 PASS на40ae7827, +как указано выше. Логи:/tmp/chat-index-recovery-{build,desktop-build,android-build, +gui-results,asan-results}.log. git diff --check PASS. Контракты attachment/local/ +setting закрыты;chat_core/event/feedback/join/sync и остальные chat/ ещё проверяются. + +Feedback и poll — 27.10.2026,продолжение от1e5fc2d3 + +Новая постоянная регрессия со старым runtime подтвердила:bind NOMEM при поиске +прежнего feedback ошибочно выглядел как отсутствие записи. Подписанная старая +реакция заменяла более новую отмену. Настоящие SQLite/Ed25519/Merkle и readonly +reader;ожидаемый assert:/tmp/chat-feedback-lookup-negative.log. Этот отрицательный +результат относится к lookup версии,не к доказательству каждого другого SQL-отказа. + +Get проверяет prepare/все bind/step/finalize,меняет out только при найденной полной +строке. Put проверяет8bind,step,changes1,finalize до пересчёта Merkle. Page/hash +также проверяют чтение/параметры/finalize;ошибка не становится пустой страницей +или нулевым листом. apply,set,page и init используют собственную BEGIN IMMEDIATE/ +COMMIT. Set выбирает версию и проверяет допуск под той же блокировкой,включая no-op. +Открытая внешняя транзакция запрещена до изменений. После отказа выполняется +только rollback,без RELEASE,который мог бы зафиксировать частичные изменения при +ошибке rollback. Оставшаяся открытой транзакция требует stop/закрытия БД владельцем +вне callback. Событие и пробуждение обмена следуют после проверенного commit. + +test_chat_feedback:20 SQL-отказов get/put/begin/commit с успешным повтором,10 page/ +hash-отказов,отдельное воскрешение старой версии,ABORT/IGNORE/реальный commit veto, +внешний no-op/page и финальный rollback failure. В событии readonly соединение +проверяет точные версию/data/signature и равенство сохранённого корня. На отказах +нет события,старые данные/корень сохраняются;close после rollback failure не +публикует незавершённую запись. Старые сценарии exclusion/restart/signer сохранены. +Профили native6/6 и ASAN/UBSan/LSan6/6 наd89a86c9 PASS: +/tmp/chat-feedback-{profile,asan-results}.log,соответствующие каталоги logs. + +chat_poll.c полностью сверен вместе с codec. Target/snapshot/JSON fields/arrays/ +options/validation проверяют SQL-этапы до интерпретации/публикации. Ошибка чтения +не становится отсутствующим полем или нулевой сводкой. Emoji-строка проверяется +целиком,встроенный NUL с хвостом не принимается как совпадение каталога. +Header уточняет байтовые пределы UTF-8/общий wire2048,частичный выход codec при +отказе,порядок encode/decode,владение JSON и семантику переключения/отмены. + +Отдельно устранён неоднозначный результат команды:после успешной публикации/ +feedback commit последующее чтение сводки могло вернуть-1,хотя действие уже +сохранено. Для poll_create/vote/react память ack резервируется до изменения. +При отказе readback возвращается0 с ch/message_id/committed:true/snapshot_pending:true. +Qt/Android сохраняют прежнее отображение и повторяют только read-only feedback. +Повторный read-отказ с request_id0 не запускает бесконечный цикл и не повторяет +публикацию/переключение. CLI-контракт обновлён;у обычного feedback ошибка остаётся +ошибкой чтения. Android headless учитывает новый отрицательный код apply LAN -3 +вместо ложного set:true;этот отдельный C-файл прошёл host C99 syntax check. + +test_chat_poll:51 prepare/bind/step/finalize-отказ в validation/шести fields/target/ +snapshot/choices/options сохраняет сообщения,реакцию,корень и события;каждый повтор +успешен.8 отказов readback после настоящего commit возвращают успешный ack;чтение +по его ID не меняет повторно число сообщений/корень/события.2 отказа памяти ack до +изменения также сохраняют состояние. Полный emoji с NUL-хвостом отклоняется. +Итог:/tmp/chat-poll-ack-test.log. Desktop vibechat,APK,GUI/Android host11/11 и JVM +155/155 (31 suite,0 errors/skipped) PASS:/tmp/chat-feedback-final-{desktop-build, +android-build,gui-results}.log. + +Первый расширенный sanitizer build остановился при линковке test_member_adapter: +tmpfs/tmp16ГиБ был заполнен,не хватило места. Параллельный full check также не +создал пригодного лога,его результат не засчитывается. Своя sanitizer-копия1.6ГиБ +перенесена в /home/user/.cache/utun-audit/grant-key-sanitizers,старый /tmp-путь — +symlink,остальные файлы не удалялись. Повторные full check и sanitizer21 запущены +с префиксом/tmp/chat-feedback-space-;результаты записываются отдельно после завершения. + +Повторная общая проверка feedback/poll — runtime d741eb73 + +Native build/check.sh213/213 PASS,0 skipped;proxy9/9,burst1/1,load8/8, +134217728байт,174с. Лог:/tmp/chat-feedback-space-full-check.log и full-logs. +ASAN/UBSan/LSan21/21 PASS,detect_leaks=1,halt_on_error=1;согласованные core/ +libuasync/Opus в собственной копии,без смешения объектов. Логи: +/tmp/chat-feedback-space-asan-results.log,/tmp/chat-feedback-space-asan-logs. +Desktop/Android/GUI11/JVM155 PASS указаны выше. Последующая маленькая правка +Android headless1b0034e1 не входит в APK/CMake-targets;отдельная C99 syntax проверка +этого C-файла PASS. Внешние отметки feedback/poll закрыты,прочие chat/ продолжаются. + +Атомарное создание канала — 27.10.2026,после1c5faaff + +Создание раньше сохраняло описание/схему/runtime до проверки подписи root JOIN. +Правильный отрицательный контроль отбирает именно112-байтовое JOIN-тело с +публичным ключом instance по смещению72:/tmp/chat-channel-root-negative.log. +Первая проба выбирала вторую подпись по счётчику и задевала другое57-байтовое +runtime-тело;она не доказывала отказ root JOIN и заменена точным условием. + +Теперь direct create проверяет канонический ID1..INT64_MAX,локального владельца, +NUL-имя<=127байт,обе пары ключей и подпись описания. Подписи root JOIN,приглашения +и профиля готовятся до SQL. Своя BEGIN IMMEDIATE объединяет новое описание, +таблицы,полный root-member и Merkle;занятый ID не заменяется. Автор повторно +проверяется из сохранённых блоков до commit. Внешняя транзакция отклоняется. +Неудачный rollback с оставшейся транзакцией запрашивает stop,закрытие остаётся +владельцу БД вне callback. CHANNEL_UPDATED и member-notification идут после commit. + +Runtime подготавливается после commit;его ошибка логируется как отложенная +активация,сохранённый канал остаётся успешным результатом. Повтор +ensure_channel_ready либо restart завершает подготовку. Полный откат SQL вместе +с файловыми/сетевыми ресурсами не имитируется. Auto проверяет все этапы независимой +генерации X25519 и raw Ed25519 seed,размеры извлечённых ключей,RNG и подписи; +очищает временную структуру OPENSSL_cleanse и возвращает ID>0 только при commit. +Native headless передаёт отказ либо created:true/channel_id;trampoline очищает +переданную структуру с приватными ключами перед освобождением. + +Один codec описания используется создателем и двумя JOIN/invite-проверками: +id\0||name\0||owner(native u64)||X25519(32)||Ed25519(32). Он проверяет границы +и не меняет выход при отказе;существующий порядок байтов wire сохранён. +Generic channel_put имеет собственный savepoint для схемы/индексов/метаданных; +отказ ROLLBACK TO не сопровождается RELEASE частичных изменений. + +Дополнительная настоящая регрессия выявила отдельный дефект:ошибка шестого bind +профильной подписи становилась NULL,а канал объявлялся созданным. Контроль: +/tmp/chat-channel-profile-bind-negative.log. member_block_put теперь проверяет +все11 bind,step,changes=1,finalize и передаёт ошибку общей транзакции. + +Новый test_chat_channel проверяет42 SQL-отказа и19 криптоотказов с отдельным +успешным повтором каждого,реальные ABORT/IGNORE и commit veto,чужую транзакцию, +ошибку rollback с последующим owner-close,отказ выделения SQL и сохранность +runtime-групп. Readonly-соединение сверяет канальные поля/ключи,схему и все +Merkle-хеши до/после отказа и уже из обработчика события;root-профиль проверяется +настоящей Ed25519-подписью. Проверены канонические ID,точный wire348,неизменность +error-output,предельные имя127/codec255 и INT64_MAX,несогласованные ключи/владелец/ +подпись,занятый ID. Настоящий локальный TCP/uasync API выдаёт ok:false при отказе +BEGIN и committed channel_id при повторе. Allocation balance восстановлен. +/tmp/chat-channel-boundaries-{build,test}.log — PASS;профиль13/13 до последнего +расширения SQL-проверок — /tmp/chat-channel-profile.log. Новые общие результаты +записываются отдельно после завершения;весь chat_core/chat/ ещё не закрыт. + +Общая проверка создания канала — runtime60942e54,test8003413d + +check.sh214/214 PASS,0 skipped;proxy9/9,burst1/1,load8/8 и134217728байт, +207с:/tmp/chat-channel-final-full-check.log,каталог full-logs того же префикса. +ASAN/UBSan/LSan13/13 PASS,detect_leaks=1,halt_on_error=1,согласованные core/ +libuasync/Opus:/tmp/chat-channel-final-asan-results.log и +/home/user/.cache/utun-audit/chat-channel-final-asan-logs. +Desktop vibechat и Android APK/JVM155/155 (31 suite,0fail/error/skipped) PASS; +GUI/Android audio host11/11 PASS. Логи:/tmp/chat-channel-final-{desktop-build, +android-build,gui-results}.log. Fetch не принёс удалённых изменений;аудит продолжается. + +Аватарки групп — 27.10.2026,послеf378c7b2 + +Полностью прочитаны group_avatar.c/.h,wire/lifecycle,doc/group_avatar.txt и +связанные Qt manager/tests. Точный negative control на прежнем runtime: +отказ третьего bind UPDATE (SHA256) даёт result0/events1 при NULL hash. +Лог:/tmp/chat-avatar-sql-negative.log;ожидаемое падение assert,не успешная проверка. + +set/apply теперь владеют короткой BEGIN IMMEDIATE:допуск,ключ,чтение/выбор версии, +проверка подписи и запись защищены одной блокировкой. Все пять bind,step, +changes1,finalize и COMMIT проверяются. Чужая транзакция запрещена даже для +no-op;no-op освобождает блокировку ROLLBACK без commit hook. Ошибка откатывается +без уведомления. Если rollback оставляет транзакцию открытой,запрашивается stop, +а владелец закрывает БД вне callbacks. При равном изображении даже предельная +версия INT64_MAX допускает no-op;новая публикация при исчерпании версии запрещена. +Временный приватный ключ очищается OPENSSL_cleanse на каждом выходе. + +get проверяет SQL-этапы,типы/размеры,hash и подпись,копирует результат только +после успеха. Общие channel_get_priv/get_privs также проверяют все SQL-этапы и +ровно32 байта BLOB,сохраняют оба выхода при отказе,очищают временные копии. +Проверка member_files_member теперь отклоняет ошибку bind/finalize,не выдавая +допуск по незавершённому чтению. Schema-init включает чтение PRAGMA и ALTER +в один savepoint;неудачный ROLLBACK TO никогда не сопровождается RELEASE +частичных ALTER. Схема допускает внешнюю транзакцию,commit остаётся её владельцу. + +Получатель сначала завершает транзакцию и всю работу с peer/сборкой,затем +вызывает CHANNEL_UPDATED. До изменения этот путь обращался к peer после +синхронного callback;новый сетевой тест отменяет все avatar peer-задачи прямо +из этого события. Это проверка новой границы lifetime;старый UAF отдельно не +воспроизводился. STATE/wire/пределы16 TX+16 RX/таймеры1/15/30с сохранены. + +test_group_avatar_sql:27 SQL-отказов set с успешными повторами (ACL/key/read/ +pubkey/write/BEGIN/COMMIT),16 отказов публичного get/ключей с неизменными +выходами,9 отказов schema-init с повторами,signing failure,ABORT/IGNORE/veto, +apply error/retry/no-op,чужая транзакция,предельная версия,readonly bytes/events +до уведомления,ошибка rollback/owner-close. Ключи NULL/31/33/TEXT отклонены; +schema ROLLBACK TO failure оставляет частичный ALTER только в незавершённой +транзакции,отдельный reader видит прежнюю схему и после owner-close. +/tmp/chat-avatar-matrix-{sql,network,channel,admin,services,member-files}-test.log: +6/6 PASS. Исправлена ошибка сборки нового fixture (не хватало openssl/evp.h) +до запуска;старый бинарник вместо неуспешной сборки не засчитывался. +Header/doc уточнены после реализации,включая fallback JPEG128/96/64 и фактический +предел16*1024*1024 пикселей Qt. Общие результаты записываются после завершения. + +Общая проверка аватарок — runtimee645ef30,test2c691f98 + +check.sh215/215 PASS,0 skipped;proxy9/9,burst1/1,load8/8,134217728байт, +202с:/tmp/chat-avatar-final-full-check.log и full-logs этого префикса. +ASAN/UBSan/LSan17/17 PASS,detect_leaks=1,halt_on_error=1,согласованные core/ +libuasync/Opus:/tmp/chat-avatar-final-asan-results.log и +/home/user/.cache/utun-audit/chat-avatar-final-asan-logs. Включены новые SQL/ +сетевые avatar,channel,admin,services,member_files и настоящий21-фазный сетевой +member_files_network,JOIN,attachment,feedback/poll. Desktop/APK build PASS; +GUI12/12,включая test_group_avatars,и JVM155/155 (31 suite) PASS. +Логи:/tmp/chat-avatar-final-{desktop-build,android-build,gui-results}.log. +Fetch не принёс изменений;group_avatar.h/doc отмечены[V],прочий chat/ продолжается. + +Снимки участников и диагностика — 27.10.2026,послеd3c5565c + +Полностью прочитаны chat_member.c/.h и chat_status.c,сверены реальные GUI/JNI +потребители и существующие group-scoped тесты services/radio. Negative control +/tmp/chat-member-negative.log:ошибка finalize снимка даёт rc0/count1;новый +test_chat_member на прежнем коде ожидаемо упал. Теперь все SQL-этапы завершаются +до публикации out;отказ оставляет list NULL/count0 и прежний single-buffer. +Callback свойств использует тот же single-query вместо предварительного SELECT. + +Предел полного списка65535 согласован с u16 count события. Превышение запрещено +целиком;рост массива ограничен,квадратичная insertion sort заменена qsort по +online DESC,unsigned node_id ASC. Сериализатор дополняет обе строки NUL, +включая хвост avatar_file. NULL/stopped/malformed trampoline-запросы логируются +и освобождаются. Общий тихий validator канонического ID1..INT64_MAX используется +описанием create/JOIN и member API;caller добавляет диагностический контекст. + +radio_set_member_muted теперь владеет BEGIN IMMEDIATE/COMMIT;проверяются bind, +step,changes1,finalize,COMMIT и finalize загрузки кэша. Память резервируется +до записи,содержимое кэша/аудио меняется после commit. Чужая транзакция +отклоняется;rollback failure с открытой транзакцией запрашивает owner-stop. +Чат готовит снимок до переключения и меняет в нём committed флаг без нового +SQL-чтения после commit. Удалённая аудио-callback группа не получает устаревшее +MEMBER_UPDATED. Сам отказ последующего чтения не превращает переключатель +в ложную ошибку. Отдельного negative control старого radio-пути не запускали. + +Фиксированные8192-байтовые буферы status/metrics с неограниченным накоплением +snprintf заменены checked strbuf;при OOM/форматировании нет частичного события. +В strbuf устранено сужение size_t к32-битному allocator и небезопасное удвоение +capacity;новый init с2^32 отклоняется до выделения нулевого блока. Node-name +lookup проверяет SQL/нулевой размер и очищает допустимый выход при ошибке. +Бинарный conn-list ограничен250 до переполнения count,links/inflight насыщаются; +member-detail проверяет finalize,насыщает счётчики,его parser принимает короткий +INVALID ответ с нулевыми полями неверного запроса,совпадающий с producer. + +Новый test_chat_member:9 SQL/память отказов снимка с повторами,отказ property +callback без события,9 SQL/память отказов mute с повторами,ABORT/IGNORE/veto/ +foreign txn/read-error after commit/failed rollback+owner-close. Независимый +reader,audio callback и display event подтверждают согласованность DB/cache/ +звука/флага после commit. Проверены4 SQL-отказа detail,4 name lookup,неверные +trampoline args/NUL,точные65535 sorted records/event и отказ65536 без усечения, +status/metrics с300 линками>8КиБ,отказ strbuf без частичного события и повтор, +граница allocator2^32;учёт аллокаций вернулся к baseline. +PASS:/tmp/chat-member-final-unit.log. Ранний профиль member/services/radio3/3 +до расширения matrix:/tmp/chat-member-first-{test,services,radio}.log. + +Диагностические ошибки fixture отражены отдельно:после неуспешной сборки +(не хватало ETCP include) случайно запущен прежний бинарник;его PASS не засчитан. +Новый собранный fixture затем упал на NULL ack_q искусственных300 линков: +core817181,/home/user/.cache/utun-audit/chat-member-matrix.core,backtrace однозначно +указал queue_total_bytes в collect_conn_metrics. Добавлена действительная пустая +ACK-очередь,а не NULL вместо обязательного ресурса;новый запуск прошёл. +Это ошибка fixture,не доказательство runtime-багa. Позднее имя node fixture +задано явно,чтобы проверка не зависела от fallback Me. Новая общая проверка +runtime41b35033/testcec3e27a выполняется отдельно;весь chat/ ещё не закрыт. + +Общая проверка участников — runtime41b35033,testcec3e27a + +check.sh216/216 PASS,0 skipped;proxy9/9,burst1/1,load8/8,134217728байт, +207с:/tmp/chat-member-final-full-check.log и full-logs этого префикса. +ASAN/UBSan/LSan19/19 PASS,detect_leaks=1,halt_on_error=1;согласованные core/ +libuasync/Opus,включая заново инструментированный strbuf. Логи: +/tmp/chat-member-final-asan-results.log и +/home/user/.cache/utun-audit/chat-member-final-asan-logs. В профиль вошли +chat_member/radio,avatar,channel/admin/services/member_files network,JOIN, +attachment,feedback/poll. Desktop/APK build и JVM155/155 (31 suite) PASS; +GUI/Android audio12/12 PASS:/tmp/chat-member-final-{desktop-build,android-build, +gui-results}.log. chat_member.h отмечен[V];core/headless и весь chat/ продолжаются. + +JSON-интроспекция — 27.10.2026,после774fb66b + +Точный negative control:/tmp/chat-json-negative.log. Допустимое подписанное +имя канала с ASCII01 возвращалось channels_json без полного escaping: +json_valid=0,ожидаемое падение нового test_chat_json. Старый channels writer +также увеличивал указатель по неограниченному snprintf-return;нулевой/малый +буфер не проверялся. Старые listing игнорировали ряд bind/step/finalize и +могли вернуть успешный префикс при отказе вложенного чтения/последующих строк. + +Channels/members теперь строят checked strbuf с общим полным JSON escaping, +копируют результат после завершения всех SELECT и проверки caller capacity. +Messages проверяет оба bind,step/finalize и арифметику оставшегося буфера; +ошибка очищает prefix. Все API сбрасывают out_len0,очищают допустимый buf, +отклоняют неверный context/канал/offset/размер и не пишут при capacity0. +Профильные TEXT читаются без сужения/потери hidden NUL;неверные key/address +типы/размеры отклоняются. Messages сериализует поля SQLite json_object. +Полные имена/8192-byte adm_tags не обрезаются;IPv6 форматирует inet_ntop, +protocol6 показывается TCP+REALITY. Deleted-member показывается offline. + +После собственного commit канала runtime может быть отложен. Channels_json +проверяет наличие history-таблицы и показывает committed канал с msgs0, +не создавая таблицы во время чтения. Прочие SQL-ошибки count/peers/addresses +возвращаются как ошибки чтения,а не нулевые счётчики. Caller-транзакция не +завершается при успешном/неуспешном listing. Header/CLI контракт уточнён. +Бинарный snapshot member.h явно отделён от расширенной JSON-интроспекции. + +Новый test_chat_json:36 prepare/bind/step/finalize/text/strbuf-отказов с +успешными повторами,включая SQL-ошибку после полностью сериализованной строки; +проверяются out_len0/пустой output и неизменность total_changes. Нулевые, +малые,ровно-недостаточные и ровно-достаточные capacity защищены canaries. +Проверен roundtrip каждого ASCII-control1..31,UTF-8/quotes/backslash, +255-байтового имени с расширением escaping,8192-byte tags,IPv4/IPv6/REALITY. +Hidden NUL,неверные размеры/типы ключей/адресов и неканонический stored ID +отклонены без усечения;caller txn сохраняется. Настоящий отказ u_calloc +db_sync_instance_add после commit оставляет новый канал видимым с msgs0; +ensure_channel_ready повторно активирует runtime. Allocation balance восстановлен. +PASS:/tmp/chat-json-final-unit.log. До расширения matrix native5/5 PASS: +/tmp/chat-json-first-{test,adapter,schema,poll,services}.log. Общая проверка +runtime29e15254/testcb0b71ae выполняется отдельно;весь chat_core/chat/ ещё частичен. + +Чтобы повторные проверки не заполнили tmpfs,завершённые собственные fixture +utun-channel-create/utun-member-sql перенесены в +/home/user/.cache/utun-audit/completed-fixtures;прежние пути сохранены symlink. +Недоступные каталоги root не трогались;чужие артефакты не удалялись. + +Общая проверка JSON — runtime29e15254,testcb0b71ae + +check.sh217/217 PASS,0 skipped;proxy9/9,burst1/1,load8/8,134217728байт, +206с:/tmp/chat-json-final-full-check.log и full-logs этого префикса. +ASAN/UBSan/LSan20/20 PASS,detect_leaks=1,halt_on_error=1,согласованные core/ +libuasync/Opus:/tmp/chat-json-final-asan-results.log и +/home/user/.cache/utun-audit/chat-json-final-asan-logs. Desktop/APK build и +JVM155/155 (31 suite) PASS;GUI/Android audio12/12 PASS: +/tmp/chat-json-final-{desktop-build,android-build,gui-results}.log. +Fetch без удалённых изменений;полный core/headless/profile/tags и chat/ продолжаются. + +Подписанные профили — 27.10.2026,после1843a492 + +Точный negative control:/tmp/chat-profile-negative.log. Getter прежнего runtime +возвращал0 и менял выходные поля после injected finalize error. Теперь JSON, +подпись и версия собираются во временные буферы;SQL завершается до проверки +JOIN/update-подписей и всего JSON. При любом отказе все выходы сохраняются. +Требуются собственная идентичность,точные BLOB/INTEGER/TEXT,положительные +версии,плоский объект с уникальными строковыми ключами без скрытого NUL. +Общий private chat_json_strings проверяет объект SQLite JSON;callback строит +только отбрасываемый временный результат. Отдельной проверки raw UTF-8 нет. + +Options/avatar владеют BEGIN IMMEDIATE;чтение/проверка старого JOIN и профиля, +допуск storage-узла,выбор версии,подпись,member/nodes/Merkle и commit образуют +одну операцию. Повреждённые подписанные поля не исправляются переподписью; +неподписанные старые атрибуты не переносятся. Версия max(now,previous+1) +ограничена INT64_MAX. Семантическое сравнение decoded key/value сохраняет +версию/подпись при перестановке ключей/другом escaping;no-op делает ROLLBACK +без commit hook/события. Ошибка сохраняет данные/Merkle;внешняя транзакция +запрещена. Неудачный окончательный rollback запрашивает stop/закрытие owner. +Уведомления идут после commit без SQLite cursor,channel ID скопирован заранее. +Обновление собственного member по всем каналам сначала завершает снимок списка; +каналы обновляются независимо,ошибки логируются. Глобальное переименование +и сохранение UI-state этим блоком не закрыты. + +member_sync не делает RELEASE после неудачного ROLLBACK TO. Собственная +savepoint-операция пробует полный ROLLBACK;чужую транзакцию должен откатить +caller. owner_put проверяет все bind/step/changes1/finalize,включая nullable +поля;node_update_verified проверяет changes1 и не признаёт IGNORE успехом. +В member_files ACL исходная bind/step ошибка больше не затирается чтением +несуществующих SQLite columns. Логи показывают этап и исходный rc. + +Apply-подписки используют deferred removal до завершения внешнего dispatch; +отмена себя/другого и вложенная доставка безопасны. Namespace копируется +перед property callback;после него group/connection заново ищутся,поэтому +удаление канала не оставляет заимствованную ссылку. Приём страниц уже вызывал +apply callbacks после commit — перенос этой фазы не требовался. +add_apply_cbk возвращает0/-1,chat_sync_init проверяет отказ и очищает частичный +старт. Destroy сервиса по-прежнему выполняется вне его callbacks. + +json_flat декодирует Unicode escapes и surrogate pairs,отклоняет U+0000, +одиночный surrogate,неверный hex и raw C0. Следующие поля,включая attachment +policy,больше не теряются после escaped имени. Старые послабления object +синтаксиса и ранний выход get сохраняются и явно описаны в json_flat.h; +строгий профиль использует полную проверку chat_json_strings. Encoder профиля +экранирует все C0 без усечения. + +Новая test_chat_profile:20 SQL-отказов reader,54 writer,9 owner,4 avatar ACL, +успешные повторы;sign/verify,ABORT/IGNORE/commit veto,внешняя транзакция, +неудачный nested ROLLBACK TO и полный ROLLBACK. Readonly соединение сверяет +все member/nodes/Merkle до события. Проверены повреждённые старые подписи/JOIN, +signed duplicate JSON,отбрасывание unsigned атрибутов,semantic no-op без hook, +Unicode/control и следующее policy-поле,строгий JSON,публикация/удаление avatar. +Реальный отказ аллокации JOIN-подписки откатывает chat_service_start,сохраняя +ядро;повтор успешно стартует. Удаление канала из committed callback безопасно; +при unrecovered rollback owner stop/DB close сохраняют readonly данные. +test_member_adapter дополнен nested dispatch/self/other unsubscribe и +освобождением владельца namespace из property callback. Allocation baseline +восстановлен. PASS:/tmp/chat-profile-final-{unit,adapter}.log. +Общие проверки текущего блока выполняются отдельно;core.h/core_priv.h и +оставшиеся core/tags/headless/JOIN/sync/invite/whisper ещё не закрыты. + +Общая проверка профилей — runtimef66c4e92,test59d8065f + +check.sh218/218 PASS,0 skipped;proxy9/9,burst1/1,load8/8,134217728байт, +203с. Логи /home/user/.cache/utun-audit/chat-profile-full-check.log и +chat-profile-full-logs в том же каталоге. ASAN/UBSan/LSan сначала20/21: +stack-use-after-return в test_chat_profile matches,глобальный selector оставался +на локальный query после owner_faults. Runtime по этому отчёту не обвиняется. +Selector очищен до выхода;повтор пересобранного test_chat_profile PASS с +detect_leaks=1/UBSan halt_on_error=1. Остальные20 тестов PASS;совокупно21/21. +Первый неуспешный лог сохранён:chat-profile-asan-logs/chat_profile.log; +исправленный повтор:chat-profile-asan-retry.log,сводка:chat-profile-asan-results.log. +Обычный профильный тест также пересобран и повторён после исправления fixture. +Desktop/APK build PASS,JVM155/155 (31 suite),GUI/Android audio13/13 PASS, +включая test_avatars и test_group_avatars. Логи chat-profile-{gui-build, +gui-results,android-build}.log в указанном каталоге. Fetch не обнаружил +удалённых изменений;core/tags/headless и оставшиеся модули chat/ продолжаются. + +Редактор прав участников — 27.10.2026,послеf0d864bf + +Точный negative control:chat-tags-negative.log в /home/user/.cache/utun-audit. +Прежний load возвращал editor после injected finalize error;новая регрессия +ожидаемо завершилась assertion134. Теперь load проверяет bind/step/fields/ +finalize,точные ключи/подпись/source,весь owner JSON и канальную подпись, +закрывает cursor до публикации. Отсутствие строки/placeholder/ошибка дают NULL. + +Editor хранит исходный owner JSON/подпись и желаемые поля. Commit владеет +BEGIN IMMEDIATE,повторно читает проверенную запись и сравнивает baseline; +изменившийся owner возвращает-2 без перезаписи. Новая версия/история ролей, +JSON и editor подготавливаются до member_sync_put и COMMIT. Ошибка сохраняет +editor,member/nodes/Merkle и отсутствие уведомлений;повтор не дублирует историю. +После commit editor обновляется без аллокаций,далее идут уведомления без +SQLite cursor. No-op делает ROLLBACK,сохраняя подпись/версию без commit hook. +Чужая транзакция запрещена,unrecovered rollback запрашивает owner stop/close. +Канальный private key очищается на всех путях после получения/подписи. + +Owner ver проверяется одним API editor/member_sync:пустой блок0,непустой +плоский объект с уникальными строковыми полями и канонической ver1..INT_MAX. +Знак/ведущий ноль/суффикс/overflow отвергаются вместо atoi. Общий member read +проверяет finalize и не обрезает TEXT/hidden NUL до сравнения версии. +admin/moder принимают желаемые yes/no,сохраняют прежнюю историю и дописывают +e/d при смене роли,включая удаление желаемого ключа. +Encoder полностью экранирует C0;строгая замена не принимает невалидный JSON, +нестроковые/повторные ключи,NUL,неподходящий ver или превышение лимитов. + +GUI trampoline применяет частичный объект к свежему snapshot,поэтому поля, +неизвестные форме,и неуказанные роли не исчезают. Необязательный ver проверяет +исходную версию формы;ошибка логируется без commit. Android проверяет setter +каждого флага и не фиксирует частичный набор при отказе. Display и JNI больше +не усекaют admin/moder историю до256 байт,что могло скрыть последнее отключение. + +test_chat_profile дополнен37 SQL fault/retry (включая begin/member savepoint/ +release/commit,чтение/private key/owner write/JSON),sign/veto/failed nested +rollback,10 allocation fault/retry через current snapshot,strbuf и prepared +editor. Независимый reader сверяет member/nodes/Merkle до committed callback; +состояние editor и число событий сохраняются при отказе. Проверены stale -2, +no-op без hook,повторные правки одного editor,строгий JSON/C0 и диапазон версий, +частичный GUI patch/stale/malformed request,foreign txn. История>256байт с +последним d показывает выключенную роль;новый e дописывается с сохранением +всего префикса. Удаление канала из committed callback безопасно,editor остаётся +собственностью caller;allocation baseline восстановлен. +PASS:chat-tags-memory-test.log и chat-tags-final-{unit,adapter}.log. + +Общая проверка owner editor — runtimec8e1d120,test104b7026 + +Первые full/Android сборки остановились на недостающем include member_sync.h +в JNI,не на тестовом runtime. Исправление — c8e1d120;первые логи сохранены: +chat-tags-{full-check,android-build}.log. Повтор check.sh218/218 PASS,0 skipped, +proxy9/9,burst1/1,load8/8,134217728байт,187с:chat-tags-retry-full-check.log +и retry-full-logs. ASAN/UBSan/LSan21/21 PASS,detect_leaks=1,halt_on_error=1: +chat-tags-asan-results.log и chat-tags-asan-logs. Desktop/APK build PASS, +JVM155/155 (31 suite),GUI/Android audio13/13 PASS;логи chat-tags-{gui-build, +gui-results,android-retry-build}.log в указанном каталоге. Fetch без удалённых +изменений. Core.h остаётся частичным:глобальное имя/UI-state/lifecycle и +headless/JOIN/sync/invite/whisper продолжаются;новых отметок[V] не добавлено. + +Настройки и UI-state — 27.10.2026,после34e729d9 + +Точный negative control:/home/user/.cache/utun-audit/chat-preferences-negative.log. +Прежний void setter не проверял bind/step/finalize;injected finalize error +после неявного commit оставлял новое значение в readonly соединении. +Новая регрессия ожидаемо assertion134. Теперь setter возвращает0/-1 и владеет +BEGIN IMMEDIATE до сравнения/записи:checked read,UPSERT,changes1,finalize и +COMMIT. No-op делает ROLLBACK без hook. Отказ сохраняет прежнее значение, +внешняя транзакция запрещена;неудачный rollback запрашивает owner stop/DB close. +В диагностике ключ/этап/rc/длина,значения UI-state не печатаются. + +Sound setter возвращает результат сохранения,рассылка выполняется только +после собственного commit/no-op. Идентичный повтор повторяет best-effort +доставку,не создавая DB commit. Getter возвращает0/1,отсутствие записи1, +ошибку-1;допускает TEXT ровно1байт0/1. Initial sync не отправляет неудачное +чтение;Android UI выбирает локальный fallback1 после залогированной ошибки. +Settings wrapper возвращает исходные0/-1/-2/-3 registry и применяет +group_autoconnect только после успешной валидации. Fixed-array trampoline +проверяют обе NUL-строки/ID до чтения и освобождают ошибочный request. + +Новая corruption-регрессия сначала упала на BLOB '1':getter проверял тип +после column_text,уже изменившего представление SQLite. Тип теперь читается +до преобразования;тот же порядок исправлен для signed profile getter/update. +Добавлена проверка корректно подписанного JSON,хранимого как BLOB:чтение и +переподпись запрещены. Первый лог:chat-preferences-final-unit.log; +исправленные:chat-preferences-type-{unit,profile}.log в указанном каталоге. + +В SoundSettingsPage::applyAndSave ключ брался из временного toUtf8 QByteArray +и использовался после конца выражения. QByteArray теперь хранится до полного +копирования queued request;allocation/size error логируются. test_group_edits +дополнен настоящей формой с9 checkbox/9 slider,ожиданием uasync barrier и +чтением18 отдельных значений из общей DB. Отдельный negative control старой +Qt-формы не запускался;ошибка lifetime найдена при чтении функции. + +Новый test_chat_preferences:13 SQL fault/retry UI-state и13 sound,5 getter, +неверные boolean/types/NUL/ID,COMMIT veto,ABORT/IGNORE,foreign txn включая +no-op,validation без group side effect,malformed/NULL requests,неудачные +COMMIT+ROLLBACK с owner stop/close. Wrapper фактической рассылки проверяет +autocommit и сохранённое значение независимым readonly соединением; +отказ не рассылается,успешный retry/no-op рассылается. Allocation baseline +восстановлен. Native preferences/registry/profile/services4/4 PASS до +corruption extension;исправленный preferences/profile2/2 PASS после неё. +Общая проверка текущего блока выполняется отдельно;глобальное имя и lifecycle +core,headless/JOIN/sync/invite/whisper остаются в работе. + +Чтобы tmpfs не заполнялся повторными прогонами,завершённые собственные +utun-profile/preferences/channel-create/member-sql fixtures перенесены на SSD +с symlink прежних путей. Тем же способом сохранены четыре завершённых +chat-{json,member,avatar,channel}-final-full-logs. Root-owned тестовые каталоги +перенесены через sudo с сохранением данных/прав;чужие артефакты не удалялись. + +Общая проверка preferences — runtime93427950,test89e2a2f2/fe5cf910 + +check.sh219/219 PASS,0 skipped;proxy9/9,burst1/1,load8/8,134217728байт, +201с. ASAN/UBSan/LSan23/23 PASS с detect_leaks=1,halt_on_error=1,согласованными +core/libuasync/Opus. Desktop/APK build PASS,JVM155/155 (31 suite),GUI/Android +audio13/13 PASS;test_group_edits действительно проверяет все18 значений формы. +Логи /home/user/.cache/utun-audit/chat-preferences-{full-check,asan-results, +gui-build,gui-results,android-build}.log,full-logs и asan-logs рядом. +Fetch без удалённых изменений. Полный core/chat/ не отмечен завершённым: +продолжаются lifecycle/global name,headless/JOIN/sync/invite/whisper. diff --git a/doc/dm_arch.md b/doc/dm_arch.md index 50535c6dc..27e23fbdd 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,16 +218,44 @@ 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. -Qt и Android получают историю через события ядра: 41 — conv + JSON сообщений, -45 — JSON бесед. Снимки ограничены 200 записями. Отправка вложения вызывает +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_len:1][conv:decimal][generation:8][JSON сообщений]`, 45 — JSON бесед. +Запрос истории с ненулевым generation читается только для текущей видимой беседы; +0 захватывает текущее поколение в uasync. UI отбрасывает снимок другого поколения. +Снимки ограничены 200 записями. Отправка вложения вызывает `dm_send_file` в uasync; длительная обработка выполняется в `media_async`. Android копирует выбранный URI и сохраняет полученный файл через `Dispatchers.IO`; Qt сохраняет файл из контекстного меню в отдельном потоке. Выбор «как файл» сохраняет @@ -239,14 +285,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,17 +326,34 @@ 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 продолжает работать. Destroy отменяет уже posted completion, callback вызывается ровно один раз. +Linux `test_dm_media_cleanup`: 13 отказов SQL при чтении назначений, записи ACK +и очистке body; сохранность квитанции, повтор, restart S, потерянный/повторный/чужой ACK, +несколько M и поздний STORED. Сеть в этом component-тесте перехвачена; подпись B и +реальное удаление M проверяет `test_dm_media`. Ошибка finalize после успешного +autocommit не означает rollback уже выполненной одношаговой записи. + `test_attachment`: строгий формат, повреждения и граничные длины, реальное кодирование/декодирование 50 Opus-кадров, длительность и waveform. `test_attachment_send`: выбранная беседа после смены текущего target, освобождение 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` +и Linux `test_dm_media_cleanup` — 9/9 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/dm_audit_findings.txt b/doc/dm_audit_findings.txt index e4bb0c822..71b276c2b 100644 --- a/doc/dm_audit_findings.txt +++ b/doc/dm_audit_findings.txt @@ -1,8 +1,11 @@ -DM — аудит документации и контрактов, 26.10.2026 +DM — аудит документации и контрактов, 26–27.10.2026 База: /home/user/utun2, HEAD 03b050949cf36cc4868324be94a54eaddfdd494c. Задание: /home/user/utun-doc-audit-checklist.txt, раздел src/dm/ и связанные документы. +Текущий статус на 27.10.2026: аудит контрактов DM завершён, 10/10 файлов [V]. +Итог — раздел12; разделы1–11 сохраняют историю промежуточных проверок и исправлений. + 1. Объём и статус Полностью прочитаны dm_core.c/.h, dm_crypto.c/.h и dm_history.c/.h, doc/dm_arch.md, @@ -506,3 +509,101 @@ __real_sqlite3_bind_blob. После окончания правок повто /tmp/dm-expiry-fix-{sanitizers-build,sanitizers-results,sanitizers-test_dm*,full-check,netns}.log. Задача завершения TTL закрыта. Общий аудит DM частичный;прежние отметки сохраняются. GUI/Android/Windows/FreeBSD не проверялись;DELETE_ACK fault matrix отдельно не добавлялась. + +12. Завершение аудита контрактов DM — 27.10.2026 + +База заключительного этапа: /home/user/utun4, master, исходный HEAD07f63c3047b4a9bef6c568ca590d3fefde95816f. +Полностью сверены все семь заголовков src/dm/ с реализациями core, crypto, history, +mailbox, mailbox_media и media; doc/dm_arch.md, doc/pm.txt и doc/pm_invites.md. +Это завершение проверки описаний/API, не утверждение об отсутствии всех возможных +ошибок программы. Новых подтверждённых дефектов выполнения в этом этапе нет. + +В заголовках уточнены: +- Заимствование аргументов, один освобождаемый блок запросов UI, жизнь instance, + поток uasync и запрет доступа workers к SQLite/сети. +- Проверка тела/подписи отдельно от области router/ролей, точные повторы и конфликты, + границы транзакций при send/accept, подпись квитанции после commit входящего сообщения. +- Независимые квитанции сообщения и файла; STORED не удаляет исходящий outbox. +- Форматы DM/mailbox/media control, native-endian чисел и big-endian attachment_info, + лимиты send data с descriptor и размеры выходных буферов квитанций. +- Поколения/hidden, сохранение ключей и seq, отпечатки удалённых тел, отмена старых + invite/transfer и отбрасывание завершения worker старого поколения. +- Роли S/M, выбор нового storage без прежних назначений, фиксация назначения до STORE, + local S/M control и отличие передачи пакета от durable приёма. +- File holds, манифест путей, busy=-1 уборщика, повтор удаления и fsync каталога; + пользовательский исходник не передаётся уборке без явного remove_source. +- Запуск mailbox/core/media и остановка quiesce -> media_async drain -> destroy + с живыми SQLite/группами, сохранение данных между stop/restart. + +dm_arch согласован с порциями таймеров32/8, обычным retry5с и отдельными retry3600с/30с, +сохранённым retry_at текстовых очередей и сбросом медиа при restart. Добавлены wire +таблица, порядок владельцев и восстановление оригинала после EXPIRED: срок копии M +не запрещает B получить исходный файл A; поздний EXPIRED не портит готовый файл. +pm.txt дополнен привязкой требований к текущим owners/ролям/настройкам, dm_arch +больше не называет этот аудит частичным. Исторические результаты сборок помечены датой. + +Сверены относящиеся к контрактам callers: chat_service_start/stop, attachment_send, +file_transfer lookup/cancel, Qt DmInviteDialog/MainWindow/MessageList и bridge, +Android JNI/ViewModel/PmInviteDialog, headless DM handlers, регистрация JOIN-ключей +и планирование member_sync после JOIN_READY/принятия участника. +Клиентский код просмотрен для описанных DM-событий/выбора адресата/подготовки файла; +полный аудит Qt/Android и остальных общих модулей этим не заявляется. +pm_invites описывает владение запросом, поколение до регистрации и отсутствие +отката открытой беседы при последующей ошибке; отмена PM не отзывает JOIN-ключ. + +Проверки текущего этапа: +- make -C src -j4 и сборка всех девяти существующих DM-целей: PASS. +- Native test_dm/history/names/load/e2e/invite/media/worker/media_cleanup:9/9 PASS. + test_dm_load:79 проверок; names:68 случаев и273 снимка, независимый Python JSON + parser прочитал каждый полный снимок. Worker:25 запусков,50 heartbeat,max_gap11.3мс. + Media:phase12,6266 heartbeat,allocations0->0; проверены11 retirement SQL faults, + 18 release faults, потерянные control/restart и возврат точного оригинала после TTL. +- Linux cleanup component:13 SQL faults для scan/ACK/body, повтор после restart, + несколько M, потерянный/повторный/чужой ACK и поздний STORED; exit0. + Сеть перехвачена, receipt задан фикстурой; криптографию и физическое удаление M + проверяет отдельный test_dm_media. Одношаговый autocommit может уже завершиться + до искусственного finalize failure; тест проверяет это состояние и идемпотентный повтор. +- Каждый из семи заголовков: C99 и C++20 с -Wall -Wextra -Werror,14/14 PASS. + Для private/media C API C++ использует extern "C", как существующие клиенты; + include path lib/ соответствует проекту и предоставляет sqlite3.h. +- Все C-файлы DM побайтно совпадают с начальным снимком; C-токены всех заголовков + совпадают после исключения комментариев/пробелов. Алгоритмы, API/ABI, wire и SQL + этим этапом не менялись. git diff --check PASS; новые тесты не добавлялись. + +Логи и снимки: /tmp/dm-contract-finish-before/sha256.json, +/tmp/dm-contract-finish-{build,final-core-build,final-tests-build,headers,validation}.log, +/tmp/dm-contract-finish-native-{results,test_dm*}.log; исходные тела/JSON — в test artifacts. +Сетевой профиль выполнен вне sandbox. Первый sandbox-запуск cleanup вернул exit0, +но uasync сообщил Operation not permitted при создании служебного fd; окончательный +повтор вне sandbox прошёл без этих сообщений. Сетевые настройки хоста не менялись. +Новые full check.sh, ASAN/UBSan/LSan, сборки/интерактив Qt/Android/Windows/FreeBSD +не выполнялись; прежние результаты не приписываются заключительному прогону. +Изменения radio и doc/tasks.md сохранены отдельно от DM-коммитов; push не выполнялся. + +13. Rebase перед push — 27.10.2026 + +После отказа push (origin содержал новые коммиты) пользователь запросил rebase. +Получен origin/master492f6adc; все шесть DM-коммитов перенесены на эту базу. +Единственный конфликт — dm_messages_req в dm_core.h: сохранены generation из origin +и проверенные комментарии. Остальные пять патчей перенесены без изменения по range-diff. +Старые хеши и результаты раздела12 относятся к состоянию до переноса. + +В origin появился generation в запросе истории и CHAT_EVT_DM_MESSAGES: +[conv_len:1][conv][generation:8][JSON]. Сверены полный trampoline, проверка current, +новая регрессия test_dm_history и получение снимка Qt/Android. dm_arch уточнён под +этот формат. Все C-файлы DM побайтно совпадают с новым origin; токены семи заголовков +совпадают после исключения комментариев/пробелов. C99/C++20:14/14 PASS. + +Перед rebase сохранены21 незакоммиченных radio-файл, включая untracked, в stash +и отдельной резервной копии с SHA256/режимами. После переноса восстановлены все21: +18 файлов побайтно, doc/tasks.md/tests/Makefile.am/tools/chatgui/CMakeLists.txt +слиты с origin; списки добавленных/удалённых строк radio точно совпали с сохранёнными. +Повторная проверка после сборки также PASS; radio не включается в DM-коммиты. + +Свежий ./build.sh --no-gui -j4:PASS. После пересборки native DM9/9 PASS, +включая скрытые/устаревшие запросы истории и точное поколение опубликованного снимка. +Дополнительно radio/radio_mesh/radio_headless/radio_headless_queue4/4 PASS. +Это профиль13 бинарников, не полный check.sh; новые GUI/Android/sanitizer прогоны +не выполнялись. Радио EOS C++-профиль не повторялся, его исходники сохранены побайтно. +Логи:/tmp/utun4-dm-rebase-{build,results,radio-preservation,test_*}.log. +Резервная копия указана в /tmp/utun4-dm-rebase-backup-path.txt. diff --git a/doc/encrypted_attachments.md b/doc/encrypted_attachments.md index 60076df18..ee5a2464c 100644 --- a/doc/encrypted_attachments.md +++ b/doc/encrypted_attachments.md @@ -34,6 +34,13 @@ Android показывает зашифрованное вложение отд и crypto_UUID имеют разные назначения: первый идентифицирует сетевые блоки, второй — контейнер и ключ. Повтор доставки передаёт прежний контейнер. +Связь авторского ключа с `message_id` и SHA256 контейнера фиксируется собственной +транзакцией до публикации сообщения. Проверяются все SQL-этапы и одна строка; +точный повтор допустим, замена сообщения или hash запрещена. Внешняя транзакция +остаётся нетронутой, SQL-отказ сохраняет прежнюю пару. UUID-префикс принимает только +канонический base64url с правильными padding/завершающими битами и lowercase hex; +при отказе выходной UUID не изменяется. + ## Подписанное сообщение и контейнер В подписанном сообщении `ct=application/x-utun-encrypted`, базовое имя тела — @@ -83,6 +90,18 @@ Nonce метаданных использует `part=UINT64_MAX`; файл — Completion не удаляет файлы по заранее вычисленному имени: очистка учитывает, какие артефакты задача действительно создала или опубликовала. +Перед расшифровкой собственная SQL-транзакция фиксирует `decrypting=1` ровно для +одного подходящего файла с выбранным ключом. Worker запускается только после commit. +Завершение также владеет транзакцией: отличает удалённую строку от ошибки чтения, +проверяет все bind/step/changes/finalize и сохраняет метаданные/ready либо ошибку. +До commit оно остаётся в списке задач instance; SQL-отказ повторяется таймером +с готовым результатом, без повторного worker. Внешняя транзакция остаётся нетронутой. +Лимит16 включает workers и такие завершения; отказ новой отправки сохраняет source +у вызывающего. Событие/публикация сообщения следуют после commit результата. +Quiesce останавливает повторы; после завершения workers destroy освобождает остаток +списка и принадлежащие ему результаты. При restart незавершённая расшифровка может +выполниться заново по сохранённому ключу и контейнеру. + Реестр `file_io` защищает активные temp от очистки кеша, включая время после close. Восстановление до запуска workers и очистка после их завершения удаляют оставшиеся `.uenc.tmp.` / `.file.tmp.` в `media/decrypted`. @@ -105,11 +124,61 @@ instance того же процесса остаются защищены общ Квота медиакеша учитывает обе копии; очистка кеша не удаляет ключ. При повторном открытии удалённая открытая копия восстанавливается с сохранённым ключом. +Производная запись контейнера создаётся из проверенной истории собственной +транзакцией: новая строка проверяется целиком, точный дубликат сохраняет ключ, +метаданные и флаги. SQL/OOM-отказ (`-1`) допускает повтор; неверный manifest или +коллизия (`-2`) его не запускают. Ошибка этого индекса не отменяет событие уже +сохранённого сообщения. Общая очередь backfill удерживает до16 каналов, через2с +повторяет чтение истории; переполнение ставит полный scan. Обрабатывается отделённый +batch, поэтому SQL-отказ во время прохода может безопасно поставить следующий. +Startup и ручная загрузка также восстанавливают индекс, включая сохранённый ciphertext. +Секретный ключ из истории не восстанавливается. + +`wanted` и сброс `ready/error` при ручной загрузке фиксируются вместе. Состояние +`downloaded` и автоматический запрос обычного участника также сохраняются отдельной +проверенной транзакцией, событие следует после commit; неизменный повтор молчит. +Новая проверенная передача сбрасывает прежний error для повторной расшифровки, +простое обнаружение старого cache сохраняет error. Внешняя транзакция запрещена. +JSON истории вместе с manifest ограничен `DB_SYNC_MESSAGE_MAX` (4000байт), как +для остальных сообщений. Ручной обработчик использует тот же предел; отдельного +достижимого усечения на4096байтах не было. + +При локальном удалении канала SQLite-trigger удаляет ключи, запросы и outbox в той +же транзакции, что `channels`; незавершённый каскад, включая `RAISE(IGNORE)`, отменяет +удаление. Также проверяется отсутствие канала и Merkle-хешей после DELETE. +SQL-ошибка сохраняет все записи и файлы; сетевые ресурсы к этому моменту уже могут +быть остановлены, поэтому требуется повтор команды. Внешняя транзакция отклоняется +до отмены ресурсов. Открытая после отказа rollback транзакция требует stop владельцем. +Только после commit удаляются ciphertext через media_index и собственные канонические +`decrypted//.file`. Посторонние имена, подкаталоги и temp этот проход +сохраняет. I/O-ошибка не возвращает удалённый канал: она логируется, очистку можно +повторить. Завершение активного worker отдельно удалит свой результат, увидев отсутствие строки. + После полной проверенной загрузки обычный клиент автоматически запрашивает ключ автора. Фоновая копия узла с подписанной ролью `storage=yes` запрос не создаёт; ручная пользовательская загрузка на таком узле запрашивает доступ. Сетевая классификация `peers.storage` для этого не используется. +Таймер создаёт исходящий REQUEST собственной BEGIN IMMEDIATE/COMMIT: повторно +проверяет, что файл скачан и нужен, ключ отсутствует и прежнего запроса нет; +затем сохраняет ровно один исходящий запрос и команду outbox. Все SQL-этапы, +число строк и commit проверяются. Уведомление и отправка следуют после commit. +Отказ сохраняет прежние строки, следующий проход повторяет подготовку. +При внешней транзакции весь проход файлов и отправки откладывается, не меняя её. +Если rollback не закрыл собственную транзакцию, таймер не запускается повторно: +модуль снимается и владелец останавливает ядро после poll. + +Перед каждой сетевой попыткой очередь собственной транзакцией проверяет команду и +фиксирует `retry_at=now+5с`. Только после commit выполняются проверка допуска и сеть; +SQL-отказ сохраняет команду/дату и не отправляет пакет. За проход обрабатываются до16 +команд, периодический tick —10с. Пустая очередь освобождает свою транзакцию без +вызова commit hook. Отсутствие маршрута, backpressure и отказ отправки сохраняют очередь. + +Очистка неподтверждённых подготовок выполняется до workers и после их завершения. +Если SQLite-транзакция открыта, весь recovery откладывается, файлы сохраняются: +незакоммиченное удаление истории не доказывает отсутствие публикации. Инициализация +модуля также отклоняет внешнюю транзакцию до изменения таблиц/таймера/сети. + В шапке desktop-клиента кнопка «Запросы расшифровки» видна при наличии запросов без решения и показывает их число. Список содержит участника, вложение, дату и отдельные кнопки выдачи/отказа. @@ -136,12 +205,17 @@ REQUEST=1, REQUEST_ACK=2, GRANT=3, DENY=4, RESULT_ACK=5 ответ только от автора, по своему существующему запросу и точной идентичности контейнера. Конфликтующие ключи и результаты отклоняются. -Приём REQUEST пока использует SAVEPOINT: REQUEST_ACK и событие следуют после RELEASE. -Без внешней транзакции это фиксирует запрос. Внутри внешней транзакции подтверждение -может уйти до commit: последующий rollback удаляет запрос у автора, а получатель уже -перешёл в state=1 и удалил REQUEST из outbox. Ожидание сохраняется после restart автора -при живых маршрутах. Этот отдельный дефект приёма ещё не исправлен; -доказательства — [chat_audit_findings.txt](chat_audit_findings.txt), «Аудит приёма REQUEST». +Приём REQUEST использует собственную BEGIN IMMEDIATE/COMMIT. Внешняя транзакция +отклоняется без изменений до чтения идентичности/прав. Под блокировкой проверяются +UUID/hash контейнера, допуск участника и авторство, затем читается существующий запрос. +Отсутствующий запрос вставляется со state=0 и проверкой одной строки; дубликат сохраняет +решение и created_at. Только incoming=1 и state=0/2/3/4 допустимы:0 ожидает автора, +2/4 повторяют GRANT в outbox,3 повторяет DENY. Все SQL-этапы проверяются; ACK/событие +отправляются после commit. При отказе прежние запрос/очередь сохраняются, ACK не отправляется. +Если после commit ACK не доставлен, клиент повторяет REQUEST; сохранённый запрос +позволяет повторно подтвердить приём и прежнее решение. Неудачный rollback с открытой +транзакцией требует quiesce/stop и закрытия ядра владельцем после poll. +История дефекта и исправления — [chat_audit_findings.txt](chat_audit_findings.txt). GRANT/DENY появляются в outbox в одной собственной BEGIN IMMEDIATE/COMMIT с решением автора. Проверка прав @@ -195,6 +269,41 @@ backpressure и повтором через 5 секунд; restart продол Отдельное readonly-соединение проверяет точные ключ, автора, UUID, метаданные и пути до worker. Дополнительно проверяются настоящий commit veto, INSERT ABORT/IGNORE, внешняя транзакция, синхронный отказ запуска, restart с опубликованным ключом и отказ rollback с закрытием БД владельцем. +Дополнительно:9 SQL-отказов захвата расшифровки,15 записи успешного результата и8 +записи ошибки настоящего worker. Проверяются ABORT/IGNORE, commit veto, внешняя +транзакция, точные ключ/метаданные/флаги через reader перед worker и в событии, +один worker при повторе completion, отмена ожидающего результата при delete/stop, +restart сервиса и rollback fault без повторного таймера. 16 готовых авторских задач +удерживаются при отказе чтения; семнадцатая сохраняет source, снятие отказа публикует +все16 ровно по одному разу. Баланс выделений восстанавливается. +Удаление проверяется14 отказами: ABORT/IGNORE каналов/трёх локальных таблиц/Merkle, +prepare/bind/step/finalize удаления метаданных; дополнительно настоящий commit veto, +внешняя транзакция и rollback fault с остановкой владельцем. На отказе reader видит +прежние точные ключ/метаданные/request/state/created/outbox/retry, файлы сохранены; +в единственном CHANNEL_DELETED — канал/история/локальные записи/Merkle уже отсутствуют. +Дополнительные проверки:13 SQL-отказов привязки авторской пары,25 наблюдения новой/существующей +идентичности и60 операций wanted/downloaded. Сверяются все поля через независимый reader, +в том числе в событии; старые ключи/метаданные/соседняя запись сохраняются. +Открытое удаление истории при shutdown сохраняет опубликованные файлы; rollback и +restart восстанавливают прежний снимок. Внешняя транзакция также запрещает повторный init. +Проверяются канонический UUID,коллизия/внешняя транзакция/commit veto/ABORT/IGNORE, +повторная ошибка в отделённом batch,восстановление при startup без сети и17 каналов +с fallback после заполнения очереди16. Ручной обработчик передаёт все23 подписанных +блока manifest длиной3845байт в порт загрузки; отказ порта не удерживает контекст. + +`test_attachment_requests` проверяет247 SQL-отказов/повторов новой записи и дубликатов +0/2/3/4, с пустой/существующей очередью. Подписанный мембер и сообщение создаются +штатными API, локальная запись вложения — фикстурой. Readonly reader в точке ACK +и события проверяет committed key/error/request/state/date/command/retry и соседний ключ. +Дополнительно: ABORT/IGNORE при INSERT запроса и INSERT/UPDATE outbox, настоящий commit veto, +внешняя транзакция, неверные состояние/направление/идентичность/флаги/мембер, +потеря ACK из-за маршрута/backpressure/send failure и повтор с прежней датой, +отказ rollback с остановкой и закрытием БД владельцем. +Тот же тест проверяет52 SQL-отказа создания исходящего REQUEST с пустой/существующей +outbox, ABORT/IGNORE, commit veto, внешнюю транзакцию до создания и до отправки, +отсутствие повторного события/запроса, rollback fault без повторного таймера. +Readonly reader видит обе строки до события и перед REQUEST; при отсутствии маршрута, +backpressure и ошибке send настоящий restart сохраняет очередь и исходную дату запроса. `test_attachment_decisions` проверяет116 SQL-отказов/повторов выдачи и отказа, с пустой и существующей очередью: чтение подписанного мембера, допуск автора, запись состояния, @@ -209,6 +318,13 @@ UPDATE состояния и INSERT/UPDATE очереди, внешняя тра перезапуски автора с запросами и получателя с ключом, выдача B и отказ C, отсутствие ключа на S/C, отрицательные control-пакеты, повтор GRANT, восстановление удалённого plaintext без нового согласия и удаление канала. +На Linux первый настоящий REQUEST от B обрабатывается внутри внешней транзакции: +проверяются её сохранность, отсутствие ACK/события/авторской записи и сохранение очереди B. +После rollback владельцем и restart автора настоящий повтор REQUEST принимается; +readonly reader видит сохранённый pending-запрос до отправки реального REQUEST_ACK. +Первый bind hash при создании индекса на B возвращает NOMEM. В событии сообщения +reader видит committed историю и отсутствие индекса,очередь восстановления уже создана; +перед настоящим REQUEST reader подтверждает восстановленные UUID/hash/downloaded/wanted. На Linux пропуск GRANT outbox и внешняя транзакция автора отвергаются без изменения ожидающего запроса и без события. После restart автора повтор решения сохраняет state+outbox; отдельное readonly-соединение подтверждает их в момент единственного события UI. diff --git a/doc/group_avatar.txt b/doc/group_avatar.txt index b7fb3d014..1e83daf5b 100644 --- a/doc/group_avatar.txt +++ b/doc/group_avatar.txt @@ -18,6 +18,18 @@ SHA256 считается по картинке; при одинаковой в Публикация одинаковой картинки не создаёт новую версию. Удаление — подписанная запись с size=0 и новой версией; подпись/версия сохраняются, старый пир не может восстановить удалённую картинку. Чтение проверяет сохранённый хеш и подпись. +Отказ SQL/проверки оставляет выход group_avatar_get неизменным. Приватный ключ +читается целиком:ровно32 байта BLOB,с проверкой bind/step/finalize;временные копии +очищаются на всех выходах. Сломанный/отсутствующий ключ не даёт права публикации. + +set/apply владеют BEGIN IMMEDIATE:допуск,выбор/сравнение версии и UPDATE выполняются +под одной блокировкой. Все bind/step/changes=1/finalize и COMMIT проверяются. +Чужая транзакция отклоняется до действия,включая no-op;без изменения выполняется +ROLLBACK без commit hook. Ошибка сохраняет запись и не отправляет CHANNEL_UPDATED/ +STATE. Если сам rollback оставляет транзакцию открытой,запрашивается stop; +владелец завершает остановку/закрытие БД вне callback. Миграция схемы использует +savepoint и допускает внешнюю транзакцию:ошибка ROLLBACK TO не сопровождается +RELEASE частичных ALTER;откат/закрытие остаются владельцу. Транспорт --------- @@ -38,8 +50,10 @@ DATA header=112 байт. Картинка разбивается на блок Порядок блоков гарантирует router. Сборка проверяет offset, постоянные version, total_size/hash/signature и допустимые размеры. До полной сборки прежняя картинка -остаётся в БД. После SHA256/Ed25519 вся запись заменяется одним UPDATE, затем +остаётся в БД. После SHA256/Ed25519 вся запись заменяется одним committed UPDATE, затем CHANNEL_UPDATED доставляет изменение GUI и STATE распространяется соседям. +Приём завершает работу со сборкой/peer и только затем уведомляет интерфейс: +обработчик вправе отменить задачи группы,не оставляя обращения к освобождённой сборке. Промежуточные блоки не публикуются, ACK/retransmit каждого блока — только router. Отправка заполняет router до нижнего порога send_q count<=0 и ждёт send_ready; @@ -59,7 +73,8 @@ ChatGUI Настройки группы: Upload group avatar/Remove group avatar, предпросмотр и статус. Кнопки доступны только при локальном приватном ключе. Применение сразу, отдельно от Save локальных настроек. Worker обрезает центральный квадрат, создаёт JPEG -128×128, снижает качество/размер до лимита 16 КиБ. Вход ограничен 16 мегапикселями. +128×128,при необходимости96×96/64×64,снижает JPEG quality85/70/50/35 до лимита16КиБ. +Вход ограничен16*1024*1024 пикселями;SVG отклоняется. GUI отправляет байты в uasync; ядро повторно проверяет размер/членство/ключ. SQLite — постоянный кеш. GroupAvatarManager хранит QImage до 32 МиБ, ключ кеша: @@ -76,6 +91,12 @@ test_group_avatar: настоящие два ядра/UDP/router, 16 КиБ че перезапуск канала, stale/conflict, редактор с ключом вне creator UID, подписанное удаление, повторное описание группы и restart БД. Schema failure откатывает частичные ALTER, сохраняет прежние данные/ключи, повторное добавление идемпотентно. +Приём также проверен с отменой peer-задач прямо из CHANNEL_UPDATED. +test_group_avatar_sql:27 SQL-отказов публикации и16 чтения аватарки/ключей, +9 отказов схемы;каждый с успешным повтором. Проверены bind подписи/хеша,signing, +ABORT/IGNORE/commit veto,чужая транзакция/no-op,предельная версия,неизменность +выходов/readonly-записи,ошибка rollback и закрытие владельцем. Ключи NULL/31/33/ +TEXT отклоняются;неудачный ROLLBACK TO не фиксирует частичную схему. test_group_avatars (Qt): нормализация, лимит/отображение, кеш, права кнопок и отказ ядра без ключа, удаление. chat=debug показывает состояния и каждый 4КиБ блок; chat=info — публикации и жизненный цикл. Ошибки содержат group/peer/version/offset. diff --git a/doc/pm.txt b/doc/pm.txt index 3052a56ca..4da0009c4 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/doc/pm_invites.md b/doc/pm_invites.md index 5e9a60c7d..29d47f96a 100644 --- a/doc/pm_invites.md +++ b/doc/pm_invites.md @@ -40,6 +40,17 @@ ID получателя, выбранную группу и исходную о Повтор во время ожидания возвращает BUSY. Закрытие диалога отменяет только соответствующий `request_id`; уже отправленное сообщение не отзывается. Отмена и остановка DM завершают принятый запрос ровно один раз. +Отмена уже завершённого request_id ничего не отправляет и не создаёт повторный результат. +Отмена касается ожидания PM, уже зарегистрированный JOIN-ключ живёт по своему TTL. + +Запрос — один обнулённый блок `u_calloc(dm_invite_req)` с NUL-строками; +после передачи в uasync им владеет обработчик до финального callback. +Instance, chat_sync и SQLite должны оставаться живыми до завершения отмены. +`dm_core_destroy` отменяет ожидающий invite до освобождения owner. +Поколение беседы захватывается перед регистрацией и проверяется перед `dm_send`: +удалённая и повторно открытая PM не принимает старый результат. +Создание/открытие беседы происходит до регистрации; ошибка регистрации или отправки +не откатывает это открытие, но не создаёт invite-сообщение. `CHAT_EVT_DM_INVITE_RESULT` содержит 36 байт: `[request_id:u64][result:i32][peer:u64][conv_id:u64][generation:u64]` в порядке байтов ядра. @@ -53,6 +64,11 @@ JOIN во вторую группу может использовать уже ## Проверка и диагностика +Контракт сверён 27.10.2026 с DM, регистрацией JOIN-ключей и обработчиками результата +Qt/Android. Свежий `test_dm_invite` прошёл вместе с остальными восемью DM-тестами. +Приведённые ниже GUI-тесты описывают существующее покрытие; в этом этапе новые +сборки Qt/Android и интерактивная проверка интерфейса не выполнялись. + - `tests/test_dm_invite`: настоящая UDP-доставка ссылки в другую группу, JOIN, репликация участника и отправка группового сообщения после вступления; отмена, BUSY, неверная группа, rollback при ошибке commit и остановка DM; diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index d97f3f110..f21d24886 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -37,7 +37,7 @@ Desktop и Android проверяют `instance->running` вместе со св включая Android-цикл после restart. Запрос из callback завершает цикл после возврата из текущего poll; уже начатая обработка остальных callbacks этого poll не прерывается. Владелец затем снимает media-привязки, уничтожает ядро и освобождает uasync. -Это используется также при отказе rollback подготовки вложения, решения автора или применения ответа о ключе, если SQL-транзакция +Это используется также при отказе rollback подготовки вложения, приёма REQUEST, решения автора или ответа о ключе, если SQL-транзакция осталась открыта: модуль вложений предварительно прекращает приём и снимает свой таймер/router. Create уже подготавливает listeners, топологию/SQLite, router/proxy и мониторинг; @@ -211,6 +211,12 @@ completion. Отказ запуска/неверные аргументы даю `encrypted_file_write/read` только готовят закрытый durable temp; worker публикует его одним rename, completion подтверждает результат в БД/UI. Отмена до completion не публикует сообщение; cleanup учитывает реально созданные/заменённые файлы. +Модуль канальных вложений отдельно владеет списком до16 файлов: worker либо +завершение, ожидающее SQL commit. Захват decrypting фиксируется до worker; +ошибка записи результата сохраняет задачу для следующего tick без новой криптографии. +Quiesce останавливает эти повторы. После media_async_destroy chat_attachment_destroy +освобождает оставшиеся завершения до удаления chat_core; внешняя транзакция +откладывает запись, незавершённый rollback запрашивает штатную остановку владельцем. Ошибка синхронизации каталога после rename означает установленное имя без подтверждённой durability; отложенный discard уже не удаляет результат. Общей транзакции ФС/БД нет. До запуска workers и после их завершения recovery очищает собственные stale temp diff --git a/doc/tasks.md b/doc/tasks.md index 77bf61b0f..800a4a9ee 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,16 +4,153 @@ ## Текущие задачи -[ ] **chat_attachment: подтверждать входящий REQUEST после собственного commit** — подтверждено27.10.2026. - ak_receive использует SAVEPOINT attachment_request;во внешней транзакции RELEASE не выполняет commit. - Реальные ACK/событие отправляются при writer request1/readonly request0;внешний rollback теряет запрос автора. - Получатель уже сохранил state1 и удалил REQUEST outbox;нет повторов,ключ отсутствует и после restart автора при живых маршрутах. - Текущая A–S–B/C-фикстура:control phase10 PASS,outer phase5 ожидаемо FAIL;allocations0->0 в обоих сценариях. - Требуется собственная транзакция приёма,отказ внутри внешней транзакции без её изменения,проверенные SQL-этапы, - ACK/событие после commit и общий безопасный rollback. Дубликаты обязаны сохранять прежнее решение/повтор ответа. - Нужны постоянные проверки независимого reader в точке ACK,внешней транзакции,SQL-отказов и реального повтора/restart. - Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Аудит приёма REQUEST»;/tmp/attachment-request-audit-*. - Runtime не изменён;дальнейший аудит остановлен по AGENTS.md:10,отметки attachment/chat остаются[~]. +[+] **chat preferences: собственный commit UI-state и рассылка сохранённого звука** — 27.10.2026. + Подтверждён commit после injected finalize error прежнего UI-state setter. Новая операция проверяет + read/write/finalize/changes/commit под BEGIN IMMEDIATE;отказ сохраняет значение,no-op не вызывает hook. + Sound setter возвращает результат и рассылает только committed/no-op;повтор восстанавливает доставку. + Getter различает отсутствие/SQL/неверный тип и boolean;initial sync пропускает ошибку чтения. + Settings wrapper сохраняет коды registry и не применяет invalid group_autoconnect;trampoline проверяют NUL. + В звуковой форме исправлен указатель на временный QByteArray;добавлена проверка18 сохранённых значений. + test_chat_preferences:13 UI-state+13 sound+5 reader SQL fault/retry,readonly observer,no-op/veto/triggers, + foreign txn,malformed requests,owner stop при rollback failure PASS. Общие результаты — в отчёте. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Настройки и UI-state»;core ещё частичен. + +[+] **chat member tags: проверенный снимок,конфликт правки и атомарный commit** — 27.10.2026. + Подтверждён ложный успех load после finalize error. Чтение закрывает SQL и проверяет owner-подпись/формат. + Commit владеет BEGIN IMMEDIATE,отклоняет устаревший снимок (-2),фиксирует подпись/версию/Merkle вместе; + отказ сохраняет editor и историю ролей для повтора,no-op не меняет версию/события. Приватный ключ очищается. + GUI применяет частичные поля,сохраняя остальные;Android не сохраняет частичный набор после ошибки setter. + ver1..INT_MAX проверяется без atoi overflow,JSON — целиком;display/JNI читают историю ролей до8192 байт. + test_chat_profile дополнен37 SQL и10 allocation отказами/повторами,sign/veto/foreign/nested rollback, + stale/no-op/repeated edits,строгим JSON/control,patch и долгой историей,удалением из callback;PASS. + Общая проверка записывается в отчёт;остальные core/headless/JOIN/sync/invite/whisper продолжаются. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Редактор прав участников». + +[+] **chat profile: проверка прежней подписи, собственная транзакция и безопасные уведомления** — 27.10.2026. + Getter публикует все выходы только после SQL/finalize,проверки JOIN/update и целого строкового JSON. + Options/avatar читают версию,проверяют допуск,подписывают и меняют запись/Merkle под BEGIN IMMEDIATE; + семантический no-op не меняет подпись/версию. Неподписанные старые поля не переподписываются. + Неудачный member ROLLBACK TO не сопровождается RELEASE частичных данных;проверяется changes=1 owner/nodes. + Callback допускает отмену подписок,вложенный dispatch и удаление группы;JOIN-подписка проверяется при старте. + Unicode escapes больше не скрывают следующие поля профиля. test_chat_profile:20 read/54 write/9 owner/4 ACL + SQL-отказа с повторами,crypto/trigger/veto/foreign/rollback,readonly observer и startup retry PASS. + Общая проверка записывается в отчёт после завершения;глобальное имя/UI-state/tags и core остаются в работе. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Подписанные профили». + +[+] **chat_core JSON: полное escaping и атомарный результат чтения** — 27.10.2026. + Подтверждён невалидный JSON для допустимого подписанного имени с ASCII01. Channels/members используют + checked strbuf и полное escaping;все listing проверяют SQL-этапы,включая bind/finalize/вложенные запросы. + Отказ оставляет out_len=0 и пустой допустимый buffer;нулевой/точный размер не повреждает canaries. + Скрытый NUL/неверный тип/размер полей отклоняются. Committed-канал без history-таблицы виден с msgs=0; + чтение не меняет БД и не завершает транзакцию caller. Новая test_chat_json:36 отказов с повторами, + ошибка после готовой строки,все ASCII controls,UTF-8/длинные имена/8192-byte tags,IPv4/IPv6/REALITY, + повреждённые строки и настоящая deferred activation/retry PASS. Общие результаты — в отчёте. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«JSON-интроспекция». + +[+] **chat_member/status: полный снимок,границы формата и committed mute** — 27.10.2026. + Подтверждён ложный успех снимка после finalize error;теперь SQL/память не дают частичный список. + Список ограничен65535 записями,сортируется qsort (online,unsigned node_id);строки wire дополняются NUL. + Запросы/канальные ID проверяются до чтения;callback свойств использует один завершённый SELECT. + radio_set_member_muted владеет транзакцией,кэш/аудио обновляются после commit;чат готовит снимок до изменения, + последующий SQL-отказ не превращает сохранённый переключатель в ошибку. Чужая транзакция запрещена. + Диагностика использует checked strbuf;member_detail проверяет finalize и согласованный error codec. + test_chat_member:SQL/память/ABORT/IGNORE/veto/foreign/rollback,readonly DB+cache+audio observer, + 65535/65536 участников,300 линков,OOM и allocator boundary PASS. Общие проверки — в отчёте. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Снимки участников и диагностика». + +[+] **group_avatar: атомарная публикация, проверенное чтение и безопасное событие приёма** — 27.10.2026. + Подтверждён прежний ложный успех при отказе bind SHA256. set/apply владеют BEGIN IMMEDIATE/COMMIT, + проверяют все SQL-этапы,отклоняют чужую транзакцию;события/STATE идут после commit. + Выход get сохраняется при отказе;ключи читаются как BLOB ровно32 байта и очищаются. + Schema savepoint не фиксирует частичные ALTER после неудачного ROLLBACK TO. + Приём завершает работу со сборкой/peer до callback,который может отменить задачи группы. + Native профиль6/6 PASS;новый test_group_avatar_sql:27 SQL-отказов публикации,16 чтения,9 схемы, + signing/ABORT/IGNORE/veto/foreign/no-op/max-version/rollback и readonly observer;повторы PASS. + Сетевой test_group_avatar проверяет отмену peer из CHANNEL_UPDATED;header/doc сверены полностью. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Аватарки групп»;общие результаты — в отчёте. + +[+] **chat_channel: атомарное создание канала и правдивый результат команды** — 27.10.2026. + Канальные пары ключей и подпись проверяются до записи; описание, схема, root JOIN, подписанный профиль, + приглашение и Merkle сохраняются одним commit. Runtime-подготовка и события выполняются после него. + Ошибка подготовки не удаляет сохранённый канал, допускается повтор ensure_channel_ready. + Исправлены частичная схема channel_put и незамеченный отказ bind подписи профиля в member_block_put. + Auto возвращает committed ID или0; native headless возвращает ошибку при отказе, очищаются временные ключи. + test_chat_channel:42 SQL-отказа и19 криптоотказов с успешными повторами,ABORT/IGNORE/veto, + чужая транзакция,ошибка rollback,границы и TCP API;readonly-проверка данных/схемы/Merkle до события. + Профильные проверки PASS;общая проверка текущего блока записывается в отчёт после завершения. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Атомарное создание канала». + +[+] **feedback/poll: проверенное чтение версий, собственные транзакции и подтверждение уже сохранённой команды** — 27.10.2026. + SQL-ошибка поиска не считается отсутствием старого состояния:отмена не заменяется прежней реакцией. + apply/set/page/init владеют BEGIN IMMEDIATE/COMMIT;версия/допуск/запись/Merkle фиксируются вместе, + уведомления — после commit. Неудачный rollback не выполняет RELEASE и запрашивает stop владельцем. + Проверены SQL-этапы get/put/page/hash и команд/снимков poll. Ошибка чтения после commit даёт успешный + committed/snapshot_pending;Qt/Android повторяют только feedback. Ack резервируется до изменения. + test_chat_feedback:подтверждён старый дефект,20 SQL-отказов изменения и10 чтения,ABORT/IGNORE/veto, + чужой no-op/page,rollback failure,readonly record/root перед событием. test_chat_poll:51 SQL-отказ, + 8 readback-отказов после commit,2 отказа памяти до изменения,полная строка emoji;повторы PASS. + Native/ASAN профиль6/6 до readback,новая unit-регрессия readback PASS;общие результаты — в отчёте. + Подробнее:[chat_audit_findings.txt](chat_audit_findings.txt),«Feedback и poll». + +[+] **chat_attachment: неизменяемая привязка, атомарный локальный индекс и восстановление из подписанной истории** — 27.10.2026. + bind проверяет собственный commit и запрещает замену message_id/hash; observe сохраняет прежний ключ/флаги точного дубликата. + wanted/downloaded обновляются своей транзакцией, события идут после commit; повторное обнаружение cache не сбрасывает error. + Отказ производного индекса не скрывает сохранённое сообщение: backfill восстанавливает запись, включая startup/manual download; + очередь16 каналов имеет полный scan при переполнении. Ключ из истории не восстанавливается. + Outbox фиксирует retry_at до сети; idle scan не вызывает commit hook. Recovery при открытой транзакции сохраняет файлы. + Native профиль7/7 и последующие settings/attachment2/2 PASS;ASAN/UBSan/LSan17/17 PASS,detect_leaks=1. + Подробности:[chat_audit_findings.txt](chat_audit_findings.txt),«Идентичность и восстановление локального индекса». + +[+] **chat_setting: чтение const без изменения буфера, атомарный отказ валидации и повтор применения LAN** — 27.10.2026. + Числовые строки хранятся отдельно по настройке и обновляются setter; NULL/строковый тип дают default. + Неудачный set не инициализирует/не меняет state;пути можно очистить. Ошибка apply LAN возвращает -3, + сохраняя желаемое значение;идентичный повтор повторяет применение. Удалён пустой общий apply. + Новый test_chat_setting проверяет неизменность всех байтов,независимые копии,границы/ошибки и apply retry; + native и ASAN/UBSan/LSan PASS. Общая проверка новых изменений записывается отдельным итогом аудита. + +[+] **chat_attachment: исходящий REQUEST и outbox фиксируются вместе до уведомления/отправки** — 27.10.2026. + Собственная транзакция повторно проверяет состояние файла,создаёт ровно один запрос и очередь. + Внешняя транзакция откладывает весь tick;неудачный rollback не перезапускает таймер после quiesce/stop. + test_attachment_requests:52 SQL-отказа/повтора с пустой/существующей очередью,ABORT/IGNORE,commit veto, + внешняя транзакция до создания/отправки,readonly reader перед событием/REQUEST,реальный restart + при отсутствии маршрута/backpressure/send failure с сохранением даты. Старый код ожидаемо FAIL новой регрессии. + Общая проверка ниже:212/212 native,16/16 sanitizers,11/11 GUI/Android host,desktop/APK build PASS. + Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Исходящий REQUEST»;/tmp/attachment-outgoing-*. + +[+] **chat_attachment: захват расшифровки до worker и владение completion до commit результата** — 27.10.2026. + decrypting=1 фиксируется собственной проверенной транзакцией;completion отличает SQL-отказ от удаления. + Готовая задача остаётся у модуля до commit;таймер повторяет только SQL,ключ/файл не теряются и второй worker не создаётся. + Лимит16 включает workers/завершения;quiesce останавливает повторы,destroy после workers освобождает остаток. + test_chat_attachment:9 отказов claim,15 успешного результата,8 записи ошибки worker,ABORT/IGNORE/veto, + внешняя транзакция,delete/stop/restart,rollback failure и16 задержанных авторских задач с сохранением17-го source. + Старый sanitizer-runtime подтвердил ready1/infoNULL после bind NOMEM;новый профиль и баланс выделений PASS. + Общая проверка:check.sh212/212,0 skipped,proxy9/9,burst1/1,load8/8,177с; + ASAN/UBSan/LSan16/16 detect_leaks=1,desktop/APK build,GUI/Android host11/11 PASS. + Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Завершение файлов»;/tmp/attachment-jobs-*. + +[+] **chat: удалять attachment-записи атомарно с каналом,файлы — после commit** — 27.10.2026. + SQLite-trigger каскадно удаляет ключи/запросы/outbox и проверяет полный результат;topo deletion проверяет finalize + и отсутствие канала/Merkle-хешей. Ошибка SQL сохраняет данные/файлы;внешняя транзакция запрещена до отмены ресурсов. + Файлы удаляются после commit,незавершённый rollback требует stop/закрытия владельцем;NULL instance безопасен. + test_chat_attachment:14 отказов с точным readonly-снимком,ABORT/IGNORE всех таблиц и Merkle,commit veto, + внешняя транзакция и rollback fault;до фикса подтверждены reader_keys2 вместо3 и потерянный plaintext при живом канале. + Native build/check.sh212/212 PASS,proxy9/9,burst1/1,load8/8 (134217728байт),177с; + ASAN/UBSan/LSan16/16 detect_leaks=1,GUI/Android host11/11,desktop/APK build PASS. + Логи:/tmp/chat-attachment-followup-{build,full-check,asan-results,desktop-build,android-build,gui-results}.log. + Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Удаление канала»;полный аудит chat/attachment остаётся[~]. + +[+] **chat_attachment: подтверждать входящий REQUEST после собственного commit** — исправлено27.10.2026. + Собственная BEGIN IMMEDIATE/COMMIT включает identity,права,чтение/создание запроса и повтор прежнего ответа. + Внешняя транзакция отклоняется без изменений;дубликаты incoming1/state0,2,3,4 сохраняют решение и created_at. + Проверены все SQL-этапы;новая строка/очередь требуют changes1,ACK/событие следуют после commit. + Общий rollback сохраняет прежние данные;открытая транзакция требует quiesce/stop и закрытия владельцем после poll. + test_attachment_requests:247 SQL-отказов/повторов,commit veto,ABORT/IGNORE,внешняя транзакция, + неверные состояния/права,независимые reader в ACK/событии,backpressure/send fault и rollback fault. + Сеть:первый REQUEST без ACK→очередь B сохранена→restart автора→настоящий повтор→committed ACK→phase10 PASS. + Native build/check.sh212/212 PASS,0 skipped,proxy9/9,burst1/1,load8/8 (134217728байт),173с. + ASAN/UBSan/LSan16/16 PASS,detect_leaks=1;desktop/APK build PASS,GUI/Android host11/11 PASS. + Отчёт:[chat_audit_findings.txt](chat_audit_findings.txt),«Исправление приёма REQUEST»;/tmp/attachment-request-fix-*. + Полный аудит attachment/chat остаётся частичным;исходящие запросы и файловые completion/recovery проверяются далее. [+] **chat_attachment: фиксировать решение автора вместе с исходящим ответом до успеха/UI** — исправлено27.10.2026. Собственная BEGIN IMMEDIATE/COMMIT включает проверку прав,state и outbox;внешняя транзакция остаётся нетронутой. diff --git a/lib/json_flat.c b/lib/json_flat.c index f8c8e47c4..d440a6f5e 100644 --- a/lib/json_flat.c +++ b/lib/json_flat.c @@ -6,14 +6,54 @@ #define SKIP_WS(p, end) while ((p) < (end) && (*(p) == ' ' || *(p) == '\t' || *(p) == '\n' || *(p) == '\r')) (p)++ #define CHECK(p, end) if ((p) >= (end)) return -1 +static int json_unicode_unit(const char** text, const char* end, uint32_t* value) { + if (end - *text < 4) return -1; + uint32_t result = 0; + for (int i = 0; i < 4; i++) { + unsigned char c = (unsigned char)(*text)[i]; int digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else return -1; + result = (result << 4) | (uint32_t)digit; + } + *text += 4; *value = result; return 0; +} + static int json_read_string(const char** pp, const char* end, char* out, size_t out_sz) { const char* p = *pp; CHECK(p, end); if (*p != '"') return -1; p++; size_t n = 0; while (p < end && *p != '"') { + if ((unsigned char)*p < 32) return -1; char ch = *p; if (*p == '\\') { p++; CHECK(p, end); + if (*p == 'u') { + uint32_t code; p++; if (json_unicode_unit(&p, end, &code) < 0 || !code) return -1; + if (code >= 0xd800 && code <= 0xdbff) { + if (end - p < 2 || p[0] != '\\' || p[1] != 'u') return -1; + p += 2; uint32_t low; + if (json_unicode_unit(&p, end, &low) < 0 || low < 0xdc00 || low > 0xdfff) return -1; + code = 0x10000 + ((code - 0xd800) << 10) + low - 0xdc00; + } else if (code >= 0xdc00 && code <= 0xdfff) return -1; + uint8_t utf8[4]; size_t bytes; + if (code < 0x80) { utf8[0] = (uint8_t)code; bytes = 1; } + else if (code < 0x800) { + utf8[0] = (uint8_t)(0xc0 | (code >> 6)); utf8[1] = (uint8_t)(0x80 | (code & 63)); bytes = 2; + } else if (code < 0x10000) { + utf8[0] = (uint8_t)(0xe0 | (code >> 12)); utf8[1] = (uint8_t)(0x80 | ((code >> 6) & 63)); + utf8[2] = (uint8_t)(0x80 | (code & 63)); bytes = 3; + } else { + utf8[0] = (uint8_t)(0xf0 | (code >> 18)); utf8[1] = (uint8_t)(0x80 | ((code >> 12) & 63)); + utf8[2] = (uint8_t)(0x80 | ((code >> 6) & 63)); utf8[3] = (uint8_t)(0x80 | (code & 63)); bytes = 4; + } + if (out) { + if (n + bytes >= out_sz) return -1; + memcpy(out + n, utf8, bytes); n += bytes; + } + continue; + } switch (*p) { case '"': ch = '"'; break; case '\\': ch = '\\'; break; @@ -83,6 +123,7 @@ int json_flat_parse(const char* json, json_flat_cb cb, void* arg) { if (*p == '}') break; } if (scratch != local) u_free(scratch); + if (result < 0) DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "json_flat: invalid object/string bytes=%zu", capacity - 1); return result; } diff --git a/lib/json_flat.h b/lib/json_flat.h index 125ad0ebe..8384343ce 100644 --- a/lib/json_flat.h +++ b/lib/json_flat.h @@ -2,7 +2,8 @@ * @file json_flat.h * @brief Парсер плоского JSON без вложенности: {"key":"value",...} * - * Только строковые значения и обычные escapes; \uXXXX не поддерживается. + * Только строковые значения,обычные escapes и \uXXXX (surrogate pairs→UTF-8). + * Нулевой Unicode-символ,непарные суррогаты,неверный hex и сырые ASCII-control отклоняются. * Callback получает полные key/value, действительные только до его возврата. * parse возвращает 0 при успехе/остановке callback, -1 при ошибке; остановка не проверяет остаток ввода. * Синтаксис проверяется нестрого: допускаются пропущенные и завершающие запятые; diff --git a/lib/strbuf.c b/lib/strbuf.c index 52c271376..922214158 100644 --- a/lib/strbuf.c +++ b/lib/strbuf.c @@ -11,6 +11,8 @@ #define STRBUF_MIN_CAP 16 void strbuf_init(struct strbuf *sb, size_t initial_cap) { + *sb = strbuf_new(); + if (initial_cap > UINT32_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: capacity exceeds allocator range=%zu", initial_cap); return; } if (initial_cap < STRBUF_MIN_CAP) initial_cap = STRBUF_MIN_CAP; sb->buf = u_malloc(initial_cap); if (sb->buf) { sb->buf[0] = '\0'; sb->cap = initial_cap; } @@ -35,8 +37,9 @@ void strbuf_reset(struct strbuf *sb) { } static int strbuf_grow(struct strbuf *sb, size_t need) { + if (need > UINT32_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: size exceeds allocator range=%zu", need); return -1; } size_t nc = sb->cap ? sb->cap : STRBUF_MIN_CAP; - while (nc < need) { nc *= 2; if (nc < STRBUF_MIN_CAP) return -1; } + while (nc < need) { if (nc > UINT32_MAX / 2) { nc = need; break; } nc *= 2; } char *n = u_realloc(sb->buf, nc); if (!n) return -1; sb->buf = n; sb->cap = nc; @@ -51,6 +54,9 @@ int strbuf_addf(struct strbuf *sb, const char *fmt, ...) { int need = vsnprintf(NULL, 0, fmt, ap); va_end(ap); if (need < 0) { return -1; } + if ((size_t)need > UINT32_MAX - 1 - sb->len) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: formatted text exceeds allocator range"); return -1; + } /* grow */ size_t want = sb->len + (size_t)need + 1; /* +1 for '\0' */ @@ -65,6 +71,7 @@ int strbuf_addf(struct strbuf *sb, const char *fmt, ...) { } void strbuf_addc(struct strbuf *sb, char c) { + if (sb->len > UINT32_MAX - 2) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: character exceeds allocator range"); return; } if (sb->len + 2 > sb->cap && strbuf_grow(sb, sb->len + 2) < 0) return; sb->buf[sb->len++] = c; sb->buf[sb->len] = '\0'; @@ -96,6 +103,7 @@ failed: void strbuf_adds(struct strbuf *sb, const char *s) { if (!s) return; size_t slen = strlen(s); + if (slen > UINT32_MAX - 1 - sb->len) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: string exceeds allocator range"); return; } if (sb->len + slen + 1 > sb->cap && strbuf_grow(sb, sb->len + slen + 1) < 0) return; memcpy(sb->buf + sb->len, s, slen); sb->len += slen; diff --git a/lib/strbuf.h b/lib/strbuf.h index 9dc5b69e1..5c50aa781 100644 --- a/lib/strbuf.h +++ b/lib/strbuf.h @@ -25,6 +25,9 @@ struct strbuf { size_t len; /* current length (excluding '\0') */ }; +/* Размер ограничен UINT32_MAX allocator-ом mem.h;превышение отклоняется до преобразования size_t. + * init предназначен для нового буфера;повторная инициализация живого буфера требует предварительного free. */ + /* zero-init on stack: struct strbuf sb = {0}; */ #define strbuf_new() ((struct strbuf){NULL, 0, 0}) diff --git a/src/chat/chat_attachment.c b/src/chat/chat_attachment.c index b3b3d856c..a56946497 100644 --- a/src/chat/chat_attachment.c +++ b/src/chat/chat_attachment.c @@ -23,6 +23,8 @@ enum { AK_REQUEST = 1, AK_REQUEST_ACK, AK_GRANT, AK_DENY, AK_RESULT_ACK }; struct ak_file { struct UTUN_INSTANCE* inst; + struct ak_file* next; // Владение uasync до успешного commit результата либо остановки. + int completed, completion_error; char channel[32], source[1024], cipher[1024], plain[1024], type[32]; struct file_io_temp output; // Единственный собственный temp; стабильный адрес до completion. uint8_t id[16], key[32], message[32], hash[32], reply[32]; @@ -35,21 +37,16 @@ struct ak_file { /* Все ошибки SQLite содержат конкретную операцию. */ static sqlite3_stmt* ak_prepare(struct UTUN_INSTANCE* inst, const char* sql) { sqlite3_stmt* st = NULL; - if (!CC(inst) || sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL) != SQLITE_OK) + if (!inst || !CC(inst) || sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: prepare failed sql=%s error=%s", sql, - CC(inst) ? sqlite3_errmsg(CC(inst)->db) : "stopped"); + inst && CC(inst) ? sqlite3_errmsg(CC(inst)->db) : "stopped"); + int rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: prepare cleanup finalize rc=%d", rc); + return NULL; + } return st; } -/* Statement всегда освобождается. */ -static int ak_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st) { - if (!st) return -1; - int rc = sqlite3_step(st); - if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: write failed rc=%d: %s", rc, sqlite3_errmsg(CC(inst)->db)); - sqlite3_finalize(st); - return rc == SQLITE_DONE ? 0 : -1; -} - /* Управление транзакциями без открытых statement. */ static int ak_exec(struct UTUN_INSTANCE* inst, const char* sql) { int rc = sqlite3_exec(CC(inst)->db, sql, NULL, NULL, NULL); @@ -76,10 +73,11 @@ static void ak_hex(const uint8_t id[16], char hex[33]) { /* Строгое имя UUID.uenc из подписанного manifest, без разделителей пути. */ int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]) { - uint8_t name[64]; + uint8_t name[64], decoded[16]; char canonical[52]; const char* pipe = body ? memchr(body, '|', length) : NULL; - if (!pipe || body[0] != '*' || pipe - body != 53 || b64_decode(body + 1, 52, name, sizeof(name)) != 37 || - memcmp(name + 32, ".uenc", 5)) goto invalid; + if (!id || !pipe || body[0] != '*' || pipe - body != 53 || b64_decode(body + 1, 52, name, sizeof(name)) != 37 || + memcmp(name + 32, ".uenc", 5) || b64_encode(name, 37, canonical, sizeof(canonical)) != 52 || + memcmp(body + 1, canonical, sizeof(canonical))) goto invalid; for (unsigned i = 0; i < 16; i++) { unsigned value = 0; for (unsigned j = 0; j < 2; j++) { @@ -87,8 +85,9 @@ int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]) { if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) goto invalid; value = value * 16 + (c <= '9' ? c - '0' : c - 'a' + 10); } - id[i] = (uint8_t)value; + decoded[i] = (uint8_t)value; } + memcpy(id, decoded, sizeof(decoded)); return 0; invalid: DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid encrypted manifest identity bytes=%zu", length); return -1; @@ -136,31 +135,99 @@ static void ak_file_work(void* arg) { job->error = 0; } -/* Проверка существующей строки предотвращает публикацию после удаления канала. */ +static void ak_rollback(struct UTUN_INSTANCE* inst, const char* operation, const char* channel); + +/* 1 — строка существует, 0 — канал/вложение удалены, -1 — SQL-отказ, который нельзя считать удалением. */ static int ak_present(struct ak_file* job) { - sqlite3_stmt* st = ak_prepare(job->inst, "SELECT 1 FROM attachment_keys WHERE channel=? AND crypto_id=?"); - if (!st) return 0; - sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); - int rc = sqlite3_step(st); sqlite3_finalize(st); - if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: presence lookup failed rc=%d", rc); - return rc == SQLITE_ROW; + sqlite3* db = CC(job->inst)->db; sqlite3_stmt* st = NULL; + const char* stage = "prepare presence"; + int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM attachment_keys WHERE channel=? AND crypto_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read presence"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto fail; + int present = rc == SQLITE_ROW; + stage = "finalize presence"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + return present; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: presence channel=%s stage=%s rc=%d (%s) db=%s", + job->channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: presence cleanup finalize rc=%d", final_rc); + return -1; } -/* Сохранение metadata и открытой копии не меняет сетевой индекс. */ -static void ak_file_done(void* arg, int error) { - struct ak_file* job = arg; struct UTUN_INSTANCE* inst = job->inst; - int valid = CC(inst) && !CC(inst)->attachment_closing && ak_present(job); - int success = !error && !job->error && valid; - if (success && !job->encrypt) { - uint8_t info[ATTACHMENT_WIRE_MAX]; int n = attachment_encode(&job->info, info, sizeof(info)); - sqlite3_stmt* st = n < 0 ? NULL : ak_prepare(inst, - "UPDATE attachment_keys SET info=?,content_type=?,ready=1,decrypting=0,error=0 WHERE channel=? AND crypto_id=?"); - if (st) { - sqlite3_bind_blob(st, 1, info, n, SQLITE_STATIC); sqlite3_bind_text(st, 2, job->type, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 3, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 4, job->id, 16, SQLITE_STATIC); - } - success = st && !ak_step(inst, st); +/* 1 — результат зафиксирован, 0 — строка удалена, -1 — сохранить задачу для повтора без нового worker. */ +static int ak_store_file_result(struct ak_file* job) { + struct UTUN_INSTANCE* inst = job->inst; + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: completion postponed inside external transaction channel=%s", job->channel); + return -1; } + const char* stage = "begin completion"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment completion */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "presence"; + int present = ak_present(job); + if (present < 0) { rc = SQLITE_ERROR; goto fail; } + int success = !job->completion_error && !job->error; + if (present && (!job->encrypt || !success)) { + uint8_t info[ATTACHMENT_WIRE_MAX]; int n = 0; + if (success) { + stage = "encode metadata"; n = attachment_encode(&job->info, info, sizeof(info)); + if (n < 0) { rc = SQLITE_MISMATCH; goto fail; } + } + stage = "prepare completion"; + rc = sqlite3_prepare_v2(db, success ? + "UPDATE attachment_keys SET info=?,content_type=?,ready=1,decrypting=0,error=0 WHERE channel=? AND crypto_id=?" : + "UPDATE attachment_keys SET decrypting=0,error=1 WHERE channel=? AND crypto_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + int index = 1; + if (success) { + stage = "bind metadata"; rc = sqlite3_bind_blob(st, index++, info, n, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind content type"; rc = sqlite3_bind_text(st, index++, job->type, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + } + stage = "bind channel"; rc = sqlite3_bind_text(st, index++, job->channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, index, job->id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write completion"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize completion"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: completion rows=%d expected=1 channel=%s", changed, job->channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + } + stage = "commit completion"; rc = sqlite3_exec(db, "COMMIT /* attachment completion */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: completion committed channel=%s encrypt=%d present=%d success=%d", + job->channel, job->encrypt, present, success); + return present; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: completion channel=%s encrypt=%d stage=%s rc=%d (%s) db=%s", + job->channel, job->encrypt, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: completion cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "file completion", job->channel); + return -1; +} + +/* Завершить только после commit; при SQL-отказе задача остаётся у модуля до следующего tick. */ +static void ak_finish_file(struct ak_file* job) { + struct UTUN_INSTANCE* inst = job->inst; + int valid = CC(inst)->attachment_closing ? 0 : ak_store_file_result(job); + if (valid < 0) return; + int success = !job->completion_error && !job->error && valid; if (success && job->encrypt) { char name[38], hex[33]; ak_hex(job->id, hex); snprintf(name, sizeof(name), "%s.uenc", hex); struct chat_msg_submit request = { .inst = inst, .data = (uint8_t*)name, .data_len = 37, .request_id = job->request }; @@ -171,12 +238,10 @@ static void ak_file_done(void* arg, int error) { memcpy(request.reply_to, job->reply, 32); chat_core_submit_media_message(inst, &request); } else if (!success) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: worker failed channel=%s encrypt=%d error=%d worker=%d current=%d", - job->channel, job->encrypt, error, job->error, valid); - if (valid) { - sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET decrypting=0,error=1 WHERE channel=? AND crypto_id=?"); - if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); ak_step(inst, st); } - } + if (valid) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: worker failed channel=%s encrypt=%d error=%d worker=%d", + job->channel, job->encrypt, job->completion_error, job->error); + else DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: result discarded channel=%s encrypt=%d closing=%d published=%d", + job->channel, job->encrypt, CC(inst)->attachment_closing, job->published); if (job->encrypt) chat_event_submitted(inst, job->request, 0); } if (job->encrypt && remove(job->source) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: prepared source cleanup failed"); @@ -191,9 +256,20 @@ static void ak_file_done(void* arg, int error) { if (success && !job->encrypt) chat_media_cache_enforce(inst); ak_changed(inst, job->channel); } + struct ak_file** owner = &CC(inst)->attachment_files; + while (*owner && *owner != job) owner = &(*owner)->next; + if (*owner) { *owner = job->next; CC(inst)->attachment_file_count--; } + else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: completed job missing from owner channel=%s", job->channel); OPENSSL_cleanse(job->key, 32); u_free(job); } +/* Доставленный done больше не принадлежит media_async; модуль удерживает его до записи результата. */ +static void ak_file_done(void* arg, int error) { + struct ak_file* job = arg; + job->completed = 1; job->completion_error = error; + ak_finish_file(job); +} + /* Вызывается только для собственной транзакции; открытая после отказа БД требует остановки владельцем. */ static void ak_rollback(struct UTUN_INSTANCE* inst, const char* operation, const char* channel) { sqlite3* db = CC(inst)->db; @@ -268,6 +344,9 @@ int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const !source || !source[0] || strlen(source) >= 1024 || !reply || encrypted_file_validate_type(type) || attachment_validate(info)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid send arguments/state"); return -1; } + if (CC(inst)->attachment_file_count >= MEDIA_ASYNC_MAX_TASKS) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: file task limit reached count=%u", CC(inst)->attachment_file_count); return -1; + } if (!sqlite3_get_autocommit(CC(inst)->db)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: send rejected inside external transaction channel=%s", channel); return -1; } @@ -279,6 +358,7 @@ int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t encoded[ATTACHMENT_WIRE_MAX]; int n = attachment_encode(info, encoded, sizeof(encoded)); if (RAND_bytes(job->id, 16) != 1 || RAND_bytes(job->key, 32) != 1 || ak_paths(job) || n < 0) goto fail; if (ak_store_preparation(job, encoded, n)) goto fail; + job->next = CC(inst)->attachment_files; CC(inst)->attachment_files = job; CC(inst)->attachment_file_count++; media_async_submit(inst->media_async, inst->ua, ak_file_work, job, ak_file_done, job); return 0; fail: @@ -289,23 +369,64 @@ fail: /* Сохраняется идентичность подписанного контейнера до выхода сообщения в сеть. */ int chat_attachment_bind(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t id[16], const uint8_t message[32], const uint8_t hash[32]) { - sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET message_id=?,cipher_hash=?" - " WHERE channel=? AND crypto_id=? AND author=? AND key IS NOT NULL AND (message_id IS NULL OR message_id=?)"); - if (!st) return -1; - sqlite3_bind_blob(st, 1, message, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); - sqlite3_bind_text(st, 3, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 4, id, 16, SQLITE_STATIC); - sqlite3_bind_int64(st, 5, (sqlite3_int64)inst->node_id); sqlite3_bind_blob(st, 6, message, 32, SQLITE_STATIC); - if (ak_step(inst, st) || sqlite3_changes(CC(inst)->db) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: author key missing or already bound channel=%s", channel); return -1; + if (!inst || !CC(inst) || !CC(inst)->attachment_initialized || CC(inst)->attachment_closing || + !ak_channel(channel) || !id || !message || !hash) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid author binding state/arguments"); return -1; } + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding rejected inside external transaction channel=%s", channel); return -1; + } + const char* stage = "begin binding"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment binding */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare binding"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_keys SET message_id=?,cipher_hash=?" + " WHERE channel=? AND crypto_id=? AND author=? AND length(key)=32" + " AND ((message_id IS NULL AND cipher_hash IS NULL) OR (message_id=? AND cipher_hash=?))", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 1, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind hash"; rc = sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 3, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 4, id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind author"; rc = sqlite3_bind_int64(st, 5, (sqlite3_int64)inst->node_id); + if (rc != SQLITE_OK) goto fail; + stage = "bind previous message"; rc = sqlite3_bind_blob(st, 6, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind previous hash"; rc = sqlite3_bind_blob(st, 7, hash, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write binding"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize binding"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding rows=%d expected=1 channel=%s", changed, channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + stage = "commit binding"; rc = sqlite3_exec(db, "COMMIT /* attachment binding */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: author binding committed channel=%s message=%02x%02x%02x%02x", + channel, message[0], message[1], message[2], message[3]); return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "author binding", channel); + return -1; } /* Запись поступает только из проверенной истории; размер и hash относятся к ciphertext. */ int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t author, const char* json, size_t length) { - if (!CC(inst) || !json || !ak_channel(channel) || length > 65536) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid observed message"); return -1; + if (!inst || !CC(inst) || CC(inst)->attachment_closing || !json || !message || !author || !ak_channel(channel) || length > DB_SYNC_MESSAGE_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid observed message/state"); return -2; } char* copy = u_malloc(length + 1); if (!copy) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: message allocation failed"); return -1; } @@ -314,46 +435,114 @@ int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, con char* body = strstr(copy, "\"d\":\""); char* end = body ? strchr(body + 5, '"') : NULL; struct media_index_result result = {0}; struct ak_file job = { .inst = inst }; snprintf(job.channel, sizeof(job.channel), "%s", channel); - int rc = -1; + int result_code = -2, rc = SQLITE_MISMATCH; sqlite3_stmt* st = NULL; + const char* stage = "manifest"; if (!end) goto done; body += 5; *end = 0; - if (chat_attachment_identity(body, (size_t)(end - body), job.id) || chat_msg_parse_media_body(body, &result) || ak_paths(&job)) goto done; - sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_keys(channel,crypto_id,message_id,author,cipher_hash,cipher_path,plain_path)" - " VALUES(?,?,?,?,?,?,?) ON CONFLICT(channel,crypto_id) DO NOTHING"); - if (!st) goto done; - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); - sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)author); - sqlite3_bind_blob(st, 5, result.content_hash, 32, SQLITE_STATIC); sqlite3_bind_text(st, 6, job.cipher, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 7, job.plain, -1, SQLITE_STATIC); rc = ak_step(inst, st); - if (!rc) { - st = ak_prepare(inst, "SELECT 1 FROM attachment_keys WHERE channel=? AND crypto_id=? AND message_id=? AND author=? AND cipher_hash=?"); - if (!st) { rc = -1; goto done; } - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); - sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)author); - sqlite3_bind_blob(st, 5, result.content_hash, 32, SQLITE_STATIC); - rc = sqlite3_step(st) == SQLITE_ROW ? 0 : -1; sqlite3_finalize(st); + if (chat_attachment_identity(body, (size_t)(end - body), job.id) || chat_msg_parse_media_body(body, &result)) goto done; + result_code = -1; + if (ak_paths(&job)) goto done; + sqlite3* db = CC(inst)->db; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: observation rejected inside external transaction channel=%s", channel); goto done; } + stage = "begin observation"; rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment observation */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare identity"; + rc = sqlite3_prepare_v2(db, "SELECT message_id,author,cipher_hash FROM attachment_keys WHERE channel=? AND crypto_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read identity"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto fail; + int exists = rc == SQLITE_ROW, matches = !exists; + if (exists && sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_bytes(st, 2) == 32 && + sqlite3_column_blob(st, 0) && sqlite3_column_blob(st, 2)) { + matches = (uint64_t)sqlite3_column_int64(st, 1) == author && !memcmp(sqlite3_column_blob(st, 0), message, 32) && + !memcmp(sqlite3_column_blob(st, 2), result.content_hash, 32); + } + stage = "finalize identity"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (!matches) { result_code = -2; stage = "colliding identity"; rc = SQLITE_CONSTRAINT; goto fail; } + if (!exists) { + stage = "prepare observation"; + rc = sqlite3_prepare_v2(db, "INSERT INTO attachment_keys(channel,crypto_id,message_id,author,cipher_hash,cipher_path,plain_path)" + " VALUES(?,?,?,?,?,?,?)", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind author"; rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)author); + if (rc != SQLITE_OK) goto fail; + stage = "bind hash"; rc = sqlite3_bind_blob(st, 5, result.content_hash, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind cipher path"; rc = sqlite3_bind_text(st, 6, job.cipher, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind plain path"; rc = sqlite3_bind_text(st, 7, job.plain, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "insert observation"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize observation"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: observation rows=%d expected=1 channel=%s", changed, channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + } + stage = "commit observation"; rc = sqlite3_exec(db, "COMMIT /* attachment observation */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!exists) DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: manifest indexed channel=%s author=%llu message=%02x%02x%02x%02x", + channel, (unsigned long long)author, message[0], message[1], message[2], message[3]); + result_code = 0; goto done; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: observation channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: observation cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "observation", channel); done: - if (rc) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: rejected manifest/colliding identity channel=%s author=%llu", channel, - (unsigned long long)author); - media_index_result_free(&result); u_free(copy); return rc; + if (result_code) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: manifest not indexed channel=%s author=%llu result=%d stage=%s", + channel, (unsigned long long)author, result_code, stage); + media_index_result_free(&result); u_free(copy); return result_code; } /* Кодограммы привязаны к immutable сообщению, UUID и полному hash контейнера. */ static int ak_body(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint8_t cmd, uint8_t wire[AK_WIRE]) { - sqlite3_stmt* st = ak_prepare(inst, "SELECT crypto_id,cipher_hash,key FROM attachment_keys WHERE channel=? AND message_id=?"); - if (!st) return -1; - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); - int rc = sqlite3_step(st), ok = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 16 && sqlite3_column_bytes(st, 1) == 32 && - (cmd != AK_GRANT || sqlite3_column_bytes(st, 2) == 32); - if (ok) { - memcpy(wire, "AK01", 4); wire[4] = cmd; memcpy(wire + 5, message, 32); - memcpy(wire + 37, sqlite3_column_blob(st, 0), 16); memcpy(wire + 53, sqlite3_column_blob(st, 1), 32); - if (cmd == AK_GRANT) memcpy(wire + AK_BODY, sqlite3_column_blob(st, 2), 32); + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + const char* stage = "prepare"; + int rc = sqlite3_prepare_v2(db, "SELECT crypto_id,cipher_hash,key FROM attachment_keys WHERE channel=? AND message_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "step"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto fail; + if (sqlite3_column_bytes(st, 0) != 16 || sqlite3_column_bytes(st, 1) != 32 || + (cmd == AK_GRANT && sqlite3_column_bytes(st, 2) != 32)) { + stage = "identity/key size"; rc = SQLITE_MISMATCH; goto fail; } - sqlite3_finalize(st); - if (!ok) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: wire identity/key unavailable channel=%s command=%u rc=%d", channel, cmd, rc); - return ok ? (cmd == AK_GRANT ? AK_WIRE : AK_BODY) : -1; + const void* id = sqlite3_column_blob(st, 0); const void* hash = sqlite3_column_blob(st, 1); + const void* key = cmd == AK_GRANT ? sqlite3_column_blob(st, 2) : NULL; + if (!id || !hash || (cmd == AK_GRANT && !key)) { stage = "identity/key allocation"; rc = SQLITE_NOMEM; goto fail; } + memcpy(wire, "AK01", 4); wire[4] = cmd; memcpy(wire + 5, message, 32); + memcpy(wire + 37, id, 16); memcpy(wire + 53, hash, 32); + if (cmd == AK_GRANT) memcpy(wire + AK_BODY, key, 32); + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + return cmd == AK_GRANT ? AK_WIRE : AK_BODY; +fail: + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: wire identity/key channel=%s command=%u stage=%s rc=%d (%s) db=%s", + channel, cmd, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: wire cleanup finalize rc=%d", final_rc); + return -1; } /* Неподписанные и незашифрованные ответы сервис не принимает. */ @@ -440,6 +629,82 @@ static void ak_ack(struct UTUN_INSTANCE* inst, const char* channel, uint64_t pee if (rc < 0) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: acknowledgment deferred channel=%s command=%u", channel, cmd); } +/* Новый запрос и повтор прежнего решения фиксируются до ACK; внешняя транзакция остаётся вызывающему. */ +static int ak_accept_request(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, const uint8_t* body) { + sqlite3* db = CC(inst)->db; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: request rejected inside external transaction channel=%s peer=%llu", + channel, (unsigned long long)peer); return -1; + } + sqlite3_stmt* st = NULL; uint8_t expected[AK_WIRE]; + const char* stage = "begin"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "identity"; + if (ak_body(inst, channel, body + 5, AK_REQUEST, expected) < 0 || memcmp(body + 5, expected + 5, AK_BODY - 5)) { + rc = SQLITE_MISMATCH; goto fail; + } + stage = "authorization"; + if (!ak_authorized(inst, channel, body + 5, peer)) { rc = SQLITE_AUTH; goto fail; } + stage = "read prepare"; + rc = sqlite3_prepare_v2(db, "SELECT incoming,state FROM attachment_key_requests WHERE channel=? AND message_id=? AND peer=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "read bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read bind message"; rc = sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read bind peer"; rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + if (rc != SQLITE_OK) goto fail; + stage = "read step"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto fail; + int exists = rc == SQLITE_ROW, incoming = exists ? sqlite3_column_int(st, 0) : 1; + int state = exists ? sqlite3_column_int(st, 1) : 0; + stage = "read finalize"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (incoming != 1 || (state != 0 && state != 2 && state != 3 && state != 4)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: request conflict channel=%s peer=%llu incoming=%d state=%d", + channel, (unsigned long long)peer, incoming, state); + stage = "request state"; rc = SQLITE_CONSTRAINT; goto fail; + } + if (!exists) { + stage = "insert prepare"; + rc = sqlite3_prepare_v2(db, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)" + " VALUES(?,?,?,1,0,unixepoch())", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "insert bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "insert bind message"; rc = sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "insert bind peer"; rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + if (rc != SQLITE_OK) goto fail; + stage = "insert step"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "insert finalize"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: request insert rows=%d expected=1 channel=%s peer=%llu", + changed, channel, (unsigned long long)peer); + stage = "insert row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + } + int command = state == 0 ? 0 : state == 3 ? AK_DENY : AK_GRANT; + stage = "outbox"; + if (command && ak_queue(inst, channel, body + 5, peer, command)) { rc = SQLITE_ERROR; goto fail; } + stage = "commit"; rc = sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: request committed channel=%s peer=%llu message=%02x%02x%02x%02x duplicate=%d state=%d queued=%d", + channel, (unsigned long long)peer, body[5], body[6], body[7], body[8], exists, state, command); + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: request channel=%s peer=%llu stage=%s rc=%d (%s) db=%s", + channel, (unsigned long long)peer, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: request cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "request", channel); + return -1; +} + /* Ответ меняет ключ, запрос и очередь одним commit; сетевое подтверждение остаётся вызывающему. */ static int ak_apply_result(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, const uint8_t* body, int* next_out) { sqlite3* db = CC(inst)->db; @@ -569,33 +834,18 @@ static void ak_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { char channel[32]; if (!CC(inst) || CC(inst)->attachment_closing || entry->len < ROUTER_SVC_PAYLOAD_OFF + AK_BODY) goto invalid; memcpy(&peer, d + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8); memcpy(&target, d + ROUTER_SVC_DST_OFF, 8); - if (target != inst->node_id || peer == inst->node_id || + if (target != inst->node_id || !peer || peer == inst->node_id || (d[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) != (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) goto invalid; snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group); uint8_t* body = d + ROUTER_SVC_PAYLOAD_OFF; size_t bytes = entry->len - ROUTER_SVC_PAYLOAD_OFF; uint8_t cmd = body[4], expected[AK_WIRE]; if (!ak_channel(channel) || memcmp(body, "AK01", 4) || cmd < AK_REQUEST || cmd > AK_RESULT_ACK || - bytes != (cmd == AK_GRANT ? AK_WIRE : AK_BODY) || ak_body(inst, channel, body + 5, AK_REQUEST, expected) < 0 || - memcmp(body + 5, expected + 5, AK_BODY - 5)) goto invalid; + bytes != (cmd == AK_GRANT ? AK_WIRE : AK_BODY)) goto invalid; if (cmd == AK_REQUEST) { - if (!ak_authorized(inst, channel, body + 5, peer) || ak_exec(inst, "SAVEPOINT attachment_request")) goto invalid; - sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)" - " VALUES(?,?,?,1,0,unixepoch()) ON CONFLICT(channel,message_id,peer) DO NOTHING"); - if (st) { sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); - sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); } - if (!st || ak_step(inst, st)) goto rollback_request; - st = ak_prepare(inst, "SELECT state FROM attachment_key_requests WHERE channel=? AND message_id=? AND peer=? AND incoming=1"); - if (!st) goto rollback_request; - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); - sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); - int rc = sqlite3_step(st), state = rc == SQLITE_ROW ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); - if (state < 0 || (state && ak_queue(inst, channel, body + 5, peer, state == 3 ? AK_DENY : AK_GRANT)) || - ak_exec(inst, "RELEASE attachment_request")) goto rollback_request; - DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: request saved channel=%s peer=%llu state=%d", channel, (unsigned long long)peer, state); + if (ak_accept_request(inst, channel, peer, body)) goto done; ak_ack(inst, channel, peer, body, AK_REQUEST_ACK); ak_changed(inst, channel); goto done; -rollback_request: - ak_exec(inst, "ROLLBACK TO attachment_request"); ak_exec(inst, "RELEASE attachment_request"); goto done; } + if (ak_body(inst, channel, body + 5, AK_REQUEST, expected) < 0 || memcmp(body + 5, expected + 5, AK_BODY - 5)) goto invalid; int next; if (ak_apply_result(inst, channel, peer, body, &next)) goto done; DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: control committed channel=%s peer=%llu command=%u state=%d", channel, @@ -678,52 +928,195 @@ void chat_attachment_decision_trampoline(void* arg) { } /* Пользовательский интерес хранится отдельно от фоновой storage-копии. */ -void chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]) { - sqlite3_stmt* check = ak_prepare(inst, "SELECT plain_path FROM attachment_keys WHERE channel=? AND message_id=? AND ready=1"); - if (!check) return; - sqlite3_bind_text(check, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(check, 2, message, 32, SQLITE_STATIC); - int result = sqlite3_step(check), missing = result == SQLITE_ROW && ma_file_size((const char*)sqlite3_column_text(check, 0)) < 0; - sqlite3_finalize(check); - if (result != SQLITE_ROW && result != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: cache lookup failed rc=%d", result); return; } - if (missing) { - sqlite3_stmt* reset = ak_prepare(inst, "UPDATE attachment_keys SET ready=0 WHERE channel=? AND message_id=? AND decrypting=0"); - if (!reset) return; - sqlite3_bind_text(reset, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(reset, 2, message, 32, SQLITE_STATIC); - if (ak_step(inst, reset)) return; - DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: decrypted cache missing, restoring from retained key channel=%s", channel); +int chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]) { + if (!inst || !CC(inst) || CC(inst)->attachment_closing || !ak_channel(channel) || !message) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid wanted state/arguments"); return -1; } - sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET wanted=1,error=0 WHERE channel=? AND message_id=?"); - if (!st) return; - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); ak_step(inst, st); + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: wanted rejected inside external transaction channel=%s", channel); return -1; + } + const char* stage = "begin wanted"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment wanted */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare cache"; + rc = sqlite3_prepare_v2(db, "SELECT ready,decrypting,plain_path FROM attachment_keys WHERE channel=? AND message_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read cache"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto fail; + int ready = sqlite3_column_int(st, 0), decrypting = sqlite3_column_int(st, 1); + const char* path = (const char*)sqlite3_column_text(st, 2); + if (!path || (ready != 0 && ready != 1) || (decrypting != 0 && decrypting != 1)) { stage = "cache state"; rc = SQLITE_MISMATCH; goto fail; } + int missing = ready && !decrypting && ma_file_size(path) < 0; + stage = "finalize cache"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + stage = "prepare wanted"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_keys SET wanted=1,error=0,ready=? WHERE channel=? AND message_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind ready"; rc = sqlite3_bind_int(st, 1, missing ? 0 : ready); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write wanted"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize wanted"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; } + stage = "commit wanted"; rc = sqlite3_exec(db, "COMMIT /* attachment wanted */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (missing) DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: missing cache reset committed, restoring from retained key channel=%s", channel); + else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: wanted committed channel=%s ready=%d decrypting=%d", channel, ready, decrypting); + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: wanted channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: wanted cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "wanted", channel); return -1; } /* Автозагрузка обычного участника запрашивает доступ; storage-реплика хранит только ciphertext. */ -void chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]) { - char sql[384]; snprintf(sql, sizeof(sql), "UPDATE attachment_keys SET downloaded=1,wanted=max(wanted," - "NOT EXISTS(SELECT 1 FROM peers_%s WHERE node_id=? AND source=0 AND deleted=0 AND length(adm_tags_sig)=64" - " AND json_extract(adm_tags,'$.storage')='yes')) WHERE channel=? AND message_id=?", channel); - sqlite3_stmt* st = ak_prepare(inst, sql); - if (!st) return; - sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); - sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); - if (!ak_step(inst, st)) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: ciphertext downloaded channel=%s", channel); ak_changed(inst, channel); } +int chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], int verified_download) { + if (!inst || !CC(inst) || CC(inst)->attachment_closing || !ak_channel(channel) || !message) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid downloaded state/arguments"); return -1; + } + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: downloaded rejected inside external transaction channel=%s", channel); return -1; + } + const char* stage = "begin downloaded"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment downloaded */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + char sql[512]; snprintf(sql, sizeof(sql), "SELECT downloaded,wanted,error,NOT EXISTS(SELECT 1 FROM peers_%s" + " WHERE node_id=? AND source=0 AND deleted=0 AND length(adm_tags_sig)=64 AND json_extract(adm_tags,'$.storage')='yes')" + " FROM attachment_keys WHERE channel=? AND message_id=?", channel); + stage = "prepare download state"; rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind node"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "read download state"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto fail; + int downloaded = sqlite3_column_int(st, 0), wanted = sqlite3_column_int(st, 1), error = sqlite3_column_int(st, 2); + int automatic = sqlite3_column_int(st, 3), desired_error = verified_download ? 0 : error; + if ((downloaded != 0 && downloaded != 1) || (wanted != 0 && wanted != 1)) { stage = "download state"; rc = SQLITE_MISMATCH; goto fail; } + int desired = wanted || automatic, changed_state = !downloaded || wanted != desired || error != desired_error; + stage = "finalize download state"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed_state) { + stage = "prepare downloaded"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_keys SET downloaded=1,wanted=?,error=? WHERE channel=? AND message_id=?", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind wanted"; rc = sqlite3_bind_int(st, 1, desired); + if (rc != SQLITE_OK) goto fail; + stage = "bind error"; rc = sqlite3_bind_int(st, 2, desired_error); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 3, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 4, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write downloaded"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize downloaded"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; } + } + stage = "commit downloaded"; rc = sqlite3_exec(db, "COMMIT /* attachment downloaded */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (changed_state) { + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: download state committed channel=%s wanted=%d automatic=%d verified=%d error=%d", + channel, desired, automatic, verified_download != 0, desired_error); + ak_changed(inst, channel); + } + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: downloaded channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: downloaded cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "downloaded", channel); return -1; +} + +/* Выбрать команду и зафиксировать следующую попытку до сети; 1 — команда, 0 — нет, -1 — ошибка. */ +static int ak_next_command(struct UTUN_INSTANCE* inst, char channel[32], uint8_t message[32], uint64_t* peer, int* cmd) { + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox postponed inside external transaction"); return -1; + } + const char* stage = "begin outbox"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment outbox */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare outbox"; + rc = sqlite3_prepare_v2(db, "SELECT channel,message_id,peer,command FROM attachment_key_outbox" + " WHERE retry_at<=unixepoch() ORDER BY retry_at,rowid LIMIT 1", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "read outbox"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto fail; + int found = rc == SQLITE_ROW; + if (found) { + const char* name = (const char*)sqlite3_column_text(st, 0); + if (!ak_channel(name) || sqlite3_column_bytes(st, 1) != 32 || !sqlite3_column_blob(st, 1)) { + stage = "outbox identity"; rc = SQLITE_MISMATCH; goto fail; + } + snprintf(channel, 32, "%s", name); memcpy(message, sqlite3_column_blob(st, 1), 32); + *peer = (uint64_t)sqlite3_column_int64(st, 2); *cmd = sqlite3_column_int(st, 3); + if (!*peer || *peer == inst->node_id || (*cmd != AK_REQUEST && *cmd != AK_GRANT && *cmd != AK_DENY)) { + stage = "outbox peer/command"; rc = SQLITE_MISMATCH; goto fail; + } + } + stage = "finalize outbox"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (!found) { + stage = "end idle outbox"; rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + return 0; + } + stage = "prepare retry"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_key_outbox SET retry_at=unixepoch()+5 WHERE channel=? AND message_id=? AND peer=?", + -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind peer"; rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)*peer); + if (rc != SQLITE_OK) goto fail; + stage = "write retry"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize retry"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; } + stage = "commit outbox"; rc = sqlite3_exec(db, "COMMIT /* attachment outbox */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: retry committed channel=%s peer=%llu command=%d", + channel, (unsigned long long)*peer, *cmd); + return 1; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "outbox", channel); return -1; } /* Один проход ограничен; retry_at даёт всем задачам очередь без голодания. */ static void ak_pump(struct UTUN_INSTANCE* inst) { for (unsigned i = 0; i < 16; i++) { - sqlite3_stmt* st = ak_prepare(inst, "SELECT channel,message_id,peer,command FROM attachment_key_outbox" - " WHERE retry_at<=unixepoch() ORDER BY retry_at,rowid LIMIT 1"); - if (!st) return; - int rc = sqlite3_step(st); - if (rc != SQLITE_ROW) { sqlite3_finalize(st); if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox read failed rc=%d", rc); return; } - char channel[32]; uint8_t message[32], wire[AK_WIRE]; - snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); memcpy(message, sqlite3_column_blob(st, 1), 32); - uint64_t peer = (uint64_t)sqlite3_column_int64(st, 2); int cmd = sqlite3_column_int(st, 3); sqlite3_finalize(st); - st = ak_prepare(inst, "UPDATE attachment_key_outbox SET retry_at=unixepoch()+5 WHERE channel=? AND message_id=? AND peer=?"); - if (!st) return; - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); - sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); if (ak_step(inst, st)) return; + char channel[32] = {0}; uint8_t message[32], wire[AK_WIRE]; uint64_t peer = 0; int cmd = 0; + int rc = ak_next_command(inst, channel, message, &peer, &cmd); + if (rc <= 0) return; if (cmd == AK_GRANT && !ak_authorized(inst, channel, message, peer)) continue; int n = ak_body(inst, channel, message, (uint8_t)cmd, wire); if (n > 0) { @@ -736,48 +1129,179 @@ static void ak_pump(struct UTUN_INSTANCE* inst) { } } -/* Восстанавливаем обработку файлов и запросов из устойчивых данных. */ +/* Создать исходящий запрос вместе с очередью; перечитать условия под собственной блокировкой записи. */ +static int ak_request_file(struct ak_file* job) { + struct UTUN_INSTANCE* inst = job->inst; + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing request rejected inside external transaction channel=%s", job->channel); + return -1; + } + const char* stage = "begin"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare eligibility"; + rc = sqlite3_prepare_v2(db, "SELECT 1 FROM attachment_keys a WHERE channel=? AND message_id=? AND author=?" + " AND downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0 AND key IS NULL" + " AND NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id)", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind author"; rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)job->author); + if (rc != SQLITE_OK) goto fail; + stage = "eligibility"; rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto fail; + stage = "finalize eligibility"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + stage = "prepare request"; + rc = sqlite3_prepare_v2(db, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)" + " VALUES(?,?,?,0,0,unixepoch())", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind author"; rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)job->author); + if (rc != SQLITE_OK) goto fail; + stage = "insert request"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize request"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing request rows=%d expected=1 channel=%s", changed, job->channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + stage = "queue request"; rc = ak_queue(inst, job->channel, job->message, job->author, AK_REQUEST); + if (rc) goto fail; + stage = "commit"; rc = sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing request committed channel=%s author=%llu message=%02x%02x%02x%02x", + job->channel, (unsigned long long)job->author, job->message[0], job->message[1], job->message[2], job->message[3]); + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing request channel=%s author=%llu stage=%s rc=%d (%s) db=%s", + job->channel, (unsigned long long)job->author, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outgoing cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "outgoing request", job->channel); + return -1; +} + +/* Пометить ровно один файл собственной транзакцией до запуска расшифровки. */ +static int ak_claim_decryption(struct ak_file* job) { + struct UTUN_INSTANCE* inst = job->inst; + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: claim rejected inside external transaction channel=%s", job->channel); return -1; + } + const char* stage = "begin claim"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare claim"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_keys SET decrypting=1 WHERE channel=? AND crypto_id=? AND key=?" + " AND downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind key"; rc = sqlite3_bind_blob(st, 3, job->key, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write claim"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize claim"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: decryption claim rows=%d expected=1 channel=%s", changed, job->channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + stage = "commit claim"; rc = sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: decryption claimed channel=%s active=%u", + job->channel, CC(inst)->attachment_file_count); + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: decryption claim channel=%s stage=%s rc=%d (%s) db=%s", + job->channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: claim cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "decryption claim", job->channel); + return -1; +} + +/* Восстанавливаем обработку файлов и запросов из устойчивых данных; внешняя транзакция откладывает весь проход. */ static void ak_tick(void* arg) { struct UTUN_INSTANCE* inst = arg; if (!CC(inst) || CC(inst)->attachment_closing) return; CC(inst)->attachment_timer = NULL; + if (!sqlite3_get_autocommit(CC(inst)->db)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: pending files/outbox postponed inside external transaction"); + goto schedule; + } + for (struct ak_file* job = CC(inst)->attachment_files; job;) { + struct ak_file* next = job->next; + if (job->completed) ak_finish_file(job); + if (CC(inst)->attachment_closing) return; + job = next; + } for (unsigned i = 0; i < 4; i++) { + if (CC(inst)->attachment_file_count >= MEDIA_ASYNC_MAX_TASKS) break; sqlite3_stmt* st = ak_prepare(inst, "SELECT channel,message_id,crypto_id,author,key,cipher_hash,cipher_path,plain_path FROM attachment_keys a" " WHERE downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0 AND" " (length(key)=32 OR NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id)) LIMIT 1"); if (!st) break; int rc = sqlite3_step(st); - if (rc != SQLITE_ROW) { sqlite3_finalize(st); if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: pending file read failed rc=%d", rc); break; } + if (rc != SQLITE_ROW) { + int final_rc = sqlite3_finalize(st); + if (rc != SQLITE_DONE || final_rc != SQLITE_OK) + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: pending file read failed step=%d finalize=%d", rc, final_rc); + break; + } + const char* channel = (const char*)sqlite3_column_text(st, 0); + const char* cipher = (const char*)sqlite3_column_text(st, 6); + const char* plain = (const char*)sqlite3_column_text(st, 7); + int key_bytes = sqlite3_column_bytes(st, 4); + if (!ak_channel(channel) || !cipher || !plain || sqlite3_column_bytes(st, 1) != 32 || sqlite3_column_bytes(st, 2) != 16 || + sqlite3_column_bytes(st, 5) != 32 || (key_bytes != 0 && key_bytes != 32) || strlen(cipher) >= 1024 || strlen(plain) >= 1024 || + !sqlite3_column_blob(st, 1) || !sqlite3_column_blob(st, 2) || !sqlite3_column_blob(st, 5) || + (key_bytes && !sqlite3_column_blob(st, 4))) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid pending identity/paths/key key_bytes=%d", key_bytes); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: pending cleanup finalize rc=%d", final_rc); + break; + } struct ak_file* job = u_calloc(1, sizeof(*job)); if (!job) { sqlite3_finalize(st); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: receive allocation failed"); break; } job->inst = inst; - snprintf(job->channel, sizeof(job->channel), "%s", sqlite3_column_text(st, 0)); memcpy(job->message, sqlite3_column_blob(st, 1), 32); + snprintf(job->channel, sizeof(job->channel), "%s", channel); memcpy(job->message, sqlite3_column_blob(st, 1), 32); memcpy(job->id, sqlite3_column_blob(st, 2), 16); job->author = (uint64_t)sqlite3_column_int64(st, 3); - int has_key = sqlite3_column_bytes(st, 4) == 32; + int has_key = key_bytes == 32; if (has_key) memcpy(job->key, sqlite3_column_blob(st, 4), 32); memcpy(job->hash, sqlite3_column_blob(st, 5), 32); - snprintf(job->cipher, sizeof(job->cipher), "%s", sqlite3_column_text(st, 6)); - snprintf(job->plain, sizeof(job->plain), "%s", sqlite3_column_text(st, 7)); sqlite3_finalize(st); + snprintf(job->cipher, sizeof(job->cipher), "%s", cipher); snprintf(job->plain, sizeof(job->plain), "%s", plain); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: pending file finalize failed channel=%s rc=%d", job->channel, rc); + OPENSSL_cleanse(job->key, 32); u_free(job); break; + } if (has_key) { - st = ak_prepare(inst, "UPDATE attachment_keys SET decrypting=1 WHERE channel=? AND message_id=?"); - if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); } - if (!st || ak_step(inst, st)) { OPENSSL_cleanse(job->key, 32); u_free(job); break; } + if (ak_claim_decryption(job)) { OPENSSL_cleanse(job->key, 32); u_free(job); break; } + job->next = CC(inst)->attachment_files; CC(inst)->attachment_files = job; CC(inst)->attachment_file_count++; media_async_submit(inst->media_async, inst->ua, ak_file_work, job, ak_file_done, job); } else { - int failed = ak_exec(inst, "SAVEPOINT attachment_outgoing"); - st = failed ? NULL : ak_prepare(inst, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at) VALUES(?,?,?,0,0,unixepoch())"); - if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); - sqlite3_bind_int64(st, 3, (sqlite3_int64)job->author); } - if (!failed && (!st || ak_step(inst, st) || ak_queue(inst, job->channel, job->message, job->author, AK_REQUEST) || - ak_exec(inst, "RELEASE attachment_outgoing"))) { - ak_exec(inst, "ROLLBACK TO attachment_outgoing"); ak_exec(inst, "RELEASE attachment_outgoing"); failed = 1; - } - if (!failed) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: decryption request queued channel=%s author=%llu", job->channel, - (unsigned long long)job->author); ak_changed(inst, job->channel); } + int failed = ak_request_file(job); + if (!failed) ak_changed(inst, job->channel); u_free(job); if (failed) break; } } + if (CC(inst)->attachment_closing) return; ak_pump(inst); +schedule: + if (CC(inst)->attachment_closing) return; CC(inst)->attachment_timer = uasync_set_timeout(inst->ua, 10000, inst, ak_tick, "attachment keys"); if (!CC(inst)->attachment_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: timer allocation failed"); } @@ -825,6 +1349,9 @@ static void ak_recover_temps(struct UTUN_INSTANCE* inst) { /* После crash/отмены убрать только собственные подготовки, не имеющие сообщения. * Вызывается до запуска workers либо после их полного завершения. */ static void ak_recover_preparations(struct UTUN_INSTANCE* inst) { + if (!sqlite3_get_autocommit(CC(inst)->db)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: preparation recovery deferred while transaction is open; files preserved"); return; + } ak_recover_temps(inst); char base[MEDIA_BASE_MAX]; if (media_index_get_base(CC(inst)->db, base)) return; @@ -868,7 +1395,12 @@ static void ak_recover_preparations(struct UTUN_INSTANCE* inst) { /* Отдельные локальные таблицы никогда не регистрируются в db_sync/Merkle. */ int chat_attachment_init(struct UTUN_INSTANCE* inst) { - if (!CC(inst) || !inst->media_async) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid initialization"); return -1; } + if (!inst || !CC(inst) || !inst->media_async) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid initialization"); return -1; + } + if (!sqlite3_get_autocommit(CC(inst)->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: initialization rejected inside external transaction"); return -1; + } if (ak_exec(inst, "CREATE TABLE IF NOT EXISTS attachment_keys(" "channel TEXT NOT NULL,crypto_id BLOB NOT NULL CHECK(length(crypto_id)=16),message_id BLOB,author INTEGER NOT NULL," "key BLOB CHECK(key IS NULL OR length(key)=32),cipher_hash BLOB,cipher_path TEXT NOT NULL,plain_path TEXT NOT NULL," @@ -879,6 +1411,14 @@ int chat_attachment_init(struct UTUN_INSTANCE* inst) { "incoming INTEGER NOT NULL,state INTEGER NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel,message_id,peer));" "CREATE TABLE IF NOT EXISTS attachment_key_outbox(channel TEXT NOT NULL,message_id BLOB NOT NULL,peer INTEGER NOT NULL," "command INTEGER NOT NULL,retry_at INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(channel,message_id,peer));" + "CREATE TRIGGER IF NOT EXISTS attachment_channel_delete AFTER DELETE ON channels BEGIN" + " DELETE FROM attachment_key_outbox WHERE channel=OLD.channel_id;" + " DELETE FROM attachment_key_requests WHERE channel=OLD.channel_id;" + " DELETE FROM attachment_keys WHERE channel=OLD.channel_id;" + " SELECT CASE WHEN EXISTS(SELECT 1 FROM attachment_key_outbox WHERE channel=OLD.channel_id)" + " OR EXISTS(SELECT 1 FROM attachment_key_requests WHERE channel=OLD.channel_id)" + " OR EXISTS(SELECT 1 FROM attachment_keys WHERE channel=OLD.channel_id)" + " THEN RAISE(ABORT,'attachment channel cleanup incomplete') END; END;" "UPDATE attachment_keys SET decrypting=0; UPDATE attachment_key_outbox SET retry_at=0;")) return -1; CC(inst)->attachment_closing = 0; CC(inst)->attachment_initialized = 1; @@ -901,26 +1441,64 @@ void chat_attachment_quiesce(struct UTUN_INSTANCE* inst) { void chat_attachment_destroy(struct UTUN_INSTANCE* inst) { chat_attachment_quiesce(inst); - if (CC(inst) && CC(inst)->attachment_initialized) ak_recover_preparations(inst); + if (!CC(inst) || !CC(inst)->attachment_initialized) return; + while (CC(inst)->attachment_files) { + struct ak_file* job = CC(inst)->attachment_files; + if (!job->completed) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: destroy before worker done channel=%s active=%u", + job->channel, CC(inst)->attachment_file_count); return; + } + ak_finish_file(job); + } + ak_recover_preparations(inst); } -/* Удаление ключей отменяет completion активных workers; они удалят свои результаты. */ -void chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel) { - if (!CC(inst) || !CC(inst)->attachment_initialized) return; - sqlite3_stmt* st = ak_prepare(inst, "SELECT plain_path FROM attachment_keys WHERE channel=?"); - if (st) { - sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); int rc; - while ((rc = sqlite3_step(st)) == SQLITE_ROW) { - const char* path = (const char*)sqlite3_column_text(st, 0); - if (path && remove(path) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: plaintext removal failed path=%s", path); - } - if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: channel cleanup scan failed rc=%d", rc); - sqlite3_finalize(st); +struct ak_plain_cleanup { const char* root; unsigned removed; int failed; }; + +/* Только канонические UUID.file непосредственно в собственном каталоге канала; чужие имена и temp сохраняются. */ +static void ak_remove_plain(void* arg, const char* path, int64_t size) { + (void)size; + struct ak_plain_cleanup* cleanup = arg; size_t prefix = strlen(cleanup->root); + if (strncmp(path, cleanup->root, prefix) || path[prefix] != '/') return; + const char* name = path + prefix + 1; + if (strlen(name) != 37 || strspn(name, "0123456789abcdef") != 32 || strcmp(name + 32, ".file")) return; + if (!remove(path)) cleanup->removed++; + else if (errno != ENOENT) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: plaintext removal failed path=%s: %s", path, strerror(errno)); + cleanup->failed = 1; } - const char* tables[] = { "attachment_key_outbox", "attachment_key_requests", "attachment_keys" }; - for (unsigned i = 0; i < 3; i++) { - char sql[128]; snprintf(sql, sizeof(sql), "DELETE FROM %s WHERE channel=?", tables[i]); st = ak_prepare(inst, sql); - if (st) { sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); ak_step(inst, st); } +} + +/* Записи удаляет каскадный trigger вместе с каналом; файловая очистка допустима только после его commit. */ +int chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel) { + if (!inst || !CC(inst) || !CC(inst)->attachment_initialized || !ak_channel(channel)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid channel file cleanup state/arguments"); return -1; } - DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: channel keys and requests removed channel=%s", channel); + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: channel file cleanup rejected inside transaction channel=%s", channel); return -1; + } + int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM channels WHERE channel_id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int final_rc = sqlite3_finalize(st); + if (rc != SQLITE_DONE || final_rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: channel still present or cleanup lookup failed channel=%s step=%d finalize=%d db=%s", + channel, rc, final_rc, sqlite3_errmsg(db)); return -1; + } + char base[MEDIA_BASE_MAX], root[MEDIA_BASE_MAX + 64]; + if (media_index_get_base(db, base)) return -1; + int length = snprintf(root, sizeof(root), "%s/decrypted/%s", base, channel); + if (length < 0 || (size_t)length >= sizeof(root)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: channel plaintext directory too long channel=%s", channel); return -1; + } + struct file_io_info info; int status = file_io_inspect(root, 0, &info); + if (status == 1) return 0; + if (status < 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: cannot inspect plaintext directory path=%s", root); return -1; } + struct ak_plain_cleanup cleanup = {.root = root}; + int error = ma_dir_walk(root, ak_remove_plain, &cleanup); + if (error || cleanup.failed) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: channel plaintext cleanup incomplete channel=%s removed=%u", + channel, cleanup.removed); + else DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: channel plaintext removed after commit channel=%s count=%u", channel, cleanup.removed); + return error || cleanup.failed ? -1 : 0; } diff --git a/src/chat/chat_attachment.h b/src/chat/chat_attachment.h index e018ba225..b487482a1 100644 --- a/src/chat/chat_attachment.h +++ b/src/chat/chat_attachment.h @@ -6,9 +6,17 @@ * Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK атомарно меняют ключ, запрос и outbox собственной транзакцией. * Внешняя транзакция для ответов запрещена. GRANT/DENY подтверждаются только после проверенного commit; * повтор с тем же результатом допустим, конфликтующий ключ/решение отклоняется. Отказ SQL сохраняет прежнее состояние. - * Незавершённый rollback отключает модуль и запрашивает остановку ядра владельцем вне callback. - * Известное ограничение приёма REQUEST: вложенный SAVEPOINT допускает ACK/событие до commit внешней транзакции; - * её rollback теряет уже подтверждённый запрос. См. doc/chat_audit_findings.txt, «Аудит приёма REQUEST». */ + * Приём REQUEST владеет собственной транзакцией проверки идентичности/прав и сохранения запроса/повтора ответа. + * Внешняя транзакция отклоняется без изменений; ACK/событие — после commit. Дубликат сохраняет решение и дату: + * state=0 ждёт автора, 2/4 повторяют GRANT, 3 повторяет DENY; чужое направление и другие состояния отклоняются. + * Исходящий REQUEST сохраняется собственной транзакцией request+outbox после повторной проверки состояния. + * Уведомление/отправка следуют после commit; внешняя транзакция откладывает весь проход файлов и очереди. + * Перед сетевой попыткой отдельно фиксируется retry_at; отказ SQL не отправляет команду и сохраняет очередь/дату. + * Пустая очередь завершается без commit hook. retry_at отодвигается на5с; tick каждые10с, до16 команд за проход. + * Расшифровка стартует после commit decrypting=1. До commit метаданных завершение остаётся у модуля: + * таймер повторяет SQL без нового worker; не более 16 активных/ожидающих завершения файлов на instance. + * quiesce останавливает повторы, destroy после workers освобождает также отложенные завершения. + * Незавершённый rollback отключает модуль и запрашивает остановку ядра владельцем вне callback. */ #ifndef CHAT_ATTACHMENT_H #define CHAT_ATTACHMENT_H #include "../media_async/attachment.h" @@ -20,13 +28,13 @@ struct UTUN_INSTANCE; // ── Жизненный цикл модуля ── -// Подключить таблицы, router-сервис и обработку сохранённых задач; 0/-1. +// Подключить таблицы, router-сервис и обработку сохранённых задач; 0/-1. Внешняя транзакция запрещена. int chat_attachment_init(struct UTUN_INSTANCE* inst); // Запретить новые подготовки и остановить таймер; запущенные workers завершаются отдельно. void chat_attachment_quiesce(struct UTUN_INSTANCE* inst); -// После завершения workers убрать незавершённые подготовки и отключить router-сервис. +// После workers убрать незавершённые подготовки; открытая транзакция откладывает recovery, сохраняя файлы. void chat_attachment_destroy(struct UTUN_INSTANCE* inst); // ── Отправка и учёт вложений ── @@ -43,24 +51,32 @@ void chat_attachment_destroy(struct UTUN_INSTANCE* inst); int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const char* source, const struct attachment_info* info, const char* type, const uint8_t reply[32], uint64_t request); -// Прочитать UUID из канонического base64-префикса тела медиа; 0/-1. Подпись проверяет вызывающий. +// Прочитать UUID из канонического base64-префикса тела медиа; 0/-1, id меняется только при успехе. Подпись проверяет вызывающий. int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]); -// Связать авторский ключ с ID сообщения и SHA256 контейнера до публикации; 0/-1. Массивы копируются в БД. +/* Собственной транзакцией связать авторский ключ с ID сообщения и SHA256 контейнера до публикации; 0/-1. + * Массивы копируются в БД. Повтор той же пары допустим, её изменение запрещено; отказ сохраняет прежние поля. + * Внешняя транзакция отклоняется без изменений; все SQL-этапы и commit проверяются. */ int chat_attachment_bind(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t id[16], const uint8_t message[32], const uint8_t hash[32]); -// Учесть проверенное db_sync сообщение и сохранить метаданные контейнера; 0/-1. Обычные сообщения игнорируются. +/* Учесть проверенное db_sync сообщение собственной транзакцией, не меняя ключ/состояние существующей идентичности. + * 0 — записано/точный дубликат/обычное сообщение, -1 — SQL/OOM-отказ для повтора, -2 — неверный manifest/коллизия. + * Внешняя транзакция запрещена. Подпись проверяет вызывающий; JSON заимствован до возврата. */ int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t author, const char* json, size_t length); // ── Получение файла и выдача доступа ── -// После проверенной загрузки: сохранить состояние, запросить ключ либо расшифровать. -void chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]); +/* После загрузки/наличия сохранённого контейнера: своей транзакцией сохранить downloaded и policy wanted. + * 0/-1; запись observe обязательна. Повтор неизменного состояния не даёт события. Криптографию проверит worker. + * verified_download!=0 — новая проверенная передача, также сбрасывает прежний error; 0 — найденный cache, error сохраняется. + * Внешняя транзакция запрещена, SQL-отказ сохраняет прежние поля, событие — после commit. */ +int chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], int verified_download); -// Ручная загрузка на storage-узле также означает запрос пользовательского доступа. -void chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]); +/* Ручная загрузка, включая storage: одной транзакцией установить wanted, сбросить error и ready отсутствующего cache. + * 0/-1; запись observe обязательна. Внешняя транзакция запрещена, отказ сохраняет прежние поля. */ +int chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]); /* Сохранить решение автора по ожидающему запросу peer: approve!=0 — выдать ключ, 0 — отказать. * 0 — собственная транзакция решения+outbox завершена, затем послано одно уведомление изменения; @@ -83,8 +99,10 @@ void chat_attachment_decision_trampoline(void* arg); // ── Локальное удаление канала ── -// Канал удаляется локально: отменить очереди, стереть ключи и частные копии. -void chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel); +/* После commit удаления канала убрать собственные UUID.file; 0/-1, при I/O-ошибке можно повторить. + * Ключи/запросы/outbox удаляет SQLite-trigger атомарно с channels; SQL-отказ сохраняет все записи. + * Внешняя транзакция или существующий канал запрещают файловую очистку; завершения workers видят удаление через БД. */ +int chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel); #ifdef __cplusplus } diff --git a/src/chat/chat_channel.c b/src/chat/chat_channel.c index a764047b6..0d53e8acc 100644 --- a/src/chat/chat_channel.c +++ b/src/chat/chat_channel.c @@ -32,30 +32,34 @@ #include #include #include +#include +#include +#include -/* ─── Ed25519 подпись join-сообщения ─── */ +uint64_t chat_channel_id(const char* text) { + if (!text) return 0; + size_t length = strnlen(text, 20); + if (!length || length == 20 || text[0] == '0' || strspn(text, "0123456789") != length) return 0; + uint64_t id = strtoull(text, NULL, 10); return id <= INT64_MAX ? id : 0; +} -static int channel_sign_join(const uint8_t* ch_x25519, const uint8_t* ch_ed25519, - uint64_t node_id, const uint8_t* node_pubkey, - uint64_t join_ts, const uint8_t* ed25519_privkey, - uint8_t sig_out[64]) -{ - uint8_t msg[128]; - int mlen = member_sync_build_join_msg(ch_x25519, ch_ed25519, node_id, node_pubkey, join_ts, - msg, (int)sizeof(msg)); - if (mlen < 0) { memset(sig_out, 0, 64); return -1; } - - EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, - ed25519_privkey, 32); - if (!pkey) { memset(sig_out, 0, 64); return -1; } - EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); - if (!mdctx) { EVP_PKEY_free(pkey); memset(sig_out, 0, 64); return -1; } - int ok = EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey) == 1 - && EVP_DigestSign(mdctx, sig_out, &(size_t){64}, msg, (size_t)mlen) == 1; - EVP_MD_CTX_free(mdctx); - EVP_PKEY_free(pkey); - if (!ok) memset(sig_out, 0, 64); - return ok ? 0 : -1; +/* Один формат описания для создания и проверки подписи входящего JOIN/invite. */ +int chat_channel_description_data(const char* channel, const char* name, uint64_t owner, + const uint8_t x25519[32], const uint8_t ed25519[32], uint8_t* out, size_t capacity) { + if (!channel || !name || !x25519 || !ed25519 || !out) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel description: missing argument"); return -1; + } + size_t channel_len = strnlen(channel, 20), name_len = strnlen(name, 256); + if (!chat_channel_id(channel) || name_len == 256 || capacity < channel_len + name_len + 74) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel description: invalid channel/name lengths=%zu/%zu capacity=%zu", + channel_len, name_len, capacity); return -1; + } + size_t offset = channel_len + 1; memcpy(out, channel, offset); + memcpy(out + offset, name, name_len + 1); offset += name_len + 1; + memcpy(out + offset, &owner, 8); offset += 8; + memcpy(out + offset, x25519, 32); offset += 32; + memcpy(out + offset, ed25519, 32); offset += 32; + return (int)offset; } /* ─── подготовка инфраструктуры канала (db_sync instance) ─── */ @@ -91,67 +95,102 @@ int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id /* ─── создание канала ─── */ -void chat_core_create_channel(struct UTUN_INSTANCE* inst, struct chat_channel_create* req) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel NOT INITIALIZED ch=%s", - CC_ID, req ? req->channel_id : "(null)"); - return; +int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_channel_create* req) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !inst->topo_groups || !req || req->owner_node_id != inst->node_id || + strnlen(req->name, sizeof(req->name)) == sizeof(req->name)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid context/owner/name"); return -1; } - if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel req=NULL", CC_ID); return; } + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: external transaction rejected"); return -1; + } + uint8_t description[CHAT_CHANNEL_DESCRIPTION_MAX], public_key[32]; + int bytes = chat_channel_description_data(req->channel_id, req->name, req->owner_node_id, + req->x25519_pubkey, req->ed25519_pubkey, description, sizeof(description)); + if (bytes < 0 || sc_compute_public_key_from_private(req->x25519_privkey, public_key) != SC_OK || + memcmp(public_key, req->x25519_pubkey, 32)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid description or X25519 key pair"); return -1; + } + EVP_PKEY* ed = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, req->ed25519_privkey, 32); + size_t public_size = 32; + int valid_pair = ed && EVP_PKEY_get_raw_public_key(ed, public_key, &public_size) == 1 && public_size == 32; + EVP_PKEY_free(ed); + if (!valid_pair || memcmp(public_key, req->ed25519_pubkey, 32) || + sc_ed25519_verify(req->ed25519_pubkey, description, (size_t)bytes, req->signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid Ed25519 key pair/description signature ch=%s", req->channel_id); return -1; + } + uint64_t group_id = strtoull(req->channel_id, NULL, 10); + if (topo_groups_find(inst->topo_groups, group_id)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: group ID already in use ch=%s", req->channel_id); return -1; + } + time_t now = ntp_time_get_seconds(inst); + if (now <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: invalid join time ch=%s", req->channel_id); return -1; } + uint64_t timestamp = (uint64_t)now; + uint8_t join[64], root[64], update[64], body[MS_USERINFO_MAX + 73]; char profile[MS_USERINFO_MAX + 1]; + bytes = member_sync_build_join_msg(req->x25519_pubkey, req->ed25519_pubkey, inst->node_id, + inst->my_keys.public_key, timestamp, body, sizeof(body)); + if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, join) != SC_OK || + member_sync_sign_pubkey(req->ed25519_privkey, inst->my_keys.public_key, root) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: root JOIN/invitation signing failed ch=%s", req->channel_id); return -1; + } + if (chat_core_name_userinfo(inst->name, profile, sizeof(profile))) return -1; + bytes = member_sync_build_update_msg(join, timestamp, profile, body, sizeof(body)); + if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, update) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: root profile signing failed ch=%s", req->channel_id); return -1; + } + sqlite3_stmt* st = NULL; const char* stage = "begin"; + int rc = sqlite3_exec(cc->db, "BEGIN IMMEDIATE /* channel creation */", NULL, NULL, NULL); + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: begin ch=%s rc=%d: %s", req->channel_id, rc, sqlite3_errmsg(cc->db)); return -1; + } + stage = "lookup channel"; + rc = sqlite3_prepare_v2(cc->db, "SELECT 1 FROM channels WHERE channel_id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, req->channel_id, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) { stage = "channel already exists"; rc = SQLITE_CONSTRAINT; goto error; } + if (rc != SQLITE_DONE) goto error; + stage = "lookup finalize"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto error; + stage = "store description"; + if (topo_node_sqlite_channel_put(cc->db, req->channel_id, req->name, req->owner_node_id, + req->x25519_pubkey, req->x25519_privkey, req->ed25519_pubkey, req->ed25519_privkey, req->signature)) { + rc = SQLITE_ERROR; goto error; + } + stage = "feedback schema"; + if (chat_feedback_ensure_channel(inst, req->channel_id)) { rc = SQLITE_ERROR; goto error; } + stage = "store root member"; + int changes = member_sync_put(inst, req->channel_id, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, + join, timestamp, update, timestamp, profile, NULL, NULL, 0, 0, root); + if (changes < 0 || !(changes & MS_APPLY_CHANGED)) { rc = SQLITE_ERROR; goto error; } + stage = "verify stored root"; + if (member_sync_author_key(inst, req->channel_id, inst->node_id, public_key) != MS_AUTHOR_ACTIVE || + memcmp(public_key, inst->my_ed25519_pubkey, 32)) { rc = SQLITE_CONSTRAINT; goto error; } + stage = "commit"; rc = sqlite3_exec(cc->db, "COMMIT /* channel creation */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto error; - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel BEGIN ch=%s name=%s", - CC_ID, req->channel_id, req->name); - - int rc = topo_node_sqlite_channel_put(cc->db, - req->channel_id, req->name, req->owner_node_id, - req->x25519_pubkey, req->x25519_privkey, - req->ed25519_pubkey, req->ed25519_privkey, - req->signature); - - if (rc != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel_put FAILED ch=%s rc=%d", - CC_ID, req->channel_id, rc); - } else { - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel_put OK ch=%s name=%s owner=0x%016llx", - CC_ID, req->channel_id, req->name, (unsigned long long)req->owner_node_id); - - chat_core_ensure_channel_ready(inst, req->channel_id); - - uint8_t ch_id_len = (uint8_t)strlen(req->channel_id); - uint8_t data[65]; - data[0] = ch_id_len; - memcpy(data + 1, req->channel_id, ch_id_len); - - { - uint64_t myid = inst->node_id; - uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst); - uint8_t join_sig[64]; - if (channel_sign_join(req->x25519_pubkey, req->ed25519_pubkey, myid, inst->my_keys.public_key, - join_ts, inst->my_ed25519_privkey, join_sig) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: root join signing failed ch=%s", CC_ID, req->channel_id); return; - } - - /* корень дерева приглашений: подписан ключом группы (канальный ed25519) */ - uint8_t root_sig[64]; - struct ms_member_rec root = { .node_id = myid, .x25519 = inst->my_keys.public_key, .signature = root_sig }; - if (member_sync_sign_pubkey(req->ed25519_privkey, inst->my_keys.public_key, root_sig) < 0 - || member_sync_verify_signature(cc->db, req->channel_id, &root) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: root invitation signing failed ch=%s", CC_ID, req->channel_id); return; - } - int mrc = member_sync_put(inst, req->channel_id, myid, - inst->my_keys.public_key, inst->my_ed25519_pubkey, - join_sig, join_ts, NULL, 0, "{}", NULL, NULL, 0, 0, root_sig); - if (mrc < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: member_sync_put(self) FAILED ch=%s rc=%d", - CC_ID, req->channel_id, mrc); - } - /* адреса + update_ts + подпись + синк — единой функцией */ - chat_core_update_my_member(inst); + int ready = !chat_core_ensure_channel_ready(inst, req->channel_id); + if (!ready) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel create: committed ch=%s, runtime activation deferred; retry preparation", req->channel_id); + member_sync_record_committed(inst, req->channel_id, inst->node_id, (unsigned)changes); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "channel created: ch=%s name=%s root=%016llx committed=1 runtime_ready=%d", + req->channel_id, req->name, (unsigned long long)inst->node_id, ready); + uint8_t event[21]; size_t length = strlen(req->channel_id); event[0] = (uint8_t)length; + memcpy(event + 1, req->channel_id, length); chat_event_post(inst, CHAT_EVT_CHANNEL_UPDATED, event, (int)length + 1); + return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: ch=%s stage=%s rc=%d (%s) db=%s", + req->channel_id, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(cc->db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: cleanup finalize ch=%s rc=%d", req->channel_id, rc); + if (!sqlite3_get_autocommit(cc->db)) { + rc = sqlite3_exec(cc->db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: rollback ch=%s rc=%d: %s", + req->channel_id, rc, sqlite3_errmsg(cc->db)); + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel create: transaction remains open ch=%s; requesting owner stop/DB close", req->channel_id); + utun_instance_stop(inst); } - - chat_event_post(inst, CHAT_EVT_CHANNEL_UPDATED, data, 1 + ch_id_len); } + return -1; } void chat_core_create_channel_trampoline(void* arg) { @@ -159,6 +198,7 @@ void chat_core_create_channel_trampoline(void* arg) { struct chat_channel_create* req = (struct chat_channel_create*)arg; if (!req) return; chat_core_create_channel(req->inst, req); + OPENSSL_cleanse(req, sizeof(*req)); u_free(req); } @@ -169,62 +209,51 @@ void chat_core_create_channel_auto_trampoline(void* arg) { u_free(arg); } -void chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !name) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel_auto — not initialized or NULL name", CC_ID); - return; +/* Генерация двух независимых типов ключей с проверкой всех стадий OpenSSL. */ +static int channel_generate_keypair(int type, uint8_t public_key[32], uint8_t private_key[32]) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(type, NULL); EVP_PKEY* key = NULL; + const char* stage = "context"; size_t public_size = 32, private_size = 32; + if (!ctx) goto error; + stage = "initialize"; if (EVP_PKEY_keygen_init(ctx) != 1) goto error; + stage = "generate"; if (EVP_PKEY_keygen(ctx, &key) != 1) goto error; + stage = "public key"; + if (EVP_PKEY_get_raw_public_key(key, public_key, &public_size) != 1 || public_size != 32) goto error; + stage = "private key"; + if (EVP_PKEY_get_raw_private_key(key, private_key, &private_size) != 1 || private_size != 32) goto error; + EVP_PKEY_free(key); EVP_PKEY_CTX_free(ctx); return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel key generation: type=%d stage=%s public_bytes=%zu private_bytes=%zu", + type, stage, public_size, private_size); + EVP_PKEY_free(key); EVP_PKEY_CTX_free(ctx); return -1; +} + +uint64_t chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !inst->topo_groups || !name || strnlen(name, 128) == 128) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: invalid context/name or name exceeds127 bytes"); return 0; } - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: create_channel_auto BEGIN name=%s", CC_ID, name); - - uint8_t x25519_pub[32] = {0}, x25519_priv[32] = {0}; - { EVP_PKEY* pkey = NULL; EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); - if (ctx) { EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); } - if (pkey) { size_t pkLen = 32, skLen = 32; EVP_PKEY_get_raw_public_key(pkey, x25519_pub, &pkLen); EVP_PKEY_get_raw_private_key(pkey, x25519_priv, &skLen); EVP_PKEY_free(pkey); } } - - uint8_t ed25519_pub[32] = {0}, ed25519_priv[32] = {0}; - EVP_PKEY* ed_pkey = NULL; - { EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); - if (ctx) { EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &ed_pkey); EVP_PKEY_CTX_free(ctx); } - if (ed_pkey) { size_t pkLen = 32, skLen = 32; EVP_PKEY_get_raw_public_key(ed_pkey, ed25519_pub, &pkLen); EVP_PKEY_get_raw_private_key(ed_pkey, ed25519_priv, &skLen); } } - - uint64_t chId = 0; RAND_bytes((uint8_t*)&chId, sizeof(chId)); - chId &= 0x7FFFFFFFFFFFFFFFULL; /* как node_id — всегда < 2^63 (помещается в signed Long) */ - if (chId == 0) chId = 1; - char channelId[64]; snprintf(channelId, sizeof(channelId), "%llu", (unsigned long long)chId); - - uint8_t sig[64] = {0}; - if (ed_pkey) { - size_t nameLen = strlen(name); - size_t chIdLen = strlen(channelId); - size_t msgLen = chIdLen + 1 + nameLen + 1 + 8 + 32 + 32; - uint8_t* msg = u_malloc(msgLen); - if (msg) { - uint8_t* p = msg; - memcpy(p, channelId, chIdLen); p += chIdLen; *p++ = '\0'; - memcpy(p, name, nameLen); p += nameLen; *p++ = '\0'; - memcpy(p, &cc->my_node_id, 8); p += 8; - memcpy(p, x25519_pub, 32); p += 32; - memcpy(p, ed25519_pub, 32); - EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); - if (mdctx) { EVP_DigestSignInit(mdctx, NULL, NULL, NULL, ed_pkey); size_t sl = 64; EVP_DigestSign(mdctx, sig, &sl, msg, msgLen); EVP_MD_CTX_free(mdctx); } - u_free(msg); - } - EVP_PKEY_free(ed_pkey); + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: external transaction rejected before key generation"); return 0; } - - struct chat_channel_create* req = u_calloc(1, sizeof(*req)); - if (!req) return; - req->inst = inst; - snprintf(req->channel_id, sizeof(req->channel_id), "%s", channelId); - snprintf(req->name, sizeof(req->name), "%s", name); - req->owner_node_id = cc->my_node_id; - memcpy(req->x25519_pubkey, x25519_pub, 32); memcpy(req->x25519_privkey, x25519_priv, 32); - memcpy(req->ed25519_pubkey, ed25519_pub, 32); memcpy(req->ed25519_privkey, ed25519_priv, 32); - memcpy(req->signature, sig, 64); - - chat_core_create_channel(inst, req); - u_free(req); + struct chat_channel_create req = {.inst = inst, .owner_node_id = inst->node_id}; + uint8_t description[CHAT_CHANNEL_DESCRIPTION_MAX]; uint64_t group_id = 0; int result = -1; + if (channel_generate_keypair(EVP_PKEY_X25519, req.x25519_pubkey, req.x25519_privkey) || + channel_generate_keypair(EVP_PKEY_ED25519, req.ed25519_pubkey, req.ed25519_privkey)) goto done; + if (RAND_bytes((uint8_t*)&group_id, sizeof(group_id)) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: ID generation failed"); goto done; + } + group_id &= INT64_MAX; if (!group_id) group_id = 1; + snprintf(req.channel_id, sizeof(req.channel_id), "%llu", (unsigned long long)group_id); + memcpy(req.name, name, strlen(name) + 1); + int bytes = chat_channel_description_data(req.channel_id, req.name, req.owner_node_id, + req.x25519_pubkey, req.ed25519_pubkey, description, sizeof(description)); + if (bytes < 0 || sc_ed25519_sign(req.ed25519_privkey, description, (size_t)bytes, req.signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel auto create: description signing failed ch=%s", req.channel_id); goto done; + } + result = chat_core_create_channel(inst, &req); +done: + OPENSSL_cleanse(&req, sizeof(req)); + return result == 0 ? group_id : 0; } /* ─── управление подключением к каналу (выбор в GUI) ─── */ @@ -324,12 +353,15 @@ void chat_core_apply_group_autoconnect(struct UTUN_INSTANCE* inst) { void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) { - struct chat_core_ctx* cc = CC(inst); + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; if (!cc || !cc->initialized || !cc->db || !inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel — not initialized", CC_ID); return; } if (!ch_id || !ch_id[0]) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel — empty ch_id", CC_ID); return; } + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel rejected inside external transaction ch=%s", CC_ID, ch_id); return; + } if (chat_media_defer_channel_delete(inst, ch_id)) return; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: delete_channel BEGIN ch=%s", CC_ID, ch_id); @@ -337,7 +369,6 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) { /* 1. отмена медиа-загрузок/релэев/суперузлов канала (держат conn_mgr handle группы) */ media_delivery_cancel_channel(inst, gid); - chat_attachment_delete_channel(inst, ch_id); member_files_cancel_group(inst, gid); group_avatar_cancel_group(inst, gid); @@ -357,16 +388,22 @@ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id) { /* 5. немедленно убрать канал из кеша chat_sync */ chat_sync_on_channel_deleted(inst, ch_id); - /* 6. удалить медиафайлы канала на диске + строки media_files */ - char media_base[MEDIA_BASE_MAX]; - int media_error = media_index_get_base(cc->db, media_base) || media_index_delete_channel(cc->db, ch_id, media_base); - if (media_error) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "channel=%s removed with incomplete media cleanup; retry required", ch_id); - - /* 7. удалить таблицы и метаданные канала */ + /* 6. удалить таблицы, метаданные и attachment-записи одной SQL-транзакцией до файловой очистки */ if (topo_node_sqlite_channel_delete(cc->db, ch_id)) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s", ch_id); return; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel metadata deletion failed ch=%s; files preserved, retry required", ch_id); + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel delete left transaction open ch=%s; requesting owner stop", ch_id); + chat_attachment_quiesce(inst); utun_instance_stop(inst); + } + return; } + /* 7. удалить файлы только после commit; I/O-отказ не восстанавливает удалённый канал */ + int media_error = chat_attachment_delete_channel(inst, ch_id); + char media_base[MEDIA_BASE_MAX]; + if (media_index_get_base(cc->db, media_base) || media_index_delete_channel(cc->db, ch_id, media_base)) media_error = 1; + if (media_error) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "channel=%s removed with incomplete media cleanup; retry required", ch_id); + /* 8. уведомить GUI */ { uint8_t evt[65]; uint8_t cl = (uint8_t)strlen(ch_id); diff --git a/src/chat/chat_core.c b/src/chat/chat_core.c index 687424bdc..b20ca1b44 100644 --- a/src/chat/chat_core.c +++ b/src/chat/chat_core.c @@ -25,10 +25,12 @@ #include "../transport_layer/secure_channel.h" #include "../media_delivery/media_index.h" #include "../../lib/mem.h" +#include "../../lib/strbuf.h" #include "../../lib/json_flat.h" #include #include +#include #include "../../lib/platform_compat.h" /* ─── хелперы ─── */ @@ -277,134 +279,218 @@ int chat_core_is_initialized(struct UTUN_INSTANCE* inst) { return (inst && CC(in /* ─── chat settings (GUI → uasync) ─── */ -void chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value) { - chat_setting_set(inst, name, value); - if (name && strcmp(name, "group_autoconnect") == 0) chat_core_apply_group_autoconnect(inst); +int chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value) { + int rc = chat_setting_set(inst, name, value); + if (!rc && !strcmp(name, "group_autoconnect")) chat_core_apply_group_autoconnect(inst); + return rc; } void chat_core_set_setting_trampoline(void* arg) { struct chat_setting_arg* a = (struct chat_setting_arg*)arg; - const char* name = a->data; - const char* value = name + strlen(name) + 1; - chat_core_set_setting(a->inst, name, value); + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "chat setting: null request"); return; } + const char* end = memchr(a->data, 0, sizeof(a->data)); + if (!end || end == a->data + sizeof(a->data) - 1 || !memchr(end + 1, 0, sizeof(a->data) - (size_t)(end + 1 - a->data))) + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "chat setting: unterminated name/value request"); + else chat_core_set_setting(a->inst, a->data, end + 1); u_free(arg); } /* ─── per-channel «play sound on new message» ─── */ int chat_core_get_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return 1; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !chat_channel_id(ch_id)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: invalid read context/channel"); return -1; + } char key[128]; snprintf(key, sizeof(key), "ch_%s_sound_on_message", ch_id); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, "SELECT value FROM ui_state WHERE key=?", -1, &st, NULL) != SQLITE_OK) return 1; - sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); - int on = 1; - if (sqlite3_step(st) == SQLITE_ROW) { + int rc = sqlite3_prepare_v2(cc->db, "SELECT value FROM ui_state WHERE key=?", -1, &st, NULL), on = 1; + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) { + int type = sqlite3_column_type(st, 0); const char* v = (const char*)sqlite3_column_text(st, 0); - if (v) on = atoi(v) != 0; + if (type != SQLITE_TEXT || sqlite3_column_bytes(st, 0) != 1 || !v || (*v != '0' && *v != '1')) rc = SQLITE_CORRUPT; + else on = *v == '1'; + } + int final = sqlite3_finalize(st); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: read ch=%s rc=%d finalize=%d: %s", ch_id, rc, final, sqlite3_errmsg(cc->db)); return -1; } - sqlite3_finalize(st); return on; } -void chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return; +int chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on) { + if (!chat_channel_id(ch_id)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: invalid channel"); return -1; } char key[128]; snprintf(key, sizeof(key), "ch_%s_sound_on_message", ch_id); char val[2]; snprintf(val, sizeof(val), "%d", on ? 1 : 0); - chat_core_save_ui_state(inst, key, val); + if (chat_core_save_ui_state(inst, key, val)) return -1; DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: sound_on_message ch=%s = %d", CC_ID, ch_id, on ? 1 : 0); chat_sync_broadcast_sound_setting(inst, ch_id, on ? 1 : 0); + return 0; } void chat_core_set_sound_on_message_trampoline(void* arg) { struct chat_sound_arg* a = (struct chat_sound_arg*)arg; - if (!a) return; - chat_core_set_sound_on_message(a->inst, a->ch_id, a->on); + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: null request"); return; } + if (!memchr(a->ch_id, 0, sizeof(a->ch_id))) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: unterminated channel request"); + else chat_core_set_sound_on_message(a->inst, a->ch_id, a->on); u_free(arg); } /* ─── headless API ─── */ -static void json_escape(const char* src, char* dst, size_t dst_sz) { - size_t i = 0; const char* s = src; - while (*s && i < dst_sz - 2) { - const char* escape = NULL; - switch (*s) { - case '"': escape = "\\\""; break; - case '\\': escape = "\\\\"; break; - case '\n': escape = "\\n"; break; - case '\r': escape = "\\r"; break; - case '\t': escape = "\\t"; break; - case '\b': escape = "\\b"; break; - case '\f': escape = "\\f"; break; - } - if (escape) { - if (i + 2 >= dst_sz) break; - dst[i++] = escape[0]; dst[i++] = escape[1]; s++; continue; - } - dst[i++] = *s++; +/* Результат чтения публикуется целиком;даже неверные аргументы не оставляют старый JSON. */ +static struct chat_core_ctx* json_context(struct UTUN_INSTANCE* inst, char* buf, size_t size, size_t* out_len) { + if (out_len) *out_len = 0; + if (buf && size) buf[0] = 0; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !buf || size < 3 || !out_len) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat JSON: invalid context/output capacity=%zu", size); return NULL; } - dst[i] = '\0'; + return cc; +} + +static int json_copy(const struct strbuf* json, char* buf, size_t capacity, size_t* out_len) { + if (json->len >= capacity) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat JSON: output exceeds capacity bytes=%zu capacity=%zu", json->len, capacity); return -1; + } + memcpy(buf, strbuf_str(json), json->len + 1); *out_len = json->len; return 0; +} + +/* Не превращать NOMEM или скрытый NUL SQLite TEXT в пустую/усечённую строку. */ +static int json_read_text(sqlite3_stmt* st, int column, const char** out) { + *out = NULL; int type = sqlite3_column_type(st, column); + if (type == SQLITE_NULL) return SQLITE_OK; + if (type != SQLITE_TEXT) return SQLITE_CORRUPT; + const char* text = (const char*)sqlite3_column_text(st, column); + if (!text) return SQLITE_NOMEM; + if (strlen(text) != (size_t)sqlite3_column_bytes(st, column)) return SQLITE_CORRUPT; + *out = text; return SQLITE_OK; +} + +int chat_json_strings(sqlite3* db, const char* text, size_t limit, + int (*visit)(const char* key, const char* value, void* arg), void* arg) { + if (!db || !text || limit > INT_MAX || strnlen(text, limit + 1) > limit) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "flat JSON: invalid arguments/size limit=%zu", limit); return -1; + } + sqlite3_stmt* st = NULL; const char* stage = "validate prepare"; + int rc = sqlite3_prepare_v2(db, "SELECT json_valid(?1),CASE WHEN json_valid(?1) THEN json_type(?1) END", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, text, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto error; + const char* type = NULL; rc = json_read_text(st, 1, &type); if (rc != SQLITE_OK) goto error; + if (!sqlite3_column_int(st, 0) || !type || strcmp(type, "object")) { stage = "not a valid object"; rc = SQLITE_CONSTRAINT; goto error; } + rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + stage = "field schema"; + rc = sqlite3_prepare_v2(db, "SELECT count(*),count(DISTINCT key),coalesce(sum(type<>'text'),0) FROM json_each(?1)", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, text, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto error; + if (sqlite3_column_int64(st, 0) != sqlite3_column_int64(st, 1) || sqlite3_column_int64(st, 2)) { + stage = "duplicate/nonstring fields"; rc = SQLITE_CONSTRAINT; goto error; + } + rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + stage = "fields"; + rc = sqlite3_prepare_v2(db, "SELECT key,value FROM json_each(?1)", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, text, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto error; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const char *key, *value; rc = json_read_text(st, 0, &key); if (rc == SQLITE_OK) rc = json_read_text(st, 1, &value); + if (rc != SQLITE_OK) goto error; + if (!key || !value || (visit && visit(key, value, arg))) { stage = "field callback"; rc = SQLITE_CONSTRAINT; goto error; } + } + if (rc != SQLITE_DONE) goto error; + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "flat JSON: stage=%s rc=%d: %s", stage, rc, sqlite3_errmsg(db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "flat JSON: cleanup finalize rc=%d", rc); + return -1; +} + +/* Сохранённый канал может ещё не иметь history-таблицы при отложенной runtime-подготовке. */ +static int json_message_count(sqlite3* db, const char* channel, sqlite3_int64* count) { + char table[80], sql[128]; msg_table_name(channel, table, sizeof(table)); sqlite3_stmt* st = NULL; + const char* stage = "history table lookup"; + int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, table, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int found = rc == SQLITE_ROW; + if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto error; + rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + if (!found) { + *count = 0; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "chat JSON: history not prepared ch=%s", channel); return 0; + } + stage = "history count"; snprintf(sql, sizeof(sql), "SELECT count(*) FROM \"%s\"", table); + rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc != SQLITE_ROW) goto error; + sqlite3_int64 result = sqlite3_column_int64(st, 0); + rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + *count = result; return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat JSON: ch=%s stage=%s rc=%d: %s", channel, stage, rc, sqlite3_errmsg(db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat JSON: count cleanup finalize rc=%d", rc); + return -1; } int chat_core_get_channels_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !buf || !out_len) return -1; - char* w = buf; char* end = buf + buf_size; - *w++ = '['; const char* sep = ""; - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, - "SELECT c.channel_id, c.name, c.owner_node_id, " - "(SELECT COUNT(*) FROM nodes WHERE node_id=c.owner_node_id AND name IS NOT NULL) AS has_owner_name, " - "(SELECT name FROM nodes WHERE node_id=c.owner_node_id) AS owner_name " - "FROM channels c ORDER BY c.created_at ASC", - -1, &st, NULL) != SQLITE_OK) { *out_len = 0; return -1; } - while (sqlite3_step(st) == SQLITE_ROW) { - const char* ch_id = (const char*)sqlite3_column_text(st, 0); - const char* ch_name = (const char*)sqlite3_column_text(st, 1); - uint64_t owner_id = (uint64_t)sqlite3_column_int64(st, 2); - const char* owner_name = (const char*)sqlite3_column_text(st, 4); - if (!ch_id) continue; - - uint32_t msg_count = chat_core_count(inst, ch_id); - char peers_tbl[80]; peers_table_name(ch_id, peers_tbl, sizeof(peers_tbl)); - int peer_count = 0, online_count = 0; - sqlite3_stmt* ps = NULL; - char psql[200]; snprintf(psql, sizeof(psql), "SELECT node_id FROM \"%s\" WHERE deleted=0", peers_tbl); - if (sqlite3_prepare_v2(cc->db, psql, -1, &ps, NULL) == SQLITE_OK) { - while (sqlite3_step(ps) == SQLITE_ROW) { - uint64_t pid = (uint64_t)sqlite3_column_int64(ps, 0); - peer_count++; - if (chat_core_member_online(inst, ch_id, pid)) online_count++; - } - sqlite3_finalize(ps); + struct chat_core_ctx* cc = json_context(inst, buf, buf_size, out_len); if (!cc) return -1; + struct strbuf json = strbuf_new(); sqlite3_stmt *st = NULL, *peers = NULL; const char* stage = "prepare"; + int rc = sqlite3_prepare_v2(cc->db, "SELECT c.channel_id,c.name,c.owner_node_id,n.name FROM channels c" + " LEFT JOIN nodes n ON n.node_id=c.owner_node_id ORDER BY c.created_at,c.channel_id", -1, &st, NULL); + if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "[") < 0) goto error; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const char *channel, *name, *owner_name; stage = "channel text"; + rc = json_read_text(st, 0, &channel); + if (rc == SQLITE_OK) rc = json_read_text(st, 1, &name); + if (rc == SQLITE_OK) rc = json_read_text(st, 3, &owner_name); + if (rc != SQLITE_OK) goto error; + uint64_t owner = (uint64_t)sqlite3_column_int64(st, 2); + if (!chat_channel_id(channel)) { stage = "invalid stored channel ID"; rc = SQLITE_CORRUPT; goto error; } + sqlite3_int64 messages, count = 0, online = 0; + stage = "history count"; + if (json_message_count(cc->db, channel, &messages)) { rc = SQLITE_ERROR; goto error; } + char table[80], sql[160]; peers_table_name(channel, table, sizeof(table)); + snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\" WHERE deleted=0", table); + stage = "members"; rc = sqlite3_prepare_v2(cc->db, sql, -1, &peers, NULL); if (rc != SQLITE_OK) goto error; + while ((rc = sqlite3_step(peers)) == SQLITE_ROW) { + count++; online += chat_core_member_online(inst, channel, (uint64_t)sqlite3_column_int64(peers, 0)); } - - char esc_name[256], esc_owner[256]; - json_escape(ch_name ? ch_name : "", esc_name, sizeof(esc_name)); - json_escape(owner_name ? owner_name : "", esc_owner, sizeof(esc_owner)); - - w += snprintf(w, (size_t)(end - w), - "%s{\"id\":\"%s\",\"name\":\"%s\",\"owner_id\":\"0x%016llx\",\"owner_name\":\"%s\"," - "\"peers\":%d,\"msgs\":%u,\"online\":%d}", - sep, ch_id, esc_name, (unsigned long long)owner_id, esc_owner, peer_count, msg_count, online_count); - sep = ","; + if (rc != SQLITE_DONE) goto error; + stage = "members finalize"; rc = sqlite3_finalize(peers); peers = NULL; if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; + if (strbuf_addf(&json, "%s{\"id\":", json.len > 1 ? "," : "") < 0 || strbuf_add_json_string(&json, channel) < 0 || + strbuf_addf(&json, ",\"name\":") < 0 || strbuf_add_json_string(&json, name) < 0 || + strbuf_addf(&json, ",\"owner_id\":\"0x%016llx\",\"owner_name\":", (unsigned long long)owner) < 0 || + strbuf_add_json_string(&json, owner_name) < 0 || + strbuf_addf(&json, ",\"peers\":%lld,\"msgs\":%lld,\"online\":%lld}", (long long)count, (long long)messages, (long long)online) < 0) goto error; + if (json.len >= buf_size) { rc = SQLITE_TOOBIG; goto error; } + stage = "channels step"; } - sqlite3_finalize(st); - *w++ = ']'; - *w = '\0'; - *out_len = (size_t)(w - buf); - return 0; + if (rc != SQLITE_DONE) goto error; + stage = "channels finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "]") < 0) goto error; + int result = json_copy(&json, buf, buf_size, out_len); strbuf_free(&json); return result; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channels JSON: stage=%s rc=%d bytes=%zu capacity=%zu: %s", + stage, rc, json.len, buf_size, sqlite3_errmsg(cc->db)); + int finish = sqlite3_finalize(peers); + if (finish != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channels JSON: members cleanup rc=%d", finish); + finish = sqlite3_finalize(st); + if (finish != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channels JSON: cleanup rc=%d", finish); + strbuf_free(&json); return -1; } int chat_core_get_messages_json(struct UTUN_INSTANCE* inst, const char* ch_id, int count, int offset, char* buf, size_t buf_size, size_t* out_len) { - struct chat_core_ctx* cc = inst ? CC(inst) : NULL; - if (out_len) *out_len = 0; - if (!cc || !cc->initialized || !ch_id || !buf || !out_len || buf_size < 3 || offset < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid message listing arguments", CC_ID); return -1; + struct chat_core_ctx* cc = json_context(inst, buf, buf_size, out_len); if (!cc) return -1; + if (!chat_channel_id(ch_id) || offset < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "messages JSON: invalid channel/offset=%d", offset); return -1; } char table[80], sql[1024]; msg_table_name(ch_id, table, sizeof(table)); snprintf(sql, sizeof(sql), @@ -414,131 +500,123 @@ int chat_core_get_messages_json(struct UTUN_INSTANCE* inst, const char* ch_id, i "'data',coalesce(CAST(m.data AS TEXT),''),'local_attrs',coalesce(m.local_attrs,''),'ver',m.ver,'edited_at',m.edited_at)" " FROM \"%s\" m LEFT JOIN nodes n ON m.node_id=n.node_id" " ORDER BY m.timestamp DESC,m.message_id DESC LIMIT ? OFFSET ?", table); - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: messages query ch=%s: %s", CC_ID, ch_id, sqlite3_errmsg(cc->db)); return -1; - } - sqlite3_bind_int(st, 1, count > 0 ? count : 100); sqlite3_bind_int(st, 2, offset); - size_t off = 1; buf[0] = '['; int step, result = 0; - while ((step = sqlite3_step(st)) == SQLITE_ROW) { + sqlite3_stmt* st = NULL; const char* stage = "prepare"; + int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind count"; rc = sqlite3_bind_int(st, 1, count > 0 ? count : 100); } + if (rc == SQLITE_OK) { stage = "bind offset"; rc = sqlite3_bind_int(st, 2, offset); } + if (rc != SQLITE_OK) goto error; + size_t off = 1; buf[0] = '['; stage = "step"; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { const char* row = (const char*)sqlite3_column_text(st, 0); size_t bytes = sqlite3_column_bytes(st, 0); - if (!row || bytes + (off > 1) + 2 > buf_size - off) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: messages exceed output buffer ch=%s capacity=%zu", CC_ID, ch_id, buf_size); - result = -1; break; + size_t separator = off > 1, available = buf_size - off; + if (!row || available < separator + 2 || bytes > available - separator - 2) { + stage = "JSON capacity/read"; rc = row ? SQLITE_TOOBIG : SQLITE_NOMEM; goto error; } - if (off > 1) buf[off++] = ','; + if (separator) buf[off++] = ','; memcpy(buf + off, row, bytes); off += bytes; } - if (result == 0 && step != SQLITE_DONE) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: messages read ch=%s: %s", CC_ID, ch_id, sqlite3_errmsg(cc->db)); result = -1; - } - sqlite3_finalize(st); - if (result < 0) { buf[0] = 0; return -1; } + if (rc != SQLITE_DONE) goto error; + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; buf[off++] = ']'; buf[off] = 0; *out_len = off; return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "messages JSON: ch=%s stage=%s rc=%d capacity=%zu: %s", ch_id, stage, rc, buf_size, sqlite3_errmsg(cc->db)); + int finish = sqlite3_finalize(st); + if (finish != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "messages JSON: cleanup rc=%d", finish); + buf[0] = 0; return -1; } int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, char* buf, size_t buf_size, size_t* out_len) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !ch_id || !buf || !out_len || buf_size < 3) return -1; - *out_len = 0; - char peers_tbl[80]; peers_table_name(ch_id, peers_tbl, sizeof(peers_tbl)); - char* w = buf; char* end = buf + buf_size; - *w++ = '['; const char* sep = ""; - char sql[256]; - snprintf(sql, sizeof(sql), - "SELECT p.node_id, n.name, n.x25519_pubkey, n.ed25519_pubkey, p.adm_tags " - "FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id", peers_tbl); - sqlite3_stmt* st = NULL; - sqlite3_stmt* as = NULL; -#define MEMBERS_APPEND(...) do { \ - int written = snprintf(w, (size_t)(end - w), __VA_ARGS__); \ - if (written < 0 || (size_t)written >= (size_t)(end - w)) goto overflow; \ - w += written; \ -} while (0) - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) != SQLITE_OK) { *out_len = 0; return -1; } - while (sqlite3_step(st) == SQLITE_ROW) { - uint64_t nid = (uint64_t)sqlite3_column_int64(st, 0); - const char* name = (const char*)sqlite3_column_text(st, 1); - int online = chat_core_member_online(inst, ch_id, nid); - const uint8_t* x25519 = (const uint8_t*)sqlite3_column_blob(st, 2); - const uint8_t* ed25519 = sqlite3_column_blob(st, 3); - int x25519_len = sqlite3_column_bytes(st, 2); - int ed25519_len = sqlite3_column_bytes(st, 3); - const char* adm_tags = (const char*)sqlite3_column_text(st, 4); - char esc_name[256], x25519_hex[65], ed25519_hex[65], esc_tags[6 * MS_ADM_TAGS_MAX + 1]; - json_escape(name ? name : "", esc_name, sizeof(esc_name)); - x25519_hex[0] = ed25519_hex[0] = '\0'; - if (x25519 && x25519_len >= 32) for (int i = 0; i < 32; i++) snprintf(x25519_hex + i*2, 3, "%02x", x25519[i]); - if (ed25519 && ed25519_len >= 32) for (int i = 0; i < 32; i++) snprintf(ed25519_hex + i*2, 3, "%02x", ed25519[i]); - - /* check connected */ - int connected = 0; - if (inst && inst->connections) { - struct ll_entry* e = queue_find_data_by_index(inst->connections, (const uint8_t*)&nid); - if (e) connected = 1; + struct chat_core_ctx* cc = json_context(inst, buf, buf_size, out_len); if (!cc) return -1; + if (!chat_channel_id(ch_id)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "members JSON: invalid channel"); return -1; } + char table[80], sql[256]; peers_table_name(ch_id, table, sizeof(table)); + snprintf(sql, sizeof(sql), "SELECT p.node_id,n.name,n.x25519_pubkey,n.ed25519_pubkey,p.adm_tags,p.deleted" + " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id ORDER BY p.node_id", table); + sqlite3_stmt *st = NULL, *addresses = NULL; struct strbuf json = strbuf_new(); const char* stage = "prepare"; + int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "[") < 0) goto error; + stage = "members step"; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + uint64_t node = (uint64_t)sqlite3_column_int64(st, 0); + const char *name, *tags; stage = "member text"; + rc = json_read_text(st, 1, &name); if (rc == SQLITE_OK) rc = json_read_text(st, 4, &tags); + if (rc != SQLITE_OK) goto error; + char keys[2][65] = {{0}}; + for (int i = 0; i < 2; i++) if (sqlite3_column_type(st, i + 2) != SQLITE_NULL) { + stage = "key size/type"; rc = SQLITE_CORRUPT; + if (sqlite3_column_type(st, i + 2) != SQLITE_BLOB || sqlite3_column_bytes(st, i + 2) != 32) goto error; + const uint8_t* key = sqlite3_column_blob(st, i + 2); if (!key) { rc = SQLITE_NOMEM; goto error; } + for (int b = 0; b < 32; b++) snprintf(keys[i] + b * 2, 3, "%02x", key[b]); } - - json_escape(adm_tags ? adm_tags : "", esc_tags, sizeof(esc_tags)); - - MEMBERS_APPEND( - "%s{\"node_id\":\"0x%016llx\",\"name\":\"%s\",\"online\":%d,\"connected\":%d," - "\"adm_tags\":\"%s\",\"x25519\":\"%s\",\"ed25519\":\"%s\",\"addrs\":[", - sep, (unsigned long long)nid, esc_name, online, connected, esc_tags, x25519_hex, ed25519_hex); - sep = ","; - - /* addresses */ - char asql[200]; snprintf(asql, sizeof(asql), - "SELECT family, protocol, address, port, rtt FROM node_addresses WHERE node_id=? ORDER BY family, protocol"); - if (sqlite3_prepare_v2(cc->db, asql, -1, &as, NULL) == SQLITE_OK) { - sqlite3_bind_int64(as, 1, (sqlite3_int64)nid); - const char* asep2 = ""; - while (sqlite3_step(as) == SQLITE_ROW) { - int fam = sqlite3_column_int(as, 0); - int proto = sqlite3_column_int(as, 1); - const uint8_t* addr = (const uint8_t*)sqlite3_column_blob(as, 2); - int addr_len = sqlite3_column_bytes(as, 2); - int port = sqlite3_column_int(as, 3); - int rtt = sqlite3_column_int(as, 4); - char ip_str[64]; ip_str[0] = '\0'; - if (fam == 4 && addr && addr_len >= 4) - snprintf(ip_str, sizeof(ip_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]); - else if (fam == 6 && addr && addr_len >= 16) - snprintf(ip_str, sizeof(ip_str), "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", - addr[0],addr[1],addr[2],addr[3],addr[4],addr[5],addr[6],addr[7], - addr[8],addr[9],addr[10],addr[11],addr[12],addr[13],addr[14],addr[15]); - else { snprintf(ip_str, sizeof(ip_str), "?"); } - MEMBERS_APPEND( - "%s{\"ip\":\"%s\",\"port\":%d,\"proto\":\"%s\",\"rtt\":%d}", - asep2, ip_str, port, proto == 1 ? "UDP" : proto == 2 ? "TCP" : "?", rtt); - asep2 = ","; + int online = !sqlite3_column_int(st, 5) && chat_core_member_online(inst, ch_id, node); + int connected = inst->connections && queue_find_data_by_index(inst->connections, (const uint8_t*)&node); + stage = "JSON"; rc = SQLITE_NOMEM; + if (strbuf_addf(&json, "%s{\"node_id\":\"0x%016llx\",\"name\":", json.len > 1 ? "," : "", (unsigned long long)node) < 0 || + strbuf_add_json_string(&json, name) < 0 || strbuf_addf(&json, ",\"online\":%d,\"connected\":%d,\"adm_tags\":", online, connected) < 0 || + strbuf_add_json_string(&json, tags) < 0 || + strbuf_addf(&json, ",\"x25519\":\"%s\",\"ed25519\":\"%s\",\"addrs\":[", keys[0], keys[1]) < 0) goto error; + stage = "addresses prepare"; + rc = sqlite3_prepare_v2(cc->db, "SELECT family,protocol,address,port,rtt FROM node_addresses WHERE node_id=? ORDER BY family,protocol", + -1, &addresses, NULL); + if (rc == SQLITE_OK) { stage = "addresses bind"; rc = sqlite3_bind_int64(addresses, 1, (sqlite3_int64)node); } + if (rc != SQLITE_OK) goto error; + const char* separator = ""; stage = "addresses step"; + while ((rc = sqlite3_step(addresses)) == SQLITE_ROW) { + int family = sqlite3_column_int(addresses, 0), protocol = sqlite3_column_int(addresses, 1); + int bytes = sqlite3_column_bytes(addresses, 2), port = sqlite3_column_int(addresses, 3); + const void* address = sqlite3_column_blob(addresses, 2); char ip[64]; + if ((family != 4 && family != 6) || sqlite3_column_type(addresses, 2) != SQLITE_BLOB || + bytes != (family == 4 ? 4 : 16) || !address || + (protocol != 1 && protocol != 2 && protocol != 6) || port < 0 || port > UINT16_MAX || + !inet_ntop(family == 4 ? AF_INET : AF_INET6, address, ip, sizeof(ip))) { + stage = "invalid stored address"; rc = SQLITE_CORRUPT; goto error; } - sqlite3_finalize(as); as = NULL; + stage = "JSON"; rc = SQLITE_NOMEM; + if (strbuf_addf(&json, "%s{\"ip\":", separator) < 0 || strbuf_add_json_string(&json, ip) < 0 || + strbuf_addf(&json, ",\"port\":%d,\"proto\":\"%s\",\"rtt\":%lld}", port, + protocol == 1 ? "UDP" : protocol == 2 ? "TCP" : "TCP+REALITY", (long long)sqlite3_column_int64(addresses, 4)) < 0) goto error; + separator = ","; if (json.len >= buf_size) { rc = SQLITE_TOOBIG; goto error; } + stage = "addresses step"; } - MEMBERS_APPEND("]}"); + if (rc != SQLITE_DONE) goto error; + stage = "addresses finalize"; rc = sqlite3_finalize(addresses); addresses = NULL; if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "]}") < 0) goto error; + if (json.len >= buf_size) { rc = SQLITE_TOOBIG; goto error; } + stage = "members step"; } - MEMBERS_APPEND("]"); - sqlite3_finalize(st); - *out_len = (size_t)(w - buf); - return 0; -overflow: - sqlite3_finalize(as); sqlite3_finalize(st); buf[0] = 0; - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: members JSON exceeds output buffer bytes=%zu", CC_ID, buf_size); - return -1; -#undef MEMBERS_APPEND + if (rc != SQLITE_DONE) goto error; + stage = "members finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "]") < 0) goto error; + int result = json_copy(&json, buf, buf_size, out_len); strbuf_free(&json); return result; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "members JSON: ch=%s stage=%s rc=%d bytes=%zu capacity=%zu: %s", + ch_id, stage, rc, json.len, buf_size, sqlite3_errmsg(cc->db)); + int finish = sqlite3_finalize(addresses); + if (finish != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "members JSON: addresses cleanup rc=%d", finish); + finish = sqlite3_finalize(st); + if (finish != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "members JSON: cleanup rc=%d", finish); + strbuf_free(&json); return -1; } int chat_core_get_node_name(struct UTUN_INSTANCE* inst, uint64_t node_id, char* out, size_t sz) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !out) return -1; - out[0] = '\0'; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (out && sz) out[0] = 0; + if (!cc || !cc->initialized || !cc->db || !out || !sz) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "node name: invalid context/output node=%016llx", (unsigned long long)node_id); return -1; + } sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, "SELECT name FROM nodes WHERE node_id=?", -1, &st, NULL) != SQLITE_OK) return -1; - sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (sqlite3_step(st) == SQLITE_ROW) { + int rc = sqlite3_prepare_v2(cc->db, "SELECT name FROM nodes WHERE node_id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) { const char* n = (const char*)sqlite3_column_text(st, 0); if (n) snprintf(out, sz, "%s", n); + else if (sqlite3_errcode(cc->db) == SQLITE_NOMEM) rc = SQLITE_NOMEM; + } + int final = sqlite3_finalize(st); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { + out[0] = 0; DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "node name: lookup node=%016llx rc=%d finalize=%d: %s", + (unsigned long long)node_id, rc, final, sqlite3_errmsg(cc->db)); return -1; } - sqlite3_finalize(st); return 0; } @@ -553,6 +631,10 @@ struct chat_member_tags_key { struct chat_member_tags { char ch_id[64]; uint64_t node_id; + sqlite3* db; /* Заимствована до остановки сервиса. */ + char baseline[MS_ADM_TAGS_MAX + 1]; /* Проверенный owner-блок на момент загрузки/commit. */ + uint8_t signature[64], x25519[32], ed25519[32]; + int version, parsing_edit; int key_count; struct chat_member_tags_key keys[CHAT_MEMBER_TAGS_MAX_KEYS]; }; @@ -578,9 +660,12 @@ static int tags_is_append_key(const char* key) { static int tags_json_cb(const char* key, const char* json_val, void* arg) { struct chat_member_tags* t = (struct chat_member_tags*)arg; - if (t->key_count >= CHAT_MEMBER_TAGS_MAX_KEYS || strlen(key) >= CHAT_MEMBER_TAGS_KEY_SZ) { + if (!key[0] || t->key_count >= CHAT_MEMBER_TAGS_MAX_KEYS || strlen(key) >= CHAT_MEMBER_TAGS_KEY_SZ) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags JSON key limit exceeded", CC_ID); return -1; } + if (t->parsing_edit && tags_is_append_key(key) && strcmp(json_val, "yes") && strcmp(json_val, "no")) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: edited role must be yes/no key=%s", key); return -1; + } struct chat_member_tags_key* k = &t->keys[t->key_count]; snprintf(k->key, CHAT_MEMBER_TAGS_KEY_SZ, "%s", key); k->val = u_strdup(tags_is_append_key(key) ? tags_append_effective(json_val, NULL) : json_val); @@ -594,33 +679,65 @@ static int tags_json_cb(const char* key, const char* json_val, void* arg) { } struct chat_member_tags* chat_member_tags_load(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !ch_id || !cc->db) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_load — not initialized", CC_ID); return NULL; } + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !chat_channel_id(ch_id) || !node_id || !cc->db) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: invalid load context/channel/member"); return NULL; + } struct chat_member_tags* t = u_calloc(1, sizeof(*t)); if (!t) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_load — OOM", CC_ID); return NULL; } snprintf(t->ch_id, sizeof(t->ch_id), "%s", ch_id); - t->node_id = node_id; + t->node_id = node_id; t->db = cc->db; char peers_tbl[80]; peers_table_name(ch_id, peers_tbl, sizeof(peers_tbl)); sqlite3_stmt* st = NULL; - char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags FROM \"%s\" WHERE node_id=?", peers_tbl); - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (sqlite3_step(st) == SQLITE_ROW) { - const char* tags = (const char*)sqlite3_column_text(st, 0); - if (tags && tags[0] && chat_member_tags_replace(t, tags) < 0) { - sqlite3_finalize(st); chat_member_tags_free(t); return NULL; - } - } - sqlite3_finalize(st); + char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags,adm_tags_sig,x25519_pubkey,ed25519_pubkey,source FROM \"%s\" WHERE node_id=?", peers_tbl); + const char* stage = "prepare"; int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); } + if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); } + if (rc != SQLITE_ROW) goto error; + stage = "fields"; rc = SQLITE_CORRUPT; + int type = sqlite3_column_type(st, 0), bytes = sqlite3_column_bytes(st, 0), sig_type = sqlite3_column_type(st, 1); + const char* text = (const char*)sqlite3_column_text(st, 0); + const void *signature = sqlite3_column_blob(st, 1), *x = sqlite3_column_blob(st, 2), *ed = sqlite3_column_blob(st, 3); + if ((type != SQLITE_NULL && type != SQLITE_TEXT) || (type == SQLITE_TEXT && (!text || strlen(text) != (size_t)bytes)) || + bytes > MS_ADM_TAGS_MAX || sqlite3_column_type(st, 2) != SQLITE_BLOB || sqlite3_column_bytes(st, 2) != 32 || !x || + sqlite3_column_type(st, 3) != SQLITE_BLOB || sqlite3_column_bytes(st, 3) != 32 || !ed || + sqlite3_column_type(st, 4) != SQLITE_INTEGER || sqlite3_column_int(st, 4)) goto error; + if (bytes) memcpy(t->baseline, text, (size_t)bytes + 1); + if (sig_type != SQLITE_NULL) { + if (sig_type != SQLITE_BLOB || sqlite3_column_bytes(st, 1) != 64 || !signature) goto error; + memcpy(t->signature, signature, 64); } - DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_load ch=%s nid=0x%016llx keys=%d", - CC_ID, ch_id, (unsigned long long)node_id, t->key_count); + memcpy(t->x25519, x, 32); memcpy(t->ed25519, ed, 32); + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + t->version = member_sync_owner_version(cc->db, t->baseline); + if (t->version < 0) goto invalid; + if (t->version) { + uint8_t key[32], body[MS_ADM_TAGS_MAX + 8]; + int length = member_sync_build_owner_msg(node_id, t->baseline, body, sizeof(body)); + if (length < 0 || topo_node_sqlite_channel_get(cc->db, ch_id, NULL, 0, NULL, NULL, key, NULL) || + sc_ed25519_verify(key, body, (size_t)length, t->signature) != SC_OK) goto invalid; + if (chat_json_strings(cc->db, t->baseline, MS_ADM_TAGS_MAX, tags_json_cb, t)) goto invalid; + } else if (memcmp(t->signature, (uint8_t[64]){0}, 64)) goto invalid; + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "tags: loaded ch=%s node=%016llx version=%d keys=%d", + ch_id, (unsigned long long)node_id, t->version, t->key_count); return t; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: invalid signed owner block ch=%s node=%016llx", ch_id, (unsigned long long)node_id); + chat_member_tags_free(t); return NULL; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: load ch=%s node=%016llx stage=%s rc=%d: %s", + ch_id, (unsigned long long)node_id, stage, rc, sqlite3_errmsg(cc->db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: load cleanup finalize=%d", rc); + chat_member_tags_free(t); return NULL; } int chat_member_tags_set(struct chat_member_tags* t, const char* key, const char* val) { - if (!t || !key) return -1; + if (!t || !key || !key[0] || !strcmp(key, "ver") || + (val && tags_is_append_key(key) && strcmp(val, "yes") && strcmp(val, "no"))) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: invalid edit key/role or reserved version"); return -1; + } if (strlen(key) >= CHAT_MEMBER_TAGS_KEY_SZ) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_set — key too long", CC_ID); return -1; } if (val && strlen(val) > MS_ADM_TAGS_MAX) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_set — val too long", CC_ID); return -1; } @@ -633,7 +750,7 @@ int chat_member_tags_set(struct chat_member_tags* t, const char* key, const char if (!copy) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_set allocation failed", CC_ID); return -1; } if (idx >= 0) { u_free(t->keys[idx].val); t->keys[idx].val = copy; - } else if (t->key_count < CHAT_MEMBER_TAGS_MAX_KEYS) { + } else if (t->key_count < CHAT_MEMBER_TAGS_MAX_KEYS - (tags_find(t, "ver") < 0)) { struct chat_member_tags_key* k = &t->keys[t->key_count++]; snprintf(k->key, CHAT_MEMBER_TAGS_KEY_SZ, "%s", key); k->val = copy; @@ -654,11 +771,15 @@ int chat_member_tags_replace(struct chat_member_tags* t, const char* json) { if (!t || !json || strlen(json) > MS_ADM_TAGS_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_replace invalid input or size", CC_ID); return -1; } - struct chat_member_tags parsed = {0}; - int rc = json_flat_parse(json, tags_json_cb, &parsed); + struct chat_member_tags parsed = {.db = t->db, .parsing_edit = 1}; + int rc = chat_json_strings(t->db, json, MS_ADM_TAGS_MAX, tags_json_cb, &parsed); + char version[16]; snprintf(version, sizeof(version), "%d", t->version); + const char* requested = chat_member_tags_get(&parsed, "ver"); + if (requested && strcmp(requested, version)) rc = -1; + if (!requested && parsed.key_count == CHAT_MEMBER_TAGS_MAX_KEYS) rc = -1; if (rc != 0) { for (int i = 0; i < parsed.key_count; i++) { u_free(parsed.keys[i].raw); u_free(parsed.keys[i].val); } - DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_replace — invalid JSON", CC_ID); return -1; + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "tags: replace rejected JSON/version/key limit"); return -1; } for (int i = 0; i < t->key_count; i++) { u_free(t->keys[i].raw); u_free(t->keys[i].val); } t->key_count = parsed.key_count; memcpy(t->keys, parsed.keys, sizeof(t->keys)); @@ -667,112 +788,117 @@ int chat_member_tags_replace(struct chat_member_tags* t, const char* json) { return 0; } -int chat_member_tags_commit(struct UTUN_INSTANCE* inst, struct chat_member_tags* t) { - struct chat_core_ctx* cc = CC(inst); - if (!t || !cc || !cc->initialized || !cc->inst || !cc->db) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — invalid state", CC_ID); return -1; - } - char json[MS_ADM_TAGS_MAX + 1]; int off = 0, ver = 1; -#define TAGS_ADVANCE(expr) do { \ - int written = (expr); \ - if (written < 0 || (size_t)written >= sizeof(json) - (size_t)off) { \ - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit JSON exceeds %d bytes", CC_ID, MS_ADM_TAGS_MAX); return -1; \ - } \ - off += written; \ -} while (0) - int vi = tags_find(t, "ver"); - if (vi >= 0) ver = atoi(t->keys[vi].val) + 1; - TAGS_ADVANCE(snprintf(json, sizeof(json), "{\"ver\":\"%d\"", ver)); - - for (int i = 0; i < t->key_count; i++) { - struct chat_member_tags_key* k = &t->keys[i]; - if (strcmp(k->key, "ver") == 0) continue; - - const char* out_val; - if (k->raw) { - uint64_t old_ts = 0; - const char* old_eff = tags_append_effective(k->raw, &old_ts); - int old_yes = (*old_eff == 'y'); - int new_yes = (k->val[0] == 'y'); /* val = "yes" or "yes:" */ - if (new_yes != old_yes) { - uint64_t ts = (uint64_t)ntp_time_get_seconds(cc->inst); - size_t oldl = strlen(k->raw); - char* nraw = u_malloc(oldl + 24); - if (!nraw) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — OOM for raw", CC_ID); return -1; } - snprintf(nraw, oldl + 24, "%s%c%llu", k->raw, new_yes ? 'e' : 'd', (unsigned long long)ts); - u_free(k->raw); k->raw = nraw; - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit append %s=%c%llu ch=%s nid=0x%016llx old_last=%c", - CC_ID, k->key, new_yes ? 'e' : 'd', (unsigned long long)ts, t->ch_id, (unsigned long long)t->node_id, old_yes ? 'e' : 'd'); - } - out_val = k->raw; - } else { - out_val = k->val; +/* Сравнение желаемых значений;отсутствующий admin/moder означает отключённую роль. */ +static int tags_same_edit(struct chat_member_tags* edit, struct chat_member_tags* current) { + for (int pass = 0; pass < 2; pass++) { + struct chat_member_tags *a = pass ? current : edit, *b = pass ? edit : current; + for (int i = 0; i < a->key_count; i++) { + const char *key = a->keys[i].key, *value = a->keys[i].val, *other = chat_member_tags_get(b, key); + if (!strcmp(key, "ver")) continue; + if (!other && tags_is_append_key(key)) other = "no"; + if (!other || strcmp(value, other)) return 0; } - char escaped_key[6 * CHAT_MEMBER_TAGS_KEY_SZ + 1], escaped_value[6 * MS_ADM_TAGS_MAX + 1]; - if (strlen(out_val) > MS_ADM_TAGS_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit value exceeds limit", CC_ID); return -1; - } - json_escape(k->key, escaped_key, sizeof(escaped_key)); - json_escape(out_val, escaped_value, sizeof(escaped_value)); - TAGS_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, ",\"%s\":\"%s\"", escaped_key, escaped_value)); } - TAGS_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, "}")); -#undef TAGS_ADVANCE + return 1; +} - uint8_t ch_ed_priv[32]; - if (topo_node_sqlite_channel_get_priv(cc->db, t->ch_id, ch_ed_priv) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — no channel privkey for ch=%s (not owner?)", CC_ID, t->ch_id); return -1; +static int tags_json_pair(struct strbuf* json, const char* key, const char* value) { + if (strbuf_addf(json, ",") < 0 || strbuf_add_json_string(json, key) < 0 || strbuf_addf(json, ":") < 0 || + strbuf_add_json_string(json, value) < 0 || json->len > MS_ADM_TAGS_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: JSON allocation/size limit key=%s", key); return -1; } - uint8_t sign_msg[MS_ADM_TAGS_MAX + 8]; - int mlen = member_sync_build_owner_msg(t->node_id, json, sign_msg, sizeof(sign_msg)); - if (mlen < 0) return -1; - uint8_t sig[64]; - if (sc_ed25519_sign(ch_ed_priv, sign_msg, mlen, sig) != SC_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — sign failed", CC_ID); return -1; - } - - char peers_tbl[80]; peers_table_name(t->ch_id, peers_tbl, sizeof(peers_tbl)); - sqlite3_stmt* st = NULL; - char sql[384]; snprintf(sql, sizeof(sql), - "SELECT x25519_pubkey, ed25519_pubkey, join_sig, join_ts, update_sig, update_ts, userinfo, storage, source" - " FROM \"%s\" WHERE node_id=?", peers_tbl); - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — member query failed", CC_ID); return -1; - } - sqlite3_bind_int64(st, 1, (sqlite3_int64)t->node_id); - if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — member not found ch=%s nid=0x%016llx", CC_ID, t->ch_id, (unsigned long long)t->node_id); return -1; - } - const uint8_t* x25 = (const uint8_t*)sqlite3_column_blob(st, 0); - const uint8_t* ed = (const uint8_t*)sqlite3_column_blob(st, 1); - const uint8_t* jsig = (const uint8_t*)sqlite3_column_blob(st, 2); - uint64_t jts = (uint64_t)sqlite3_column_int64(st, 3); - const uint8_t* usig = (const uint8_t*)sqlite3_column_blob(st, 4); - uint64_t uts = (uint64_t)sqlite3_column_int64(st, 5); - const char* uinfo = (const char*)sqlite3_column_text(st, 6); - int stg = sqlite3_column_int(st, 7); - int src = sqlite3_column_int(st, 8); - if (!x25 || !ed) { sqlite3_finalize(st); - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — member has no keys ch=%s", CC_ID, t->ch_id); return -1; - } - if (src != 0) { sqlite3_finalize(st); - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — cannot set flags on placeholder member (source=%d) ch=%s nid=0x%016llx", - CC_ID, src, t->ch_id, (unsigned long long)t->node_id); - return -1; - } - const char* stg_val = chat_member_tags_get(t, "storage"); - if (stg_val && strcmp(stg_val, "yes") == 0) stg = 1; - - int rc = member_sync_put(inst, t->ch_id, t->node_id, x25, ed, jsig, jts, usig, uts, - uinfo ? uinfo : "", json, sig, stg, 0, NULL); - sqlite3_finalize(st); - if (rc < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit — member_sync_put failed rc=%d", CC_ID, rc); return -1; } - - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: tags_commit ch=%s nid=0x%016llx ver=%d keys=%d bytes=%d", - CC_ID, t->ch_id, (unsigned long long)t->node_id, ver, t->key_count, off); return 0; } +/* История ролей берётся из проверенного текущего блока,новая версия строится отдельно от editor. */ +static int tags_build_json(struct UTUN_INSTANCE* inst, struct chat_member_tags* edit, + struct chat_member_tags* current, struct strbuf* json) { + if (current->version == INT_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: version exhausted"); return -1; } + if (strbuf_addf(json, "{\"ver\":\"%d\"", current->version + 1) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: JSON allocation failed"); return -1; + } + for (int i = 0; i < edit->key_count; i++) { + const char* key = edit->keys[i].key; + if (strcmp(key, "ver") && !tags_is_append_key(key) && tags_json_pair(json, key, edit->keys[i].val)) return -1; + } + const char* roles[] = {"admin", "moder"}; + for (size_t i = 0; i < 2; i++) { + const char* desired = chat_member_tags_get(edit, roles[i]); + int index = tags_find(current, roles[i]); + const char* history = index < 0 ? "" : current->keys[index].raw; + int enabled = desired && !strcmp(desired, "yes"), previous = !strcmp(tags_append_effective(history, NULL), "yes"); + char appended[MS_ADM_TAGS_MAX + 1]; + if (enabled != previous) { + time_t seconds = ntp_time_get_seconds(inst); + int bytes = snprintf(appended, sizeof(appended), "%s%c%llu", history, enabled ? 'e' : 'd', (unsigned long long)seconds); + if (seconds <= 0 || bytes < 0 || (size_t)bytes >= sizeof(appended)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: role history/time limit key=%s", roles[i]); return -1; + } + history = appended; + } + if (history[0] && tags_json_pair(json, roles[i], history)) return -1; + } + if (strbuf_addf(json, "}") < 0 || json->len > MS_ADM_TAGS_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: complete JSON exceeds memory/size limit"); return -1; + } + return 0; +} + +int chat_member_tags_commit(struct UTUN_INSTANCE* inst, struct chat_member_tags* t) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!t || !cc || !cc->initialized || !cc->db || t->db != cc->db || !sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: commit requires active owner DB without outer transaction"); return -1; + } + int rc = sqlite3_exec(cc->db, "BEGIN IMMEDIATE /* tags */", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: begin rc=%d: %s", rc, sqlite3_errmsg(cc->db)); return -1; } + struct strbuf json = strbuf_new(); int result = -1; + struct chat_member_tags* current = chat_member_tags_load(inst, t->ch_id, t->node_id); + if (!current) goto done; + if (strcmp(current->baseline, t->baseline) || memcmp(current->signature, t->signature, 64)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "tags: stale editor ch=%s node=%016llx expected=%d current=%d", + t->ch_id, (unsigned long long)t->node_id, t->version, current->version); result = -2; goto done; + } + if (tags_same_edit(t, current)) { + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "tags: unchanged ch=%s node=%016llx ver=%d", t->ch_id, + (unsigned long long)t->node_id, t->version); result = 0; goto done; + } + if (tags_build_json(inst, t, current, &json)) goto done; + uint8_t private_key[32] = {0}, body[MS_ADM_TAGS_MAX + 8]; + int bytes = member_sync_build_owner_msg(t->node_id, json.buf, body, sizeof(body)); + int signed_ok = bytes >= 0 && !topo_node_sqlite_channel_get_priv(cc->db, t->ch_id, private_key) && + sc_ed25519_sign(private_key, body, (size_t)bytes, current->signature) == SC_OK; + OPENSSL_cleanse(private_key, sizeof(private_key)); + if (!signed_ok) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: channel signing failed ch=%s", t->ch_id); goto done; } + for (int i = 0; i < current->key_count; i++) { u_free(current->keys[i].raw); u_free(current->keys[i].val); } + memset(current->keys, 0, sizeof(current->keys)); current->key_count = 0; + if (chat_json_strings(cc->db, json.buf, MS_ADM_TAGS_MAX, tags_json_cb, current)) goto done; + memcpy(current->baseline, json.buf, json.len + 1); current->version++; + int changes = member_sync_put(inst, t->ch_id, t->node_id, current->x25519, current->ed25519, + NULL, 0, NULL, 0, NULL, json.buf, current->signature, 0, 0, NULL); + if (changes < 0 || !(changes & MS_APPLY_CHANGED)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: owner version not written ch=%s flags=%d", t->ch_id, changes); goto done; + } + rc = sqlite3_exec(cc->db, "COMMIT /* tags */", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: commit rc=%d: %s", rc, sqlite3_errmsg(cc->db)); goto done; } + char channel[64]; memcpy(channel, t->ch_id, sizeof(channel)); uint64_t node = t->node_id; + for (int i = 0; i < t->key_count; i++) { u_free(t->keys[i].raw); u_free(t->keys[i].val); } + *t = *current; u_free(current); strbuf_free(&json); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "tags: committed ch=%s node=%016llx ver=%d keys=%d", channel, + (unsigned long long)node, t->version, t->key_count); + member_sync_record_committed(inst, channel, node, (unsigned)changes); return 0; +done: + chat_member_tags_free(current); strbuf_free(&json); + if (!sqlite3_get_autocommit(cc->db)) { + rc = sqlite3_exec(cc->db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: rollback rc=%d: %s", rc, sqlite3_errmsg(cc->db)); result = -1; } + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: transaction remains open;requesting owner stop/DB close"); + utun_instance_stop(inst); result = -1; + } + } + return result; +} + void chat_member_tags_free(struct chat_member_tags* t) { if (!t) return; for (int i = 0; i < t->key_count; i++) { u_free(t->keys[i].raw); u_free(t->keys[i].val); } @@ -783,11 +909,26 @@ void chat_member_tags_free(struct chat_member_tags* t) { void chat_member_tags_commit_trampoline(void* arg) { struct chat_member_tags_req* req = (struct chat_member_tags_req*)arg; - if (!req || !CC(req->inst) || !CC(req->inst)->initialized) { u_free(arg); return; } + if (!req || !req->inst || !memchr(req->ch_id, 0, sizeof(req->ch_id)) || !memchr(req->json, 0, sizeof(req->json))) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: invalid trampoline request"); u_free(arg); return; + } struct chat_member_tags* t = chat_member_tags_load(req->inst, req->ch_id, req->node_id); if (!t) { u_free(arg); return; } - if (req->json[0]) chat_member_tags_replace(t, req->json); - chat_member_tags_commit(req->inst, t); + int rc = 0; + if (req->json[0]) { + struct chat_member_tags* patch = u_calloc(1, sizeof(*patch)); + if (!patch) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "tags: patch allocation failed"); rc = -1; } + else { + patch->db = t->db; patch->version = t->version; + rc = chat_member_tags_replace(patch, req->json); + for (int i = 0; !rc && i < patch->key_count; i++) { + struct chat_member_tags_key* key = &patch->keys[i]; + if (strcmp(key->key, "ver")) rc = chat_member_tags_set(t, key->key, key->val); + } + chat_member_tags_free(patch); + } + } + if (!rc) chat_member_tags_commit(req->inst, t); chat_member_tags_free(t); u_free(arg); } diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 797295b3f..cd0ea977a 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -37,16 +37,17 @@ int chat_core_init(struct UTUN_INSTANCE* inst, const char* db_path); // Освободить chat_core после остановки остальных частей чата; общая БД остаётся у ядра. void chat_core_destroy(struct UTUN_INSTANCE* inst); -/* Backfill: регистрирует уже скачанные медиафайлы (лежат на диске, но отсутствуют - * в media_files) — вызывается после загрузки каналов/сообщений при старте. */ +/* Startup: восстановить производный attachment-индекс из подписанной истории и зарегистрировать сохранённые медиа. + * Вызывать после инициализации attachment и startup_backfill, чтобы SQL-отказ мог поставить повтор. Ключи из истории не восстанавливаются. */ void chat_media_backfill(struct UTUN_INSTANCE* inst); -/* Докачка недостающего медиа за последние storage_backfill_days дней. Проходит по - * медиасообщениям каналов и запускает загрузку для тех, чей файл отсутствует на диске. */ +/* Докачка недостающего медиа за последние storage_backfill_days дней и применение квоты. + * Также восстанавливает attachment-индекс/доступность сохранённых контейнеров; SQL-отказ ставит повтор канала. */ void chat_media_autodownload_backfill(struct UTUN_INSTANCE* inst); -/* Регистрирует триггер: после первой подтверждённой Merkle-сверки истории (+ дебаунс 2с) анонсирует локальные - * блоки суперузлам и запускает chat_media_autodownload_backfill. Один раз за запуск чата. */ +/* Подписать завершения Merkle: первый обмен запускает общий scan после 2с тишины; отдельные каналы — batch до16 через2с. + * Повтор индекса не отменяет MSG_RECEIVED committed-истории; следующий batch может быть поставлен во время текущего. + * При переполнении очереди и для восстановления допускаются последующие общие scan. Destroy снимает подписки/таймеры. */ void chat_media_startup_backfill_init(struct UTUN_INSTANCE* inst); void chat_media_startup_backfill_destroy(struct UTUN_INSTANCE* inst); @@ -92,7 +93,9 @@ int chat_core_edit_message(struct UTUN_INSTANCE* inst, const char* channel, cons * media_copy копирует src в dest, media_video запускает транскодирование. Ошибка также приходит событием. */ void chat_core_submit_media_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req); -// Ручная загрузка вложения; для зашифрованного файла также запрашивает ключ автора. +/* Ручная загрузка по локальному номеру строки; внешняя SQL-транзакция запрещена. + * Manifest ограничен общим DB_SYNC_MESSAGE_MAX. Зашифрованный индекс восстанавливается из истории до wanted/загрузки; + * отсутствие plaintext сбрасывает ready с сохранением ключа. Отказ SQL логируется, состояние модуля атомарно сохраняется. */ void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* channel_id, int64_t msg_id); // ── DB-операции (оставлены для интроспекции) ── @@ -114,15 +117,15 @@ int chat_core_load_nodeinfo(struct UTUN_INSTANCE* inst, uint64_t node_id, u // ── Создание канала (GUI → uasync) ── struct chat_channel_create { - struct UTUN_INSTANCE* inst; - char channel_id[64]; - char name[128]; - uint64_t owner_node_id; - uint8_t x25519_pubkey[32]; - uint8_t x25519_privkey[32]; - uint8_t ed25519_pubkey[32]; - uint8_t ed25519_privkey[32]; - uint8_t signature[64]; + struct UTUN_INSTANCE* inst; /* Заимствован trampoline; прямой вызов использует свой аргумент inst. */ + char channel_id[64]; /* NUL-строка: каноническое десятичное число 1..INT64_MAX. */ + char name[128]; /* NUL-строка до127 байт, без молчаливого обрезания. */ + uint64_t owner_node_id; /* node_id создающего instance. */ + uint8_t x25519_pubkey[32]; /* Канальный ключ шифрования; должен соответствовать privkey. */ + uint8_t x25519_privkey[32];/* Независимый приватный X25519-ключ канала. */ + uint8_t ed25519_pubkey[32];/* Канальный ключ подписи; должен соответствовать raw seed. */ + uint8_t ed25519_privkey[32];/* Raw Ed25519 seed, без преобразования sc_derive_ed25519_pubkey. */ + uint8_t signature[64]; /* Подпись id\0 || name\0 || owner(native u64) || X25519 || Ed25519. */ }; struct chat_create_auto_arg { struct UTUN_INSTANCE* inst; char name[128]; }; @@ -131,14 +134,22 @@ struct chat_create_auto_arg { struct UTUN_INSTANCE* inst; char name[128]; }; * проверенные данные БД сохраняются. Членства на других узлах не даёт. */ int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id); -// Создать канал по готовым ключам и метаданным req; req остаётся у вызывающего. -void chat_core_create_channel(struct UTUN_INSTANCE* inst, struct chat_channel_create* req); +/* Создать новый канал:проверить пары ключей/подпись,одним commit сохранить описание,root JOIN/профиль/приглашение и Merkle. + * 0 — сохранено,-1 — отказ без публикации;req заимствован и не меняется. Владелец — этот instance. + * Внешняя транзакция и занятый ID запрещены. CHANNEL_UPDATED — после commit и попытки runtime-подготовки. + * Отказ подготовки не отменяет созданный канал:можно повторить ensure_channel_ready. Неудачный rollback + * с открытой транзакцией запрашивает stop/закрытие БД владельцем вне callback. */ +int chat_core_create_channel(struct UTUN_INSTANCE* inst, const struct chat_channel_create* req); +// Забирает u_malloc-структуру chat_channel_create, очищает ключи и освобождает её после вызова. void chat_core_create_channel_trampoline(void* arg); -// Сгенерировать ключи и создать канал, добавив себя владельцем. -void chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name); +/* Сгенерировать независимые пары X25519/Ed25519 и создать канал. ID>0 — сохранён,0 — отказ. + * name — NUL-строка<=127байт,не обрезается. Проверяет RNG/генерацию/извлечение/подписи; + * временные приватные ключи очищаются. Внешняя транзакция отклоняется до генерации. */ +uint64_t chat_core_create_channel_auto(struct UTUN_INSTANCE* inst, const char* name); +// Забирает и освобождает u_malloc-структуру chat_create_auto_arg; instance должен дожить до выполнения. void chat_core_create_channel_auto_trampoline(void* arg); // ── Локальное удаление канала (GUI → uasync) ── @@ -152,8 +163,11 @@ struct chat_delete_channel_req { * (соединения/синк), db_sync инстанса, таблиц msg_ и peers_, медиафайлов и * merkle-хешей. Работает в uasync-потоке. Другие узлы канал не теряют. * Исходящие video/register задачи сначала завершаются; удаление продолжит последний completion. - * Новые исходящие медиа этого канала в это время отклоняются. Событие CHANNEL_DELETED приходит после удаления метаданных; - * ошибка файловой очистки логируется отдельно, записи неочищенных файлов сохраняются для повтора. */ + * Новые исходящие медиа этого канала в это время отклоняются. Внешняя SQL-транзакция запрещена до отмены ресурсов. + * Ключи/запросы/outbox удаляются триггером атомарно с метаданными; при SQL-ошибке данные и файлы сохраняются, + * сетевые ресурсы уже могут быть отменены — требуется повтор. Открытая после ошибки транзакция требует stop владельцем. + * CHANNEL_DELETED приходит после commit метаданных и попытки файловой очистки. Её I/O-ошибка логируется отдельно; + * остатки media_files сохраняются, собственный каталог plaintext можно очистить повторно после commit. */ void chat_core_delete_channel(struct UTUN_INSTANCE* inst, const char* ch_id); void chat_core_delete_channel_trampoline(void* arg); @@ -176,24 +190,31 @@ void chat_core_sync_my_addresses(struct UTUN_INSTANCE* inst); * Адреса обновляются отдельно через topo_node; подпись member-блока их не содержит. */ void chat_core_update_my_member(struct UTUN_INSTANCE* inst); -// Сохранение key-value в ui_state (вызывается из uasync-потока) -void chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value); +/* Сохранить key/value в uasync:0 — committed/no-op,-1 — отказ;key непустой,value допускает пустую строку. + * Собственная транзакция проверяет все SQL-этапы;внешняя запрещена,no-op не вызывает commit hook. + * Ошибка сохраняет прежнее значение;unrecovered rollback запрашивает owner stop/DB close. */ +int chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value); /* Per-channel «play sound on new message» (ui_state: ch__sound_on_message, default 1=on). - * set — сохраняет и рассылает пирам через chat_sync (троттлинг db_sync на сервере). */ + * get возвращает0/1,-1 при ошибке;отсутствие записи —1. set возвращает0/-1, + * после собственного commit/no-op рассылает желаемое значение через chat_sync best effort. + * SQL-отказ не рассылается,идентичный повтор повторяет доставку. ch_id — канонический ID канала. */ int chat_core_get_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id); -void chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on); +int chat_core_set_sound_on_message(struct UTUN_INSTANCE* inst, const char* ch_id, int on); struct chat_sound_arg { struct UTUN_INSTANCE* inst; char ch_id[64]; int on; }; void chat_core_set_sound_on_message_trampoline(void* arg); +/* Полный request принадлежит trampoline/u_free;data содержит key+NUL,value+NUL в пределах массива. */ struct save_ui_state_arg { struct UTUN_INSTANCE* inst; char data[1024]; }; void chat_core_save_ui_state_trampoline(void* arg); -// Установка chat-настройки (name=value) через chat_setting API (GUI → uasync) -void chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value); +/* Установить через chat_setting:0 — успех,-1 — неизвестное имя,-2 — валидация,-3 — LAN apply pending. + * Только успешный group_autoconnect запускает пересмотр групп;остальные эффекты — в chat_setting.h. */ +int chat_core_set_setting(struct UTUN_INSTANCE* inst, const char* name, const char* value); +/* Trampoline владеет полным request/u_free;data — name+NUL,value+NUL,обе строки проверяются в границах. */ struct chat_setting_arg { struct UTUN_INSTANCE* inst; char data[256]; }; void chat_core_set_setting_trampoline(void* arg); @@ -246,20 +267,21 @@ struct voice_played_req { /* Сбор статуса (NTP + connections) в uasync. arg — заимствованный inst. * Ответ CHAT_EVT_STATUS_REFRESH: UTF-8 без завершающего NUL в длине; размер растёт по данным. - * Текст заимствован только на время синхронного event callback; при ошибке приходит короткое описание. */ + * Текст заимствован только на время синхронного event callback; отказ памяти/форматирования логируется без события. */ void chat_core_collect_status_trampoline(void* arg); // Лёгкий запрос нагрузки uasync для GUI. arg = inst, ответ CHAT_EVT_CPU_LOAD. void chat_core_collect_cpu_load_trampoline(void* arg); -// Сбор списка ETCP-соединений и отправка в GUI (binary packet). arg = inst +/* Список до250 ETCP-соединений:arg — заимствованный inst,ответ CONN_LIST [count:u16 native][entry:46]*. + * Entry:peer:u64,flags:u8,links:u8,rtt:u16,inflight_KiB:u16,name:32;числа native,links/inflight насыщаются. */ void chat_core_collect_conn_list_trampoline(void* arg); struct chat_node_arg { struct UTUN_INSTANCE* inst; uint64_t node_id; }; /* Полные метрики одного соединения, вызывается в uasync. arg — u_malloc struct chat_node_arg, * trampoline освобождает arg; inst остаётся заимствованным. CHAT_EVT_CONN_METRICS содержит UTF-8 - * без завершающего NUL в длине; буфер растёт по числу линков, при ошибке — короткое описание. + * без завершающего NUL в длине; буфер растёт по числу линков, при OOM нет частичного события. * Данные ответа заимствованы только на время синхронного event callback. */ void chat_core_collect_conn_metrics_trampoline(void* arg); @@ -267,32 +289,46 @@ void chat_core_collect_conn_metrics_trampoline(void* arg); // ── Headless CLI API ── -// Список каналов с метаданными в JSON: [{id,name,owner_id,owner_name,peers,msgs,online}] +/* JSON-списки:0 — полный JSON с NUL,-1 — ошибка;out_len без NUL,при отказе0,допустимый buf очищается. + * buf/out_len обязательны,buf_size>=3. SQL/память/переполнение не дают успешный префикс; + * Строки экранируются целиком;channels/members отклоняют скрытый NUL в канальных/профильных SQLite TEXT. + * Каналы:[{id,name,owner_id,owner_name,peers,msgs,online}];отложенная history-таблица означает msgs=0. */ int chat_core_get_channels_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len); /* Сообщения канала в JSON: [{id,ts,author_id,author_name,content_type,data,local_attrs}]. - * От новых к старым; count<=0 — до 100 записей, offset — сколько пропустить. */ + * От новых к старым;count<=0 — до100 записей,offset>=0 — сколько пропустить. + * Канал — канонический ID1..INT64_MAX;общий контракт буфера как у channels_json. */ int chat_core_get_messages_json(struct UTUN_INSTANCE* inst, const char* ch_id, int count, int offset, char* buf, size_t buf_size, size_t* out_len); /* Мемберы в JSON: [{node_id,name,online,connected,adm_tags,x25519,ed25519,addrs}]. - * connected означает наличие транспорта в реестре, а не его UP или готовность группы. */ + * connected означает наличие транспорта в реестре,а не его UP/готовность группы;deleted показывается offline. + * addrs.proto=UDP/TCP/TCP+REALITY,IPv4/IPv6 текстовые;канал/буфер как у messages/channels_json. */ int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, char* buf, size_t buf_size, size_t* out_len); -// Имя узла из таблицы nodes +/* Имя nodes с NUL,при необходимости обрезанное до sz-1 байт:0 — прочитано/узел отсутствует,-1 — ошибка. + * out/sz>0 обязательны;отсутствие узла и ошибка оставляют пустую строку (если буфер допустим). */ int chat_core_get_node_name(struct UTUN_INSTANCE* inst, uint64_t node_id, char* out, size_t sz); -// Ссылка на описание avatars/.json внутри дерева своего UID/канала. server=0 удаляет ссылку. +/* Ссылка avatars/.json внутри дерева своего UID/канала;server=0 удаляет ссылку,file остаётся обязательной NUL-строкой. + * 0 — committed/семантически прежнее,-1 — отказ;чужая транзакция запрещена. Storage-роль сервера проверяется под блокировкой. */ int chat_core_set_my_avatar(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t server, const char* file); +/* Построить {name:string},включая Unicode/control escaping;0/-1,при ошибке out не меняется. */ int chat_core_name_userinfo(const char* name, char* out, size_t capacity); +/* Прочитать собственный подписанный профиль:0/-1,при отказе все выходы сохраняются. + * Проверяет SQL,размеры,JOIN/update-подписи и целый плоский JSON с уникальными строковыми полями. + * out требует capacity>=длина+1,signature64,timestamp — версия update_ts в секундах/монотонных шагах. */ int chat_core_get_my_profile(struct UTUN_INSTANCE* inst, const char* channel, char* out, size_t capacity, uint8_t signature[64], uint64_t* timestamp); /* Одно подписанное обновление нескольких собственных строковых опций. NULL value удаляет ключ. - * Остальные поля сохраняются; имя/аватар меняются своими API. Ошибка не меняет БД/Merkle. - * Вызов только в uasync-потоке, вне внешней SQLite-транзакции. */ + * Остальные подписанные поля сохраняются;неподписанные старые атрибуты не авторизуются новым обновлением. + * Имя/аватар меняются своими API. Чтение/проверка/версия/запись/Merkle защищены одним BEGIN IMMEDIATE. + * Семантический no-op сохраняет подпись/версию независимо от порядка ключей/escaping;ошибка не меняет БД/Merkle. + * Уведомления идут после commit,без SQLite cursor. Вызов в uasync вне чужой транзакции; + * неудачный rollback с открытой транзакцией запрашивает stop/закрытие БД владельцем. */ struct chat_profile_option { const char* key; const char* value; }; int chat_core_set_my_profile_options(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_profile_option* options, size_t count); @@ -311,26 +347,33 @@ void chat_core_set_my_avatar_trampoline(void* arg); struct chat_member_tags; -// Загрузить текущие adm_tags мембера в память. Возвращает NULL если мембера нет. +/* Загрузить проверенный owner-снимок полного участника:NULL при отсутствии/placeholder/SQL/подписи/формате. + * Объект принадлежит caller до free,используется в uasync и освобождается до остановки chat-сервиса. */ struct chat_member_tags* chat_member_tags_load(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id); -// Установить ключ. val=NULL → удалить ключ. ver — особый, автоинкремент при commit. +/* Изменить желаемое поле:0/-1;отказ не меняет editor. val=NULL удаляет поле;admin/moder принимают yes/no. + * Пустой ключ/ver запрещены,версию выбирает commit;место для ver резервируется в лимите16 ключей. */ int chat_member_tags_set(struct chat_member_tags* t, const char* key, const char* val); // Получить значение ключа. Возвращает NULL если ключ не найден. const char* chat_member_tags_get(struct chat_member_tags* t, const char* key); -// Заменить все теги данными из JSON-строки (плоский, строковые значения). +/* Заменить желаемые теги:0/-1,при отказе editor не меняется. Весь объект<=8192 байта,уникальные + * непустые ключи,только строки;admin/moder yes/no. Необязательный ver обязан совпадать с исходным снимком. */ int chat_member_tags_replace(struct chat_member_tags* t, const char* json); -/* Сохранить: сериализовать в JSON, инкрементировать ver, подписать канальным Ed25519, - * записать через member_sync_put() → синхронизируется всем. */ +/* Собственный BEGIN IMMEDIATE проверяет исходный owner-снимок и фиксирует новую подпись/версию/Merkle. + * 0 — committed/no-op,-2 — конфликт с изменившимся owner-блоком,-1 — прочий отказ;чужая транзакция запрещена. + * Отказ сохраняет editor/БД,успех обновляет editor для следующей правки. No-op сохраняет версию/подпись. + * Версия1..INT_MAX;admin/moder сохраняют историю,отключение дописывает d. Приватный ключ очищается. + * Уведомления после commit без cursor;при неудачном rollback запрашивается stop/закрытие БД владельцем. */ int chat_member_tags_commit(struct UTUN_INSTANCE* inst, struct chat_member_tags* t); // Освободить без сохранения. void chat_member_tags_free(struct chat_member_tags* t); -// Трамплин для gui_bridge: arg = struct chat_member_tags_req* (доверенная память). +/* Трамплин для gui_bridge:владеет req/u_free. json — частичный объект желаемых полей,неуказанные сохраняются; + * необязательный ver проверяет снимок формы. Пустая строка — no-op;ошибки логируются без commit. */ struct chat_member_tags_req { struct UTUN_INSTANCE* inst; char ch_id[64]; diff --git a/src/chat/chat_core_priv.h b/src/chat/chat_core_priv.h index 7d8e2e45e..5c0f46ce3 100644 --- a/src/chat/chat_core_priv.h +++ b/src/chat/chat_core_priv.h @@ -19,6 +19,8 @@ #include #define CC_ID "chat_core" +#define CHAT_MEDIA_BACKFILL_CHANNELS 16 +#define CHAT_CHANNEL_DESCRIPTION_MAX (20 + 256 + 8 + 32 + 32) /* ── Base64 encode/decode (URL-safe, used for msg "d" field prefix) ── */ @@ -80,6 +82,7 @@ struct media_index_result; struct ms_apply_cbk; /* member_sync.c */ struct group_avatar; struct media_submit_ctx; +struct ak_file; struct chat_core_ctx { struct UTUN_INSTANCE* inst; @@ -92,18 +95,21 @@ struct chat_core_ctx { struct ms_props_cbk* props_cbks; /* was member_sync.c g_props_cbks */ unsigned props_dispatch_depth; /* снятие props-подписки откладывает free до выхода из вложенных callbacks */ struct ms_apply_cbk* apply_cbks; /* was member_sync.c g_apply_cbks */ + unsigned apply_dispatch_depth; /* снятые apply callbacks освобождаются после выхода из всех вложенных вызовов */ struct group_avatar* group_avatar; uint64_t media_cache_bytes; /* was chat_msg.c g_media_cache_bytes */ struct media_submit_ctx* media_submissions; /* исходящие задачи до последнего callback; удаление канала ждёт их */ void* media_backfill_timer;/* was chat_msg.c g_media_startup_backfill_timer */ int media_backfill_ran; /* was chat_msg.c g_media_startup_backfill_ran */ struct UTUN_INSTANCE* media_backfill_inst; /* was chat_msg.c g_media_startup_backfill_inst */ - void* media_channel_backfill_timer; /* debounce per-channel докачки */ - char media_channel_backfill[16][64]; /* pending ch_id */ + void* media_channel_backfill_timer; /* ограниченный batch докачки/восстановления индекса через 2с от первой заявки */ + char media_channel_backfill[CHAT_MEDIA_BACKFILL_CHANNELS][64]; /* pending ch_id */ int media_channel_backfill_pending; void* attachment_timer; int attachment_closing; int attachment_initialized; + struct ak_file* attachment_files; /* workers и завершения, ожидающие SQL commit; владеет chat_attachment */ + unsigned attachment_file_count; /* максимум MEDIA_ASYNC_MAX_TASKS, включая отложенные завершения */ }; /* Per-instance chat context. inst->chat_core обязателен после chat_core_init. */ @@ -115,6 +121,21 @@ void sanitize_ch_id(const char* ch_id, char* out, size_t out_sz); void msg_table_name(const char* ch_id, char* buf, size_t sz); void peers_table_name(const char* ch_id, char* buf, size_t sz); +/* Тихая проверка канонического десятичного ID1..INT64_MAX;0 — отказ,NULL также недопустим. + * Использующий модуль логирует ошибку с контекстом своей операции. */ +uint64_t chat_channel_id(const char* text); + +/* Проверить весь JSON-object<=limit:уникальные ключи,только строковые значения,без скрытого NUL. + * SQLite заимствована;0/-1 с логом,callback может быть NULL. Callback получает строки до следующего step, + * строит только временный результат и возвращает0/-1;при поздней ошибке caller отбрасывает весь результат. */ +int chat_json_strings(sqlite3* db, const char* text, size_t limit, + int (*visit)(const char* key, const char* value, void* arg), void* arg); + +/* Байты подписи описания:канонический ID+NUL,имя<=255байт+NUL,owner native-u64,X25519/Ed25519 public[32]. + * Возвращает длину/-1;аргументы обязательны,при отказе out не меняется. Общий формат create/JOIN/invite. */ +int chat_channel_description_data(const char* channel, const char* name, uint64_t owner, + const uint8_t x25519[32], const uint8_t ed25519[32], uint8_t* out, size_t capacity); + /* ── db_sync registry (chat_core.c) ── */ struct DB_SYNC_INSTANCE* si_find(struct UTUN_INSTANCE* inst, const char* ch_id); diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 64492fc92..d5c03e42e 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -50,13 +50,19 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [43]="RADIO_TALK", [44]="CALL_CONNECTION", [45]="DM_CONVERSATIONS", [46]="ATTACHMENT_PREPARATION", [47]="DM_CALL_ROUTE", + [48]="DM_INVITE_RESULT", [49]="MEMBER_FILES_RESPONSE", [50]="MEMBER_FILES_TRANSFER", + [51]="AVATAR_RESULT", [52]="GROUP_AVATAR_RESULT", [53]="FEEDBACK_CHANGED", [54]="FEEDBACK_RESULT", [55]="RADIO_MUTE_RESULT", [56]="MSG_SUBMITTED", [57]="CPU_LOAD", + [58]="STREAM_SOURCES", [59]="DM_DELETE_RESULT", + [60]="ATTACHMENT_KEYS_CHANGED", [61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE", + [65]="RADIO_CALL", [66]="CALL_PHONE_BUSY", [67]="MSG_UPDATED", }; - const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : "?"; + const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : NULL; + if (!n) n = "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len); } @@ -73,6 +79,7 @@ void chat_event_submitted(struct UTUN_INSTANCE* inst, uint64_t request_id, int s /* ID задачи не даёт позднему завершению изменить статус другой подготовки. */ void chat_event_attachment(struct UTUN_INSTANCE* inst, int is_dm, const char* target, const uint8_t id[16], int state) { + if (!target || !id) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: preparation event requires target and UUID"); return; } char name[68]; int n = snprintf(name, sizeof(name), "%s%s", is_dm ? "dm:" : "", target); if (n <= 0 || (size_t)n >= sizeof(name) || state < 0 || state > 2) { diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index ecd5be5e6..0e0f049f4 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -6,7 +6,7 @@ * post вызывает его сразу, в том же uasync-потоке; очереди событий здесь нет. * data заимствован только до возврата обработчика. Для передачи в другой поток нужна копия. * - * Без обработчика — данные логируются через DEBUG_DEBUG + log_dump. + * Без обработчика — данные логируются через DEBUG_DEBUG + log_dump; LOCAL_INVITE с ключом не дампится. * Все вызовы — из uasync-потока. */ @@ -67,7 +67,7 @@ extern "C" { #define CHAT_EVT_RADIO_TALK 43 /* [group_id:8][src_node_id:8][stream_id:2][on:1] — кто-то начал/закончил говорить */ #define CHAT_EVT_CALL_CONNECTION 44 /* [call_id:8][CONN_TYPE:1][next_hop_node_id:8] — текущий путь отправки звонка */ #define CHAT_EVT_DM_CONVERSATIONS 45 /* [JSON:utf8] — последние 200 личных бесед */ -#define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], 0=start 1=ready 2=failed */ +#define CHAT_EVT_ATTACHMENT_PREPARATION 46 /* [target_len:1][target:var][job_uuid:16][state:1], target=channel либо "dm:"+conv;0/1/2=start/ready/failed */ #define CHAT_EVT_DM_CALL_ROUTE 47 /* [conv_id_len:1][conv_id:var][peer_node_id:8][group_id:8], 0 = нет BGP-пути */ #define CHAT_EVT_DM_INVITE_RESULT 48 /* [request_id:8][result:4][peer_node_id:8][conv_id:8][generation:8], dm_invite_result */ @@ -104,7 +104,8 @@ void chat_event_set_handler(struct UTUN_INSTANCE* inst, chat_event_handler_fn ha // Синхронно передать type и len байт data; память остаётся у отправителя. Формат зависит от CHAT_EVT_*. void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); -// Сообщить этап подготовки вложения: state=0 начало, 1 готово, 2 ошибка; target — ID канала или DM. +// Сообщить этап подготовки: state=0 начало,1 готово,2 ошибка; target — ID без префикса, is_dm добавляет "dm:". +// target/id обязательны, копируются в синхронный payload; неверный state/переполнение строки только логируется. void chat_event_attachment(struct UTUN_INSTANCE* inst, int is_dm, const char* target, const uint8_t id[16], int state); // request_id=0 отключает подтверждение; вызывается один раз после сохранения либо ошибки подготовки. diff --git a/src/chat/chat_feedback.c b/src/chat/chat_feedback.c index 2c414bae0..26972fadf 100644 --- a/src/chat/chat_feedback.c +++ b/src/chat/chat_feedback.c @@ -171,12 +171,19 @@ int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uin snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature FROM %s" " WHERE message_id=? AND node_id=? AND kind=?", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "get"); - sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id); - sqlite3_bind_int(st, 3, kind); - int rc = sqlite3_step(st), result = rc == SQLITE_ROW ? (fb_read_row(st, out) < 0 ? -1 : 1) : 0; - sqlite3_finalize(st); - if (rc != SQLITE_ROW && rc != SQLITE_DONE) return fb_db_error(inst, channel, "get step"); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 3, kind); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + struct chat_feedback_record record; + int result = rc == SQLITE_ROW ? (fb_read_row(st, &record) < 0 ? -1 : 1) : 0; + int final = sqlite3_finalize(st); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: lookup failed ch=%s node=%016llx kind=%u step=%d finalize=%d db=%s", + channel, (unsigned long long)node_id, kind, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; + } + if (result > 0) *out = record; return result; } @@ -217,14 +224,22 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s snprintf(sql, sizeof(sql), "INSERT INTO %s(record_key,bucket,message_id,node_id,kind,ver,data,signature)" " VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(record_key) DO UPDATE SET ver=excluded.ver,data=excluded.data,signature=excluded.signature", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "put"); - sqlite3_bind_blob(st, 1, record_key.bytes, 32, SQLITE_STATIC); - sqlite3_bind_int64(st, 2, (sqlite3_int64)(fb_read64(record_key.bytes) >> 39)); - sqlite3_bind_blob(st, 3, r->message_id, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)r->node_id); - sqlite3_bind_int(st, 5, r->kind); sqlite3_bind_int64(st, 6, (sqlite3_int64)r->ver); - sqlite3_bind_blob(st, 7, r->data, r->data_len, SQLITE_STATIC); sqlite3_bind_blob(st, 8, r->signature, 64, SQLITE_STATIC); - int rc = sqlite3_step(st); sqlite3_finalize(st); - if (rc != SQLITE_DONE) return fb_db_error(inst, channel, "put step"); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, record_key.bytes, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)(fb_read64(record_key.bytes) >> 39)); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, r->message_id, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)r->node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 5, r->kind); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 6, (sqlite3_int64)r->ver); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 7, r->data, r->data_len, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 8, r->signature, 64, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int changed = rc == SQLITE_DONE ? sqlite3_changes(inst->topo_sqlite_db) : 0; + int final = sqlite3_finalize(st); + if (rc != SQLITE_DONE || changed != 1 || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: write failed ch=%s node=%016llx kind=%u step=%d changes=%d finalize=%d db=%s", + channel, (unsigned long long)r->node_id, r->kind, rc, changed, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; + } if (merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, fb_read64(record_key.bytes)) < 0) return -1; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: applied ch=%s message=%s node=%016llx kind=%u ver=%llu previous=%llu bytes=%u", channel, message_hex, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver, @@ -239,15 +254,23 @@ static void fb_committed(struct UTUN_INSTANCE* inst, const char* channel, const chat_event_post(inst, CHAT_EVT_FEEDBACK_CHANGED, event, (int)len + 34); } -/* Завершает savepoint, при ошибке не публикует частичные изменения. */ +/* Завершает только свою транзакцию; неудачный rollback никогда не переходит в commit. */ static int fb_finish_transaction(struct UTUN_INSTANCE* inst, const char* channel, int result) { sqlite3* db = inst->topo_sqlite_db; - if (result < 0 && sqlite3_exec(db, "ROLLBACK TO feedback_update", NULL, NULL, NULL) != SQLITE_OK) - fb_db_error(inst, channel, "rollback"); - if (sqlite3_exec(db, "RELEASE feedback_update", NULL, NULL, NULL) == SQLITE_OK) return result; - fb_db_error(inst, channel, "commit"); - if (sqlite3_exec(db, "ROLLBACK TO feedback_update; RELEASE feedback_update", NULL, NULL, NULL) != SQLITE_OK) - fb_db_error(inst, channel, "commit cleanup"); + if (result >= 0) { + int rc = sqlite3_exec(db, "COMMIT /* feedback update */", NULL, NULL, NULL); + if (rc == SQLITE_OK) return result; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: commit failed ch=%s rc=%d (%s) db=%s", channel, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + } + if (!sqlite3_get_autocommit(db)) { + int rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: rollback failed ch=%s rc=%d (%s) db=%s", + channel, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + } + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: transaction remains open ch=%s; requesting core stop and owner DB close after callback", channel); + utun_instance_stop(inst); + } return -1; } @@ -259,7 +282,7 @@ int chat_feedback_apply(struct UTUN_INSTANCE* inst, const char* channel, const s if (!sqlite3_get_autocommit(inst->topo_sqlite_db)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: public apply inside external transaction ch=%s", channel); return -1; } - if (sqlite3_exec(inst->topo_sqlite_db, "SAVEPOINT feedback_update", NULL, NULL, NULL) != SQLITE_OK) + if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback update */", NULL, NULL, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "begin"); int result = fb_finish_transaction(inst, channel, fb_apply_row(inst, channel, r)); if (result > 0) { merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel); fb_committed(inst, channel, r); } @@ -272,29 +295,40 @@ int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uin if (fb_table(inst, channel, table, &id) < 0 || !message_id || len > CHAT_FEEDBACK_DATA_MAX || (len && !data)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid local state"); return -1; } + if (!sqlite3_get_autocommit(inst->topo_sqlite_db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local set inside external transaction ch=%s", channel); return -1; + } + if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback local set */", NULL, NULL, NULL) != SQLITE_OK) + return fb_db_error(inst, channel, "local begin"); + int result = -1; + struct chat_feedback_record r = { .node_id = inst->node_id, .ver = 1, .kind = kind, .data_len = len }, old; + memcpy(r.message_id, message_id, 32); if (len) memcpy(r.data, data, len); uint8_t author_key[32]; enum ms_author_status status = member_sync_author_key(inst, channel, inst->node_id, author_key); if (status != MS_AUTHOR_ACTIVE) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local action denied ch=%s node=%016llx status=%d", - channel, (unsigned long long)inst->node_id, status); return -1; + channel, (unsigned long long)inst->node_id, status); goto finish; } - struct chat_feedback_record r = { .node_id = inst->node_id, .ver = 1, .kind = kind, .data_len = len }, old; - memcpy(r.message_id, message_id, 32); if (len) memcpy(r.data, data, len); int found = chat_feedback_get(inst, channel, message_id, inst->node_id, kind, &old); - if (found < 0) return -1; - if (found && old.data_len == len && !memcmp(old.data, r.data, len)) { if (out_ver) *out_ver = old.ver; return 0; } + if (found < 0) goto finish; + if (found && old.data_len == len && !memcmp(old.data, r.data, len)) { r.ver = old.ver; result = 0; goto finish; } if (found && old.ver == INT64_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: version exhausted ch=%s", channel); return -1; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: version exhausted ch=%s", channel); goto finish; } if (found) r.ver = old.ver + 1; uint8_t body[FB_WIRE_MAX + 32]; int bytes = fb_signed_bytes(id, &r, body); if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, r.signature) != SC_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local signing failed ch=%s", channel); return -1; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local signing failed ch=%s", channel); goto finish; } - int result = chat_feedback_apply(inst, channel, &r); + result = fb_apply_row(inst, channel, &r); +finish: + result = fb_finish_transaction(inst, channel, result); if (result >= 0 && out_ver) *out_ver = r.ver; - if (result > 0) DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local state saved ch=%s kind=%u ver=%llu bytes=%zu", - channel, kind, (unsigned long long)r.ver, len); + if (result > 0) { + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: local state saved ch=%s kind=%u ver=%llu bytes=%zu", + channel, kind, (unsigned long long)r.ver, len); + merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel); fb_committed(inst, channel, &r); + } return result; } @@ -302,16 +336,22 @@ int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uin static int fb_page(void* ctx, const char* channel, uint64_t prefix, int av, const merkle_key_t* after, uint8_t* data, size_t* len, merkle_key_t* next, int* more) { struct UTUN_INSTANCE* inst = ctx; char table[40], sql[384]; uint64_t id; - if (fb_table(inst, channel, table, &id) < 0 || *len < 2) { + if (fb_table(inst, channel, table, &id) < 0 || !len || *len < 2 || !after || !data || !next || !more) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid page capacity"); return -1; } merkle_key_t cursor = *after; memset(next, 0, sizeof(*next)); *more = 0; snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket FROM %s" " WHERE bucket=? AND (?=0 OR record_key>?) ORDER BY record_key", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "page"); - sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39)); sqlite3_bind_int(st, 2, av); - sqlite3_bind_blob(st, 3, cursor.bytes, 32, SQLITE_STATIC); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39)); + if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 2, av); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, cursor.bytes, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) { + int final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page query failed ch=%s rc=%d finalize=%d db=%s", + channel, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; + } size_t off = 2; unsigned count = 0; int step, result = 0; while ((step = sqlite3_step(st)) == SQLITE_ROW) { struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX]; @@ -328,7 +368,11 @@ static int fb_page(void* ctx, const char* channel, uint64_t prefix, int av, cons memcpy(next->bytes, sqlite3_column_blob(st, 6), 32); } if (result == 0 && !*more && step != SQLITE_DONE) result = fb_db_error(inst, channel, "page step"); - sqlite3_finalize(st); data[0] = count >> 8; data[1] = count & 255; *len = off; return result; + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page finalize failed ch=%s rc=%d", channel, final); result = -1; + } + data[0] = count >> 8; data[1] = count & 255; *len = off; return result; } /* Хеширует подписанные тела: разные валидные подписи одного тела не меняют корень. */ @@ -340,8 +384,13 @@ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefi snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket" " FROM %s WHERE bucket=? ORDER BY record_key", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "hash"); - sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39)); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)(prefix >> 39)); + if (rc != SQLITE_OK) { + int final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: hash query failed ch=%s rc=%d finalize=%d db=%s", + channel, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; + } int step, count = 0; while ((step = sqlite3_step(st)) == SQLITE_ROW) { struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX]; @@ -354,7 +403,10 @@ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefi } count++; } - sqlite3_finalize(st); + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: hash finalize failed ch=%s rc=%d", channel, final); return -1; + } if (count >= 0 && step != SQLITE_DONE) return fb_db_error(inst, channel, "hash step"); return count; } @@ -362,7 +414,10 @@ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefi /* Вся страница применяется атомарно; события отправляются после её commit. */ static int fb_apply_page(void* ctx, const char* channel, uint64_t peer, const uint8_t* data, size_t len) { struct UTUN_INSTANCE* inst = ctx; (void)peer; - if (len < 2) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: short page ch=%s", channel); return -1; } + if (!inst || !inst->topo_sqlite_db || !sqlite3_get_autocommit(inst->topo_sqlite_db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page requires own transaction ch=%s", channel); return -1; + } + if (!data || len < 2) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: short/missing page ch=%s", channel); return -1; } unsigned count = ((unsigned)data[0] << 8) | data[1]; size_t off = 2; if (count > (len - 2) / FB_FIXED_SIZE) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid page count=%u bytes=%zu", count, len); return -1; @@ -376,7 +431,7 @@ static int fb_apply_page(void* ctx, const char* channel, uint64_t peer, const ui if (!count) return 0; uint8_t* changed = u_calloc(count, 1); if (!changed) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: page allocation failed"); return -1; } - if (sqlite3_exec(inst->topo_sqlite_db, "SAVEPOINT feedback_update", NULL, NULL, NULL) != SQLITE_OK) { + if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback page */", NULL, NULL, NULL) != SQLITE_OK) { u_free(changed); return fb_db_error(inst, channel, "page begin"); } off = 2; int result = 0; @@ -507,7 +562,7 @@ int chat_feedback_init(struct UTUN_INSTANCE* inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: init requires database outside transaction"); return -1; } if (merkle_sync_register(inst, MT_DATA_FEEDBACK, &fb_ops, inst) < 0) return -1; - if (sqlite3_exec(inst->topo_sqlite_db, "SAVEPOINT feedback_update", NULL, NULL, NULL) != SQLITE_OK) + if (sqlite3_exec(inst->topo_sqlite_db, "BEGIN IMMEDIATE /* feedback init */", NULL, NULL, NULL) != SQLITE_OK) return fb_db_error(inst, "-", "init begin"); char channel[21] = "", next[21]; int scan, result = 0; unsigned buckets = 0; while ((scan = fb_next_channel(inst, channel, next, 0)) > 0) { diff --git a/src/chat/chat_feedback.h b/src/chat/chat_feedback.h index f6d8375b3..21133d003 100644 --- a/src/chat/chat_feedback.h +++ b/src/chat/chat_feedback.h @@ -4,7 +4,10 @@ * но не входят в Merkle, выдачу и результаты. Допуск проверяет member_sync; * интерпретацию ответа относительно опроса выполняет вызывающий модуль. * Все операции — в uasync-потоке. init вызывается после member_sync_init; - * общий Merkle-движок освобождается member_sync_destroy. */ + * общий Merkle-движок освобождается member_sync_destroy. + * apply/set/приём страницы/init владеют своей BEGIN IMMEDIATE/COMMIT; внешняя транзакция запрещена. + * Запись и Merkle-индекс фиксируются вместе, уведомление — после commit. Неудачный rollback + * с открытой транзакцией запрашивает stop ядра; владелец закрывает БД вне callback. */ #ifndef CHAT_FEEDBACK_H #define CHAT_FEEDBACK_H @@ -13,19 +16,23 @@ struct UTUN_INSTANCE; +#ifdef __cplusplus +extern "C" { +#endif + #define CHAT_FEEDBACK_REACTION 1 #define CHAT_FEEDBACK_ANSWER 2 #define CHAT_FEEDBACK_DATA_MAX 2048 #define CHAT_MESSAGE_ID_SIZE 32 struct chat_feedback_record { - uint8_t message_id[CHAT_MESSAGE_ID_SIZE]; - uint64_t node_id; + uint8_t message_id[CHAT_MESSAGE_ID_SIZE]; /* Неизменяемый ID исходного сообщения. */ + uint64_t node_id; /* Автор этого состояния,1..INT64_MAX. */ uint64_t ver; /* 1..INT64_MAX; отдельная версия для каждого kind */ - uint8_t kind; - uint16_t data_len; - uint8_t data[CHAT_FEEDBACK_DATA_MAX]; - uint8_t signature[64]; + uint8_t kind; /* REACTION либо ANSWER. */ + uint16_t data_len; /* Длина полного состояния,0 отменяет. */ + uint8_t data[CHAT_FEEDBACK_DATA_MAX]; /* Канонические байты codec из chat_poll.h. */ + uint8_t signature[64]; /* Ed25519 автора над domain/каналом/полями/data. */ }; /* Постоянный ID: канал, время создания, автор и случайный nonce[16]; 0/-1. Числа — BE64. */ @@ -42,15 +49,21 @@ int chat_feedback_member_update(struct UTUN_INSTANCE* inst, uint64_t node_id); void chat_feedback_member_committed(struct UTUN_INSTANCE* inst, uint64_t node_id); /* Подготовить таблицу канала; 0/-1. Не создаёт членство или сетевую группу. */ int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel); -/* Прочитать сохранённое состояние, включая неактивное: 1 найдено, 0 отсутствует, -1 ошибка. */ +/* Прочитать сохранённое состояние, включая неактивное:1 найдено,0 отсутствует,-1 ошибка. + * out меняется только при успехе1;ошибка bind/step/finalize не считается отсутствием. */ int chat_feedback_get(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], uint64_t node_id, uint8_t kind, struct chat_feedback_record* out); /* Проверить и применить подписанную запись: 1 изменилось, 0 повтор/старая/автор недоступен, -1 ошибка. * Одинаковый ver разрешается сравнением data; подпись проверяется до сравнения. */ int chat_feedback_apply(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* record); -/* Подписать локальное полное состояние с ver+1; NULL/0 отменяет. 1/0/-1 как apply. - * out_ver необязателен. Семантику data проверяет вызывающий модуль до этого вызова. */ +/* Подписать локальное полное состояние с ver+1; NULL/0 отменяет.1/0/-1 как apply. + * Допуск/прежняя версия/подпись/запись выполняются под собственной блокировкой,включая no-op. + * out_ver необязателен,меняется только после commit. Канонический формат проверяет codec, + * семантику ответа относительно опроса проверяет вызывающий до этого вызова. */ int chat_feedback_set(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], uint8_t kind, const uint8_t* data, size_t len, uint64_t* out_ver); +#ifdef __cplusplus +} +#endif #endif diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index 2d2b03171..f3c134df5 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -592,8 +592,10 @@ static void hc_handle_connect(struct headless_client* cli, int id, const char* j static void hc_handle_create_channel(struct headless_client* cli, int id, const char* json) { char name[256]; if (json_get_str(json, "name", name, sizeof(name)) < 0) { send_response(cli, id, NULL, "missing 'name' param"); return; } - chat_core_create_channel_auto(cli->hc->inst, name); - send_response(cli, id, "{\"created\":true}", NULL); + uint64_t channel = chat_core_create_channel_auto(cli->hc->inst, name); + if (!channel) { send_response(cli, id, NULL, "channel creation failed (see log)"); return; } + char result[96]; snprintf(result, sizeof(result), "{\"created\":true,\"channel_id\":\"%llu\"}", (unsigned long long)channel); + send_response(cli, id, result, NULL); } static void hc_handle_invite_to(struct headless_client* cli, int id, const char* json) { diff --git a/src/chat/chat_local.h b/src/chat/chat_local.h index 0820a4022..64e801593 100644 --- a/src/chat/chat_local.h +++ b/src/chat/chat_local.h @@ -1,6 +1,9 @@ /* Адаптер LAN discovery: готовые invite-ссылки и подписанные адреса известных узлов, без запуска JOIN. * Адреса подтверждаются nonce-ответом только при отсутствии прямого UP и BGP-путей во всех группах. - * Ключи берутся из registry/SQLite и не передаются в открытых объявлениях; NODEINFO не изменяется. */ + * Ключи берутся из registry/SQLite и не передаются в открытых объявлениях; NODEINFO не изменяется. + * Весь API и callbacks работают в uasync-потоке живого instance; stop — до освобождения ядра. + * LOCAL_INVITE передаёт проверенную ссылку обработчику chat_event; получение не запускает JOIN. + * LOCAL_DELIVERY подтверждает доставку ссылки устройству, а не вступление в канал. */ #ifndef CHAT_LOCAL_H #define CHAT_LOCAL_H @@ -16,7 +19,8 @@ int chat_local_start(struct UTUN_INSTANCE* inst); /* Снять подписку и уничтожить транспорт чат-сервиса; NULL/repeat допустимы. */ void chat_local_stop(struct UTUN_INSTANCE* inst); /* Применить local_invites_enabled/local_broadcasts_enabled из chat_setting без рестарта. - * При отсутствии транспорта ничего не делает. Возвращает 0/-1. */ + * Receive проверяется при каждой доставке, broadcasts применяется к транспорту. + * При отсутствии транспорта ничего не делает. Возвращает 0/-1; сохранённые настройки не откатывает. */ int chat_local_apply_settings(struct UTUN_INSTANCE* inst); /* Трамплины uasync. peers получает заимствованный inst; send освобождает u_malloc request. */ void chat_local_peers_trampoline(void* inst); diff --git a/src/chat/chat_member.c b/src/chat/chat_member.c index 09e46b8b0..113cb439f 100644 --- a/src/chat/chat_member.c +++ b/src/chat/chat_member.c @@ -21,14 +21,14 @@ #include "../../lib/platform_compat.h" #include -#include /* ─── live-онлайн: тот же живой путь, которым пользуется маршрутизатор ─── */ int chat_core_member_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id) { - if (!inst) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member online: null instance"); return 0; } - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->inst || !ch_id || !ch_id[0]) return 0; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->inst || !chat_channel_id(ch_id)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member online: inactive instance or invalid channel"); return 0; + } if (node_id == cc->my_node_id) return 1; /* self всегда онлайн (local_node вне group->nodes) */ if (!cc->inst->topo_groups) return 0; uint64_t group_id = strtoull(ch_id, NULL, 10); @@ -64,7 +64,7 @@ static uint8_t parse_adm_tags_flags(const char* adm_tags) { uint8_t flags = 0; /* supernode */ - char buf[256]; + char buf[MS_ADM_TAGS_MAX + 1]; if (json_flat_get(adm_tags, "supernode", buf, sizeof(buf)) == 0 && strcmp(buf, "yes") == 0) flags |= CHAT_MEMBER_FLAG_SUPERNODE; @@ -89,6 +89,7 @@ static uint8_t parse_adm_tags_flags(const char* adm_tags) { /* ─── сериализация (struct → wire) ─── */ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* dst) { + if (!src || !dst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member serialization: missing argument"); return; } mf_put64(dst, src->node_id); dst[8] = src->online; dst[9] = src->is_self; @@ -100,7 +101,7 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* strncpy((char*)dst + 15, src->name, sizeof(src->name) - 1); dst[78] = 0; mf_put64(dst + 79, src->avatar_server); - memcpy(dst + 87, src->avatar_file, 70); + strncpy((char*)dst + 87, src->avatar_file, 69); dst[156] = 0; dst[157] = src->storage_role; dst[158] = src->verified; dst[159] = src->node_type; dst[160] = src->conn_presence; dst[161] = src->conn_up; @@ -110,7 +111,7 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* } int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst) { - if (!src || !dst) return -1; + if (!src || !dst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member deserialization: missing argument"); return -1; } dst->node_id = mf_get64(src); dst->online = src[8]; dst->is_self = src[9]; @@ -199,7 +200,7 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl uint8_t** out, int* count) { struct chat_core_ctx* cc = inst ? CC(inst) : NULL; *out = NULL; *count = 0; - if (!cc || !cc->initialized || !ch_id || !ch_id[0] || !cc->db || !cc->inst) { + if (!cc || !cc->initialized || !chat_channel_id(ch_id) || !cc->db || !cc->inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: invalid instance/channel"); return -1; } char table[80]; peers_table_name(ch_id, table, sizeof(table)); @@ -215,36 +216,32 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl sqlite3_stmt* st = NULL; int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); if (rc == SQLITE_OK && single) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (rc != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: query ch=%s rc=%d: %s", ch_id, rc, sqlite3_errmsg(cc->db)); - sqlite3_finalize(st); return -1; - } struct chat_member_display* members = NULL; - size_t limit = UINT32_MAX / sizeof(*members); // u_realloc принимает uint32_t число байтов - if (limit > INT_MAX) limit = INT_MAX; int cap = 0, cnt = 0; + if (rc != SQLITE_OK) goto done; while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + if (cnt == UINT16_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: channel exceeds wire member limit ch=%s limit=%u", ch_id, UINT16_MAX); + rc = SQLITE_TOOBIG; break; + } if (cnt == cap) { - if ((size_t)cap >= limit) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: allocation limit ch=%s count=%d max=%zu", ch_id, cnt, limit); - u_free(members); sqlite3_finalize(st); return -1; - } - size_t next = cap ? (size_t)cap * 2 : (single ? 1 : 32); - if (next > limit) next = limit; - cap = (int)next; - struct chat_member_display* tmp = u_realloc(members, (uint32_t)(next * sizeof(*members))); + cap = cap ? (cap > UINT16_MAX / 2 ? UINT16_MAX : cap * 2) : (single ? 1 : 32); + struct chat_member_display* tmp = u_realloc(members, (size_t)cap * sizeof(*members)); if (!tmp) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: allocation ch=%s count=%d", ch_id, cnt); - u_free(members); sqlite3_finalize(st); return -1; + rc = SQLITE_NOMEM; break; } members = tmp; } member_from_row(inst, ch_id, st, &members[cnt++]); } - sqlite3_finalize(st); - if (rc != SQLITE_DONE) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: read ch=%s rc=%d: %s", ch_id, rc, sqlite3_errmsg(cc->db)); - u_free(members); return -1; +done: + { + int final = sqlite3_finalize(st); + if (rc != SQLITE_DONE || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: read ch=%s rc=%d finalize=%d count=%d: %s", + ch_id, rc, final, cnt, sqlite3_errmsg(cc->db)); u_free(members); return -1; + } } if (cnt > 1) qsort(members, (size_t)cnt, sizeof(*members), compare_members); if (cnt) { @@ -260,6 +257,8 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl } int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uint8_t** out, int* count) { + if (out) *out = NULL; + if (count) *count = 0; if (!out || !count) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: null output"); return -1; } return member_query(inst, ch_id, 0, 0, out, count); } @@ -278,14 +277,15 @@ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, u int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id) { uint8_t member[CHAT_MEMBER_DISPLAY_SIZE]; - if (!ch_id || !ch_id[0] || strlen(ch_id) >= 64 || chat_core_get_single_member(inst, ch_id, node_id, member) < 0) { + if (!chat_channel_id(ch_id) || chat_core_get_single_member(inst, ch_id, node_id, member) < 0) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: mute toggle member not found node=%016llx", (unsigned long long)node_id); return -1; } int muted = !(member[10] & CHAT_MEMBER_FLAG_RADIO_MUTED); if (radio_set_member_muted(inst, strtoull(ch_id, NULL, 10), node_id, muted) < 0) return -1; - if (chat_core_get_single_member(inst, ch_id, node_id, member) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: cannot publish local mute ch=%s node=%016llx", - ch_id, (unsigned long long)node_id); return -1; + /* Снимок уже готов: после committed mute нет нового чтения, способного превратить успех в ошибку. */ + member[10] = (uint8_t)((member[10] & ~CHAT_MEMBER_FLAG_RADIO_MUTED) | (muted ? CHAT_MEMBER_FLAG_RADIO_MUTED : 0)); + if (!inst->topo_groups || !topo_groups_find(inst->topo_groups, strtoull(ch_id, NULL, 10))) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: mute committed, group removed by audio callback ch=%s", ch_id); return 0; } size_t cl = strlen(ch_id); uint8_t evt[1 + 64 + 1 + CHAT_MEMBER_DISPLAY_SIZE]; @@ -300,7 +300,7 @@ void chat_core_toggle_member_radio_mute_trampoline(void* arg) { if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: null member mute request"); return; } int rc = chat_core_toggle_member_radio_mute(req->inst, req->ch_id, req->node_id); uint8_t result[17]; - mf_put64(result, strtoull(req->ch_id, NULL, 10)); mf_put64(result + 8, req->node_id); result[16] = rc == 0; + mf_put64(result, chat_channel_id(req->ch_id)); mf_put64(result + 8, req->node_id); result[16] = rc == 0; chat_event_post(req->inst, CHAT_EVT_RADIO_MUTE_RESULT, result, sizeof(result)); u_free(req); } @@ -309,8 +309,8 @@ void chat_core_toggle_member_radio_mute_trampoline(void* arg) { void chat_core_request_member_list_trampoline(void* arg) { struct chat_member_list_req* req = (struct chat_member_list_req*)arg; - if (!req || !req->inst || !CC(req->inst) || !CC(req->inst)->initialized) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member list request rejected: chat unavailable"); u_free(arg); return; + if (!req || !req->inst || !CC(req->inst) || !CC(req->inst)->initialized || !chat_channel_id(req->ch_id)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member list: stopped chat/invalid request"); u_free(arg); return; } struct UTUN_INSTANCE* inst = req->inst; char ch_id[64]; strncpy(ch_id, req->ch_id, sizeof(ch_id) - 1); ch_id[sizeof(ch_id) - 1] = '\0'; @@ -350,8 +350,8 @@ void chat_core_request_member_list_trampoline(void* arg) { void chat_core_request_member_rtt_trampoline(void* arg) { struct chat_member_list_req* req = (struct chat_member_list_req*)arg; - if (!req || !req->inst || !CC(req->inst) || !CC(req->inst)->initialized) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request rejected: chat unavailable"); u_free(arg); return; + if (!req || !req->inst || !CC(req->inst) || !CC(req->inst)->initialized || !chat_channel_id(req->ch_id)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member RTT: stopped chat/invalid request"); u_free(arg); return; } struct UTUN_INSTANCE* inst = req->inst; char ch_id[64]; strncpy(ch_id, req->ch_id, sizeof(ch_id) - 1); ch_id[sizeof(ch_id) - 1] = '\0'; @@ -389,36 +389,20 @@ void chat_core_request_member_rtt_trampoline(void* arg) { static void on_member_props_changed(uint64_t node_id, const char* adm_tags, const char* channel_id, void* arg) { struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; - struct chat_core_ctx* cc = CC(inst); + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; (void)adm_tags; - if (!cc || !cc->initialized || !cc->inst || !cc->db || !channel_id || !channel_id[0]) return; - - char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); - char sql[256]; snprintf(sql, sizeof(sql), "SELECT 1 FROM \"%s\" WHERE node_id=?", peers_tbl); - - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (sqlite3_step(st) == SQLITE_ROW) { - sqlite3_finalize(st); - size_t cl = strlen(channel_id); - uint8_t evt[1 + 64 + 1 + CHAT_MEMBER_DISPLAY_SIZE]; - evt[0] = (uint8_t)cl; - memcpy(evt + 1, channel_id, cl); - evt[1 + cl] = 1; - if (chat_core_get_single_member(inst, channel_id, node_id, evt + 1 + cl + 1) == 0) { - const uint8_t* member = evt + 1 + cl + 1; - DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "chat_member: update ch=%s node=%016llx online=%u self=%u radio=%u", - channel_id, (unsigned long long)node_id, member[8], member[9], !!(member[10] & CHAT_MEMBER_FLAG_RADIO)); - chat_event_post(inst, CHAT_EVT_MEMBER_UPDATED, evt, 1 + (int)cl + 1 + CHAT_MEMBER_DISPLAY_SIZE); - } else { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "chat_member: cannot build update ch=%s node=%016llx", - channel_id, (unsigned long long)node_id); - } - return; - } - sqlite3_finalize(st); - } + if (!cc || !cc->initialized) return; + if (!chat_channel_id(channel_id)) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member update: invalid channel"); return; } + uint8_t* wire = NULL; int count = 0; + if (member_query(inst, channel_id, 1, node_id, &wire, &count) < 0) return; + if (!count) { u_free(wire); return; } + size_t cl = strlen(channel_id); uint8_t evt[1 + 64 + 1 + CHAT_MEMBER_DISPLAY_SIZE]; + evt[0] = (uint8_t)cl; memcpy(evt + 1, channel_id, cl); evt[1 + cl] = 1; + memcpy(evt + cl + 2, wire, CHAT_MEMBER_DISPLAY_SIZE); u_free(wire); + const uint8_t* member = evt + cl + 2; + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "chat_member: update ch=%s node=%016llx online=%u self=%u radio=%u", + channel_id, (unsigned long long)node_id, member[8], member[9], !!(member[10] & CHAT_MEMBER_FLAG_RADIO)); + chat_event_post(inst, CHAT_EVT_MEMBER_UPDATED, evt, (int)cl + 2 + CHAT_MEMBER_DISPLAY_SIZE); } static void on_member_route_changed(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* node, void* arg) { diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index ad5fa633e..a461a8481 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -2,8 +2,11 @@ * chat_member.h — единая структура мембера для отображения в GUI * * Используется chatgui (desktop), headless CLI, Android. - * Live-снимки для всех потребителей формирует chat_core_get_member_list() в uasync-потоке. + * Бинарные снимки для GUI/JNI формирует chat_core_get_member_list() в uasync-потоке. + * Расширенная JSON-интроспекция с ключами/адресами описана отдельно в chat_core.h. * Снимок содержит свойства участника и состояние маршрута именно его CHAT-группы. + * Все вызовы — в uasync-потоке. ch_id — канонический десятичный ID1..INT64_MAX. + * В список входят self,placeholders и deleted;deleted показывается offline,self обычно online. * * Формат на проводе (ручная сериализация, фиксированный размер 164 байта): * [0-7] node_id uint64_t LE @@ -48,32 +51,32 @@ extern "C" { #define CHAT_MEMBER_DISPLAY_SIZE 164 -/* Получить всех мемберов канала в uasync. out/count обязательны; 0 — успех, -1 с логом. - * out — собственный u_malloc-массив count*CHAT_MEMBER_DISPLAY_SIZE, освобождается u_free; - * пустой список даёт NULL/0. При корректных out/count ошибка даёт NULL/0. Порядок: online первыми, затем uint64 node_id. - * Это API не ограничено 16-битным count GUI-события; рост массива ограничен размером u_realloc. */ +/* Снять полный список:0 — успех,-1 — ошибка/более65535 записей. Обязательные out/count сбрасываются в NULL/0. + * out — u_malloc-массив count*CHAT_MEMBER_DISPLAY_SIZE байт,освобождает caller через u_free;пустой список — NULL/0. + * Порядок:online первыми,затем unsigned node_id по возрастанию. SQL/память не дают частичный успешный результат. */ int chat_core_get_member_list(struct UTUN_INSTANCE* inst, const char* ch_id, uint8_t** out, int* count); -/* Получить одного мембера по node_id. out = буфер размером CHAT_MEMBER_DISPLAY_SIZE. */ +/* Снять одного участника:0/-1 (ошибка/нет строки). out — обязательный буфер CHAT_MEMBER_DISPLAY_SIZE байт; + * при отказе не меняется. Формирование полей совпадает с полным списком. */ int chat_core_get_single_member(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id, uint8_t* out); /* Live-онлайн: маршрутизатор располагает живым путём в группе канала (self всегда онлайн). - Не читает nodes.online — статус не хранится в БД. */ + Не читает nodes.online и не проверяет членство/deleted;это отдельная проверка транспорта,self всегда1. NULL/неверный ID дают0. */ int chat_core_member_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id); -/* Сериализовать из внутренней структуры в wire-формат */ +/* Внутренний снимок:поля далее кодируются вручную,struct-layout в wire не передаётся. */ struct chat_member_display { - uint64_t node_id; - uint8_t online; - uint8_t is_self; - uint8_t flags; - uint16_t rtt; - uint8_t nat_type; - uint8_t storage; - char name[64]; - uint64_t avatar_server; - char avatar_file[70]; - uint8_t storage_role; + uint64_t node_id; /* ID участника. */ + uint8_t online; /* Живой путь/self,сброшен для deleted. */ + uint8_t is_self; /* Совпадение с node_id instance. */ + uint8_t flags; /* CHAT_MEMBER_FLAG_*;offline не несёт phone known/busy. */ + uint16_t rtt; /* Суммарный RTT выбранного пути,0.1мс;UINT16_MAX — неизвестен/насыщен. */ + uint8_t nat_type; /* Агрегированный ADDR_TYPE_* адресов узла;255 — адресов нет. */ + uint8_t storage; /* Локальный приоритет хранения,отдельно от подписанной роли. */ + char name[64]; /* Local nick,имя nodes или0xNNNN;до63 байт+NUL. */ + uint64_t avatar_server; /* Сервер файлового аватара,0 — ссылки нет. */ + char avatar_file[70];/* Проверенный sha256.json+NUL. */ + uint8_t storage_role; /* source=0,не deleted,adm_tags.storage=yes и64-байтовая подпись owner-блока;доверие записи — у member_sync. */ uint8_t verified; /* административное подтверждение участника */ uint8_t node_type; /* классификация узла, не состояние маршрута */ uint8_t conn_presence; /* известные виды подключения NCONN_* */ @@ -81,22 +84,29 @@ struct chat_member_display { uint16_t intermediaries; /* число посредников выбранного живого пути */ }; +/* Копирование фиксированного wire164:src/dst обязательны,caller обеспечивает размеры. + * Serialize завершает/дополняет NUL поля name/avatar_file;NULL только логируется. */ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* dst); +/* Deserialize читает ровно164 доступных байта;0/-1 при NULL,длину/семантику полей проверяет caller. */ int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst); /* Трамплин для gui_bridge, только uasync: принимает собственный u_malloc-блок arg и освобождает его. - * ch_id — NUL-строка до 63 байт. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535). + * ch_id — канонический ID+NUL. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535), записи wire164 LE. * Непредставимый count/ошибка чата/памяти логируются, события нет; ложный/усечённый снимок не отправляется. */ struct chat_member_list_req { struct UTUN_INSTANCE* inst; char ch_id[64]; }; void chat_core_request_member_list_trampoline(void* arg); /* Владение/поток/аргумент как у list trampoline. Для каждого участника — один полный - * CHAT_EVT_MEMBER_UPDATED, включая остальные live-поля. Пустой список не даёт событий. */ + * CHAT_EVT_MEMBER_UPDATED, включая остальные live-поля. Пустой список не даёт событий. + * Это снимок актуального состояния без запуска новых сетевых измерений; inst заимствован до выполнения. */ void chat_core_request_member_rtt_trampoline(void* arg); -/* Локальный переключатель рации; каждый запрос меняет актуальное состояние в uasync. */ +/* Локальный переключатель рации другого участника:0 — committed,-1 — отказ,чужая транзакция запрещена. + * Снимок готовится до изменения;после radio commit обновлённый флаг приходит MEMBER_UPDATED без нового SQL-чтения. + * Ошибка сохраняет настройку/кэш/звук и не выдаёт успешное событие. */ int chat_core_toggle_member_radio_mute(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t node_id); struct chat_member_radio_mute_req { struct UTUN_INSTANCE* inst; char ch_id[64]; uint64_t node_id; }; +/* Забирает u_malloc-запрос;inst заимствован. Ответ — RADIO_MUTE_RESULT(group,node,success),включая отказ. */ void chat_core_toggle_member_radio_mute_trampoline(void* arg); /* Подписки свойств/маршрутов → MEMBER_UPDATED; вызываются в uasync при init/destroy чата. */ @@ -117,7 +127,8 @@ struct chat_member_detail_req { * hop_count:1, link_count:1, sock_count:1; далее hops:8*, links:64*, socks:48*. * transport_flags: bit0=conn_present, bit1=links_up, bit2=initialized; не доступность в группе. * hops — список выбранного пути от назначения к первому посреднику; у offline/self пустой. - * Ошибка содержит только заголовок. + * Ошибка содержит только заголовок;INVALID допускает нулевые поля неверного исходного запроса. + * Счётчики own_tcp и ссылок сокета насыщаются на65535;списки ограничены16 элементами каждый. * link: tcp:1,state:1,status:1,ready:1,server:1,local_family:1,local_addr:16,local_port:2, * remote_family:1,remote_addr:16,remote_port:2,socket_name:20,reality:1. * sock: id:4,port:2,links_up:2,links_down:2,ifname:38. Строки завершаются NUL. */ diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index 786efe07b..6dee928cc 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -29,6 +29,8 @@ #include #include +static void media_queue_channel_backfill(struct UTUN_INSTANCE* inst, const char* channel); + chat_whisper_trigger_fn g_chat_whisper_trigger = NULL; /* forward decl */ @@ -328,10 +330,17 @@ static void on_media_registered(void* arg, int err, const struct media_index_res mctx->content_type, (int)full_data_len, full_data, mctx->reply_hex[0] ? ",\"reply_to\":\"" : "", mctx->reply_hex, mctx->reply_hex[0] ? "\"" : ""); + size_t json_len = strlen(json); + if (json_len > DB_SYNC_MESSAGE_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media manifest exceeds history limit ch=%s blocks=%d bytes=%zu limit=%u", + mctx->channel_id, nb, json_len, (unsigned)DB_SYNC_MESSAGE_MAX); + u_free(json); u_free(full_data); media_submit_finish(mctx, 0); return; + } + uint64_t ts = db_sync_next_timestamp(si); struct db_message_revision revision = {.ver = 1}; uint8_t json_sig[64], message_id[32]; if (RAND_bytes(revision.nonce, sizeof(revision.nonce)) != 1 || - db_sync_sign_message(db_sync_instance_group_id(si), ts, cc->my_node_id, &revision, json, strlen(json), + db_sync_sign_message(db_sync_instance_group_id(si), ts, cc->my_node_id, &revision, json, json_len, mctx->inst->my_ed25519_privkey, json_sig) < 0 || chat_message_id(db_sync_instance_group_id(si), ts, cc->my_node_id, revision.nonce, message_id) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 sign JSON failed", CC_ID); @@ -353,7 +362,7 @@ static void on_media_registered(void* arg, int err, const struct media_index_res u_free(json); u_free(full_data); media_submit_finish(mctx, 0); return; } } - int ret = db_sync_insert_signed(si, json, strlen(json), json_sig, 64, ts, attrs, &revision); + int ret = db_sync_insert_signed(si, json, json_len, json_sig, 64, ts, attrs, &revision); if (ret != 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media insert failed ret=%d", CC_ID, ret); } else { @@ -750,7 +759,11 @@ static int msg_read_id(struct chat_core_ctx* cc, const char* channel, int64_t id const void* key = sqlite3_column_blob(st, 0); if (key) { memcpy(message_id, key, 32); result = 0; } } - sqlite3_finalize(st); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media identity finalize failed ch=%s id=%lld rc=%d", channel, (long long)id, final_rc); + result = -1; + } if (result) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media identity missing ch=%s id=%lld rc=%d: %s", channel, (long long)id, rc, sqlite3_errmsg(cc->db)); return result; @@ -772,7 +785,8 @@ static void md_download_done_cb(void* arg, int err) { struct chat_core_ctx* cc = CC(ctx->inst); if (!err) { if (!strcmp(ctx->content_type, ENCRYPTED_ATTACHMENT_TYPE)) { - chat_attachment_downloaded(ctx->inst, ctx->channel_id, ctx->message_id); + if (chat_attachment_downloaded(ctx->inst, ctx->channel_id, ctx->message_id, 1)) + media_queue_channel_backfill(ctx->inst, ctx->channel_id); } char attrs[1024]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", ctx->dest_relpath); @@ -976,7 +990,7 @@ static int md_start_download(struct UTUN_INSTANCE* inst, md_download_done_cb, ctx, md_download_progress_cb, ctx); media_index_result_free(&result); if (started < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media download rejected ch=%s path=%s", ch_id, dest); u_free(ctx); + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media download request declined ch=%s path=%s; see download reason", ch_id, dest); u_free(ctx); } return started; } @@ -1016,14 +1030,17 @@ void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], chat_event_post(inst, CHAT_EVT_MSG_UPDATED, evt, 1 + size + 32); return; } - if (chat_attachment_observe(inst, ch_id, message_id, author, data, len)) { - DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted media: invalid manifest ch=%s", ch_id); return; + int attachment_result = chat_attachment_observe(inst, ch_id, message_id, author, data, len); + if (attachment_result) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted media: manifest indexing deferred/rejected ch=%s result=%d", ch_id, attachment_result); + if (attachment_result == -1) media_queue_channel_backfill(inst, ch_id); } chat_feedback_check_message(inst, ch_id, message_id); uint8_t evt[73]; uint8_t cl = (uint8_t)strlen(ch_id); evt[0] = cl; memcpy(evt + 1, ch_id, cl); memcpy(evt + 1 + cl, &author, 8); evt[1 + cl + 8] = initial_sync != 0; chat_event_post(inst, CHAT_EVT_MSG_RECEIVED, evt, 1 + cl + 9); + if (attachment_result) return; // История уже committed; ошибка производного индекса не отменяет её уведомление. /* Медиа live-PUSH загружается сразу; записи из Merkle-страниц — через backfill после сверки истории. */ if (initial_sync && data && len > 0 && author != cc->my_node_id) { @@ -1090,8 +1107,10 @@ void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], /* ─── manual attachment download (called from JNI bridge via uasync post) ─── */ void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* channel_id, int64_t msg_id) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !channel_id || msg_id <= 0) return; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !channel_id || msg_id <= 0 || !sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "manual media download: invalid state/arguments or external transaction"); return; + } char tbl[80]; msg_table_name(channel_id, tbl, sizeof(tbl)); char sql[256]; snprintf(sql, sizeof(sql), "SELECT data, timestamp, author_signature, node_id FROM \"%s\" WHERE id=?", tbl); @@ -1112,18 +1131,21 @@ void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* chann const uint8_t* sig_blob = (const uint8_t*)sqlite3_column_blob(st, 2); int sig_len = sqlite3_column_bytes(st, 2); int64_t author_node_id = sqlite3_column_int64(st, 3); - if (!jdata || jlen <= 0 || !sig_blob || sig_len != 64) { + if (!jdata || jlen <= 0 || jlen > DB_SYNC_MESSAGE_MAX || !sig_blob || sig_len != 64) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "%s: download bad msg data ch=%s id=%lld", CC_ID, channel_id, (long long)msg_id); sqlite3_finalize(st); return; } const char* ds = strstr((const char*)jdata, "\"d\":\""); - if (!ds) { sqlite3_finalize(st); return; } + if (!ds) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "manual media download: missing body ch=%s", channel_id); sqlite3_finalize(st); return; } const char* d_start = ds + 5; - char body[4096]; size_t bi = 0; - while (*d_start && *d_start != '\"' && bi < sizeof(body) - 1) body[bi++] = *d_start++; - body[bi] = '\0'; + const char* d_end = strchr(d_start, '"'); + if (!d_end || d_end - d_start > DB_SYNC_MESSAGE_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "manual media download: invalid body length ch=%s", channel_id); sqlite3_finalize(st); return; + } + size_t bi = (size_t)(d_end - d_start); char body[DB_SYNC_MESSAGE_MAX + 1]; + memcpy(body, d_start, bi); body[bi] = 0; char content_type[32] = {0}; { @@ -1140,20 +1162,40 @@ void chat_core_attachment_download(struct UTUN_INSTANCE* inst, const char* chann extract_base_filename(body, base_filename, sizeof(base_filename)); uint8_t author_sig[64]; memcpy(author_sig, sig_blob, 64); - sqlite3_finalize(st); + uint8_t message[32]; int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE); + if (encrypted_attachment) { + int observed = msg_read_id(cc, channel_id, msg_id, message) ? -1 : + chat_attachment_observe(inst, channel_id, message, (uint64_t)author_node_id, (const char*)jdata, (size_t)jlen); + if (observed) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "manual encrypted download: cannot index manifest ch=%s result=%d", channel_id, observed); + if (observed == -1) media_queue_channel_backfill(inst, channel_id); + sqlite3_finalize(st); return; + } + } + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "manual media download: history finalize failed ch=%s rc=%d", channel_id, final_rc); return; + } - if (!strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE)) { - uint8_t message[32]; - if (msg_read_id(cc, channel_id, msg_id, message)) return; - chat_attachment_want(inst, channel_id, message); + if (encrypted_attachment) { + if (chat_attachment_want(inst, channel_id, message)) return; sqlite3_stmt* encrypted = NULL; - if (sqlite3_prepare_v2(cc->db, "SELECT cipher_path FROM attachment_keys WHERE channel=? AND message_id=? AND downloaded=1", - -1, &encrypted, NULL) == SQLITE_OK) { - sqlite3_bind_text(encrypted, 1, channel_id, -1, SQLITE_STATIC); sqlite3_bind_blob(encrypted, 2, message, 32, SQLITE_STATIC); - int present = sqlite3_step(encrypted) == SQLITE_ROW && ma_file_size((const char*)sqlite3_column_text(encrypted, 0)) > 0; - sqlite3_finalize(encrypted); - if (present) { chat_attachment_downloaded(inst, channel_id, message); return; } - } else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted media: local file lookup failed: %s", sqlite3_errmsg(cc->db)); + int rc = sqlite3_prepare_v2(cc->db, "SELECT cipher_path FROM attachment_keys WHERE channel=? AND message_id=? AND downloaded=1", + -1, &encrypted, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(encrypted, 1, channel_id, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(encrypted, 2, message, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(encrypted); + const char* path = rc == SQLITE_ROW ? (const char*)sqlite3_column_text(encrypted, 0) : NULL; + int present = path && ma_file_size(path) > 0; + int finish = sqlite3_finalize(encrypted); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || finish != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted media: local file lookup failed ch=%s step=%d finalize=%d db=%s", + channel_id, rc, finish, sqlite3_errmsg(cc->db)); return; + } + if (present) { + if (chat_attachment_downloaded(inst, channel_id, message, 0)) media_queue_channel_backfill(inst, channel_id); + return; + } } /* сбрасываем пометку «файл есть»: файл мог быть вычищен кешем, а local_attrs ещё "fl". @@ -1194,7 +1236,7 @@ void chat_media_backfill(struct UTUN_INSTANCE* inst) { char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); char sql[256]; - snprintf(sql, sizeof(sql), "SELECT data FROM \"%s\"", tbl); + snprintf(sql, sizeof(sql), "SELECT data,message_id,node_id FROM \"%s\"", tbl); sqlite3_stmt* ms = NULL; if (sqlite3_prepare_v2(cc->db, sql, -1, &ms, NULL) != SQLITE_OK) continue; @@ -1227,6 +1269,22 @@ void chat_media_backfill(struct UTUN_INSTANCE* inst) { content_type[i] = '\0'; } + int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE); + uint8_t message[32]; + if (encrypted_attachment) { + if (sqlite3_column_bytes(ms, 1) != 32 || !sqlite3_column_blob(ms, 1)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media startup: missing encrypted message identity ch=%s", ch_id); + media_index_result_free(&result); u_free(body); continue; + } + memcpy(message, sqlite3_column_blob(ms, 1), 32); + int observed = chat_attachment_observe(inst, ch_id, message, (uint64_t)sqlite3_column_int64(ms, 2), jdata, + (size_t)sqlite3_column_bytes(ms, 0)); + if (observed) { + if (observed == -1) media_queue_channel_backfill(inst, ch_id); + media_index_result_free(&result); u_free(body); continue; + } + } + char base_filename[256]; extract_base_filename(body, base_filename, sizeof(base_filename)); char final_name[256]; @@ -1244,6 +1302,8 @@ void chat_media_backfill(struct UTUN_INSTANCE* inst) { int registered = media_index_commit(cc->db, &result, cc->my_node_id, NULL, ch_id, path, media_base); if (!registered) total += result.num_blocks; else DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill registration failed ch=%s file=%s", ch_id, path); + if (encrypted_attachment && !registered && chat_attachment_downloaded(inst, ch_id, message, 0)) + media_queue_channel_backfill(inst, ch_id); u_free(body); media_index_result_free(&result); @@ -1296,7 +1356,7 @@ static void bf_collect(struct chat_core_ctx* cc, const char* media_base, const c char sql[256]; snprintf(sql, sizeof(sql), - "SELECT id, timestamp, node_id, data, author_signature, local_attrs FROM \"%s\"", tbl); + "SELECT id, timestamp, node_id, data, author_signature, local_attrs, message_id FROM \"%s\"", tbl); sqlite3_stmt* ms = NULL; if (sqlite3_prepare_v2(cc->db, sql, -1, &ms, NULL) != SQLITE_OK) continue; @@ -1333,6 +1393,21 @@ static void bf_collect(struct chat_core_ctx* cc, const char* media_base, const c content_type[i] = '\0'; } + int encrypted_attachment = !strcmp(content_type, ENCRYPTED_ATTACHMENT_TYPE); + uint8_t message[32]; + if (encrypted_attachment) { + if (sqlite3_column_bytes(ms, 6) != 32 || !sqlite3_column_blob(ms, 6)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: missing encrypted message identity ch=%s", ch_id); + media_index_result_free(&result); u_free(body); continue; + } + memcpy(message, sqlite3_column_blob(ms, 6), 32); + int observed = chat_attachment_observe(cc->inst, ch_id, message, author, jdata, (size_t)sqlite3_column_bytes(ms, 3)); + if (observed) { + if (observed == -1) media_queue_channel_backfill(cc->inst, ch_id); + media_index_result_free(&result); u_free(body); continue; + } + } + char base_filename[256]; extract_base_filename(body, base_filename, sizeof(base_filename)); @@ -1346,6 +1421,8 @@ static void bf_collect(struct chat_core_ctx* cc, const char* media_base, const c snprintf(full_path, sizeof(full_path), "%s/media/%s/%s", media_base, ch_id, final_name); int64_t actual = ma_file_size(full_path); if (actual < 0) actual = 0; + if (encrypted_attachment && actual > 0 && chat_attachment_downloaded(cc->inst, ch_id, message, 0)) + media_queue_channel_backfill(cc->inst, ch_id); if (l->count == l->cap) { int nc = l->cap ? l->cap * 2 : 64; @@ -1830,26 +1907,40 @@ static void media_startup_backfill_done_cb(struct DB_SYNC_INSTANCE* si, uint64_t static void media_channel_backfill_timer_cb(void* arg) { struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; struct chat_core_ctx* cc = CC(inst); + if (!cc) return; cc->media_channel_backfill_timer = NULL; - for (int i = 0; i < cc->media_channel_backfill_pending; i++) - chat_media_autodownload_backfill_channel(inst, cc->media_channel_backfill[i]); + char channels[CHAT_MEDIA_BACKFILL_CHANNELS][sizeof(cc->media_channel_backfill[0])]; int count = cc->media_channel_backfill_pending; + memcpy(channels, cc->media_channel_backfill, (size_t)count * sizeof(channels[0])); cc->media_channel_backfill_pending = 0; + for (int i = 0; i < count; i++) chat_media_autodownload_backfill_channel(inst, channels[i]); +} + +/* Один ограниченный batch; отказ индекса во время scan ставит следующий timer, не меняя текущий batch. */ +static void media_queue_channel_backfill(struct UTUN_INSTANCE* inst, const char* ch_id) { + struct chat_core_ctx* cc = CC(inst); + if (!cc || !cc->initialized || !cc->media_backfill_inst) return; + for (int i = 0; i < cc->media_channel_backfill_pending; i++) + if (strcmp(cc->media_channel_backfill[i], ch_id) == 0) goto schedule; + if (cc->media_channel_backfill_pending == (int)(sizeof(cc->media_channel_backfill) / sizeof(cc->media_channel_backfill[0]))) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "media backfill: channel batch full, scheduling complete scan ch=%s", ch_id); + if (!cc->media_backfill_timer) cc->media_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst, + media_startup_backfill_timer_cb, "chat_media_bl"); + if (!cc->media_backfill_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: complete scan timer allocation failed"); + return; + } + snprintf(cc->media_channel_backfill[cc->media_channel_backfill_pending++], sizeof(cc->media_channel_backfill[0]), "%s", ch_id); +schedule: + if (!cc->media_channel_backfill_timer) cc->media_channel_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst, + media_channel_backfill_timer_cb, "chat_md_chbl"); + if (!cc->media_channel_backfill_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "media backfill: channel timer allocation failed ch=%s", ch_id); + else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "media backfill: channel queued ch=%s pending=%d", ch_id, cc->media_channel_backfill_pending); } static void media_channel_backfill_done_cb(struct DB_SYNC_INSTANCE* si, uint64_t peer_node_id, void* arg) { - struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; - struct chat_core_ctx* cc = CC(inst); (void)peer_node_id; - if (!cc || !si) return; + if (!si) return; char ch_id[32]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)db_sync_instance_group_id(si)); - for (int i = 0; i < cc->media_channel_backfill_pending; i++) - if (strcmp(cc->media_channel_backfill[i], ch_id) == 0) return; - if (cc->media_channel_backfill_pending < (int)(sizeof(cc->media_channel_backfill) / sizeof(cc->media_channel_backfill[0]))) - snprintf(cc->media_channel_backfill[cc->media_channel_backfill_pending++], - sizeof(cc->media_channel_backfill[0]), "%s", ch_id); - if (cc->media_channel_backfill_timer) uasync_cancel_timeout(inst->ua, cc->media_channel_backfill_timer); - cc->media_channel_backfill_timer = uasync_set_timeout(inst->ua, 20000, inst, - media_channel_backfill_timer_cb, "chat_md_chbl"); + media_queue_channel_backfill(arg, ch_id); } void chat_media_startup_backfill_init(struct UTUN_INSTANCE* inst) { diff --git a/src/chat/chat_poll.c b/src/chat/chat_poll.c index 51f72baaf..e235042fd 100644 --- a/src/chat/chat_poll.c +++ b/src/chat/chat_poll.c @@ -11,6 +11,8 @@ #include #include +#define POLL_COMMITTED_ACK_SIZE 192 + /* Канал — канонический положительный ID, подходящий для имени таблицы. */ static int poll_channel(struct UTUN_INSTANCE* inst, const char* channel, uint64_t* id) { if (!inst || !inst->topo_sqlite_db || !channel || !channel[0] || channel[0] == '0' || strlen(channel) > 19) goto invalid; @@ -40,11 +42,12 @@ static int poll_target(struct UTUN_INSTANCE* inst, const char* channel, const ui "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ch') END" " FROM msg_%s WHERE message_id=?", channel); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) return poll_db_error(inst, "target query"); - sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); - int step = sqlite3_step(st), result = 0; + int step = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (step == SQLITE_OK) step = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + if (step == SQLITE_OK) step = sqlite3_step(st); + int result = 0; if (step == SQLITE_DONE) result = 1; - else if (step != SQLITE_ROW) result = poll_db_error(inst, "target read"); + else if (step != SQLITE_ROW) result = -1; else { const char* type = (const char*)sqlite3_column_text(st, 0); if (type && !strcmp(type, "poll")) { @@ -64,7 +67,12 @@ static int poll_target(struct UTUN_INSTANCE* inst, const char* channel, const ui if (result < 0) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid signed poll ch=%s", channel); } } - sqlite3_finalize(st); return result; + int final = sqlite3_finalize(st); + if ((step != SQLITE_ROW && step != SQLITE_DONE) || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: target query failed ch=%s step=%d finalize=%d db=%s", + channel, step, final, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; + } + return result; } /* Семантика ответа не влияет на сохранение подписанных байтов и Merkle-корень. */ @@ -117,8 +125,12 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons if (strbuf_addf(&replies, "[") < 0) goto done; char sql[256]; snprintf(sql, sizeof(sql), "SELECT node_id,kind,ver,data FROM feedback_%s WHERE message_id=? ORDER BY node_id,kind", channel); - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { poll_db_error(inst, "snapshot query"); goto done; } - sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot query failed ch=%s rc=%d db=%s", channel, rc, sqlite3_errmsg(inst->topo_sqlite_db)); + goto done; + } while ((step = sqlite3_step(st)) == SQLITE_ROW) { uint64_t author = (uint64_t)sqlite3_column_int64(st, 0), ver = (uint64_t)sqlite3_column_int64(st, 2); uint8_t author_key[32]; @@ -148,6 +160,10 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons } } if (step != SQLITE_DONE) { poll_db_error(inst, "snapshot read"); goto done; } + rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot finalize failed ch=%s rc=%d", channel, rc); goto done; + } char hex[65]; for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", message_id[i]); if (strbuf_addf(&replies, "]") < 0 || strbuf_addf(&out, "{\"ch\":\"%s\",\"message_id\":\"%s\",\"pending\":%s,\"reaction_ver\":%llu,\"reactions\":[", @@ -177,7 +193,10 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons if (strbuf_addf(&out, "}") < 0) goto done; *json = strbuf_detach(&out); result = 0; done: - if (st) sqlite3_finalize(st); + if (st) { + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot cleanup failed ch=%s rc=%d", channel, final); + } strbuf_free(&replies); strbuf_free(&out); if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot failed ch=%s", channel); return result; @@ -193,8 +212,13 @@ void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel char sql[256]; snprintf(sql, sizeof(sql), "SELECT node_id,ver,data FROM feedback_%s WHERE message_id=? AND kind=2 AND length(data)>0", channel); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { poll_db_error(inst, "evaluate query"); return; } - sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); int step; unsigned accepted = 0, rejected = 0; + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) { + int final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluation query failed ch=%s rc=%d finalize=%d", channel, rc, final); return; + } + int step; unsigned accepted = 0, rejected = 0; while ((step = sqlite3_step(st)) == SQLITE_ROW) { uint8_t author_key[32]; enum ms_author_status status = member_sync_author_key(inst, channel, @@ -210,20 +234,24 @@ void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel rejected++; } else accepted++; } - sqlite3_finalize(st); - if (step != SQLITE_DONE) { poll_db_error(inst, "evaluate read"); return; } + int final = sqlite3_finalize(st); + if (step != SQLITE_DONE || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluation read failed ch=%s step=%d finalize=%d", channel, step, final); return; + } if (accepted || rejected) DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluated ch=%s valid=%u invalid=%u", channel, accepted, rejected); } /* Читает поле JSON с его типом. Отсутствующее поле имеет type=NULL. */ static sqlite3_stmt* poll_field(struct UTUN_INSTANCE* inst, const char* json, const char* path) { sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_type(?1,?2),json_extract(?1,?2)", -1, &st, NULL) != SQLITE_OK) { - poll_db_error(inst, "command field"); return NULL; - } - sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_ROW) return st; - sqlite3_finalize(st); poll_db_error(inst, "command field read"); return NULL; + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_type(?1,?2),json_extract(?1,?2)", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) return st; + int final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: field query failed path=%s step=%d finalize=%d db=%s", + path, rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); return NULL; } /* Требуем полную строку, не разрешая тихое обрезание и встроенный NUL. */ @@ -235,8 +263,9 @@ static int poll_field_text(struct UTUN_INSTANCE* inst, const char* json, const c else if (type && !strcmp(type, "text") && text && bytes < capacity && strlen(text) == bytes) { memcpy(out, text, bytes + 1); result = 0; } - sqlite3_finalize(st); - if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid command field=%s", path); + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) result = -1; + if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid command field=%s finalize=%d", path, final); return result; } @@ -244,14 +273,20 @@ static int poll_field_text(struct UTUN_INSTANCE* inst, const char* json, const c static int poll_field_ids(struct UTUN_INSTANCE* inst, const char* json, const char* path, uint8_t* ids, size_t capacity) { sqlite3_stmt* field = poll_field(inst, json, path); if (!field) return -1; const char* type = (const char*)sqlite3_column_text(field, 0); int absent = !type; - int array = type && !strcmp(type, "array"); sqlite3_finalize(field); + int array = type && !strcmp(type, "array"); int final = sqlite3_finalize(field); + if (final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: array type finalize failed field=%s rc=%d", path, final); return -1; + } if (absent) return 0; if (!array) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: expected array field=%s", path); return -1; } sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,?2)", -1, &st, NULL) != SQLITE_OK) { - poll_db_error(inst, "command array"); return -1; + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,?2)", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: array query failed field=%s rc=%d finalize=%d", path, rc, final); return -1; } - sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); int step, count = 0; while ((step = sqlite3_step(st)) == SQLITE_ROW) { const char* element = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 n = sqlite3_column_int64(st, 1); @@ -260,8 +295,10 @@ static int poll_field_ids(struct UTUN_INSTANCE* inst, const char* json, const ch } ids[count++] = (uint8_t)n; } - sqlite3_finalize(st); - if (count >= 0 && step != SQLITE_DONE) { poll_db_error(inst, "command array read"); return -1; } + final = sqlite3_finalize(st); + if (final != SQLITE_OK || (count >= 0 && step != SQLITE_DONE)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: array read failed field=%s step=%d finalize=%d", path, step, final); return -1; + } return count; } @@ -269,8 +306,9 @@ static int poll_field_bool(struct UTUN_INSTANCE* inst, const char* json, const c sqlite3_stmt* st = poll_field(inst, json, path); if (!st) return -2; const char* type = (const char*)sqlite3_column_text(st, 0); int value = !type ? default_value : !strcmp(type, "true") ? 1 : !strcmp(type, "false") ? 0 : -2; - sqlite3_finalize(st); - if (value == -2) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: expected boolean field=%s", path); + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) value = -2; + if (value == -2) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid boolean field=%s finalize=%d", path, final); return value; } @@ -283,6 +321,15 @@ static int poll_command_error(const char* reason, char** result) { strbuf_free(&out); return -1; } +/* Ack резервируется до изменения: отказ последующего чтения не превращает commit в отказ команды. */ +static int poll_committed_snapshot(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], char* ack, char** result) { + if (!chat_feedback_snapshot(inst, channel, message, result)) { u_free(ack); return 0; } + char hex[65]; for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", message[i]); + snprintf(ack, POLL_COMMITTED_ACK_SIZE, "{\"ch\":\"%s\",\"message_id\":\"%s\",\"committed\":true,\"snapshot_pending\":true}", channel, hex); + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: command committed ch=%s message=%s; snapshot unavailable, read may be retried", channel, hex); + *result = ack; return 0; +} + /* Общий командный API исключает отдельные правила голосования в каждом интерфейсе. */ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result) { if (result) *result = NULL; @@ -290,10 +337,16 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid command arguments"); return -1; } sqlite3_stmt* valid = NULL; - if (strlen(command) > 16384 || sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_valid(?1)", -1, &valid, NULL) != SQLITE_OK) - return poll_command_error("Invalid command size", result); - sqlite3_bind_text(valid, 1, command, -1, SQLITE_STATIC); - int ok = sqlite3_step(valid) == SQLITE_ROW && sqlite3_column_int(valid, 0) == 1; sqlite3_finalize(valid); + if (strlen(command) > 16384) return poll_command_error("Invalid command size", result); + int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_valid(?1)", -1, &valid, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(valid, 1, command, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(valid); + int ok = rc == SQLITE_ROW && sqlite3_column_int(valid, 0) == 1; + int final = sqlite3_finalize(valid); + if (rc != SQLITE_ROW || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: command validation failed step=%d finalize=%d db=%s", rc, final, sqlite3_errmsg(inst->topo_sqlite_db)); + return poll_command_error("Cannot validate command", result); + } if (!ok) return poll_command_error("Malformed JSON", result); char cmd[32], channel[21], hex[65]; uint8_t message_id[32]; if (poll_field_text(inst, command, "$.cmd", cmd, sizeof(cmd), 0) < 0) return poll_command_error("Missing command", result); @@ -317,13 +370,21 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* if (custom < 0) return poll_command_error("Invalid custom answer option", result); poll.allow_custom_answer = custom; sqlite3_stmt* field = poll_field(inst, command, "$.options"); if (!field) return poll_command_error("Invalid options", result); - const char* type = (const char*)sqlite3_column_text(field, 0); ok = type && !strcmp(type, "array"); sqlite3_finalize(field); + const char* type = (const char*)sqlite3_column_text(field, 0); ok = type && !strcmp(type, "array"); + final = sqlite3_finalize(field); + if (final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: options type finalize failed rc=%d", final); + return poll_command_error("Cannot read options", result); + } if (!ok) return poll_command_error("Options must be an array", result); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,'$.options')", -1, &st, NULL) != SQLITE_OK) { - poll_db_error(inst, "options"); return poll_command_error("Cannot read options", result); + rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT type,value FROM json_each(?1,'$.options')", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, command, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + final = sqlite3_finalize(st); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: options query failed rc=%d finalize=%d", rc, final); + return poll_command_error("Cannot read options", result); } - sqlite3_bind_text(st, 1, command, -1, SQLITE_STATIC); int step; while ((step = sqlite3_step(st)) == SQLITE_ROW) { const char* value_type = (const char*)sqlite3_column_text(st, 0), *text = (const char*)sqlite3_column_text(st, 1); @@ -332,10 +393,16 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* || len <= 0 || len > CHAT_POLL_OPTION_MAX || strlen(text) != (size_t)len) { ok = 0; break; } memcpy(poll.options[poll.option_count++], text, len + 1); } - sqlite3_finalize(st); - if (!ok || step != SQLITE_DONE || chat_poll_create(inst, channel, &poll, message_id) < 0) + final = sqlite3_finalize(st); + if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: options finalize failed rc=%d", final); + if (!ok || step != SQLITE_DONE || final != SQLITE_OK) return poll_command_error("Invalid poll or publication failed", result); - return chat_feedback_snapshot(inst, channel, message_id, result); + char* ack = u_malloc(POLL_COMMITTED_ACK_SIZE); + if (!ack) return poll_command_error("Out of memory before publication", result); + if (chat_poll_create(inst, channel, &poll, message_id) < 0) { + u_free(ack); return poll_command_error("Poll publication failed", result); + } + return poll_committed_snapshot(inst, channel, message_id, ack, result); } if (poll_field_text(inst, command, "$.message_id", hex, sizeof(hex), 0) < 0 || strlen(hex) != 64 || sc_hex_to_binary(hex, message_id, 32) != 0) return poll_command_error("Invalid message ID", result); @@ -363,11 +430,13 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* const char* type = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 emoji = sqlite3_column_int64(st, 1); if (type && !strcmp(type, "text")) { const char* text = (const char*)sqlite3_column_text(st, 1); emoji = -1; - for (unsigned i = 0; text && i < CHAT_REACTION_COUNT; i++) + int bytes = sqlite3_column_bytes(st, 1); + for (unsigned i = 0; text && strlen(text) == (size_t)bytes && i < CHAT_REACTION_COUNT; i++) if (!strcmp(text, chat_reaction_emoji(i))) { emoji = i; break; } } ok = type && (!strcmp(type, "integer") || !strcmp(type, "text")) && emoji >= 0 && emoji < CHAT_REACTION_COUNT; - sqlite3_finalize(st); + final = sqlite3_finalize(st); + if (final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: emoji finalize failed rc=%d", final); ok = 0; } if (!ok) return poll_command_error("Invalid emoji", result); int selected = count == 1 && ids[0] == emoji; int on = poll_field_bool(inst, command, "$.on", !selected); @@ -377,9 +446,14 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* } bytes = chat_reactions_encode(ids, count, data, sizeof(data)); kind = CHAT_FEEDBACK_REACTION; } else return poll_command_error("Unknown interaction command", result); - if (bytes < 0 || chat_feedback_set(inst, channel, message_id, kind, data, bytes, NULL) < 0) + if (bytes < 0) return poll_command_error("Invalid state", result); + char* ack = u_malloc(POLL_COMMITTED_ACK_SIZE); + if (!ack) return poll_command_error("Out of memory before saving state", result); + if (chat_feedback_set(inst, channel, message_id, kind, data, bytes, NULL) < 0) { + u_free(ack); return poll_command_error("Cannot save state", result); - return chat_feedback_snapshot(inst, channel, message_id, result); + } + return poll_committed_snapshot(inst, channel, message_id, ack, result); } /* Ответ UI асинхронен: успех/ошибка и исходный request_id всегда идут вместе. */ diff --git a/src/chat/chat_poll.h b/src/chat/chat_poll.h index 51a4ad696..da328b171 100644 --- a/src/chat/chat_poll.h +++ b/src/chat/chat_poll.h @@ -26,17 +26,20 @@ struct UTUN_INSTANCE; #define CHAT_REACTION_SELECTED_MAX 1 struct chat_poll { - uint8_t mode, allow_custom_answer, option_count; - char question[CHAT_POLL_QUESTION_MAX + 1]; - char options[CHAT_POLL_OPTIONS_MAX][CHAT_POLL_OPTION_MAX + 1]; + uint8_t mode, allow_custom_answer, option_count; /* SINGLE/MULTIPLE,0/1,число пунктов2..32. */ + char question[CHAT_POLL_QUESTION_MAX + 1]; /* Непустой UTF-8 с NUL,лимиты в байтах. */ + char options[CHAT_POLL_OPTIONS_MAX][CHAT_POLL_OPTION_MAX + 1]; /* Непустые различные строки UTF-8 с NUL. */ }; struct chat_poll_answer { - uint8_t count, choices[CHAT_POLL_OPTIONS_MAX]; - char custom[CHAT_POLL_CUSTOM_MAX + 1]; + uint8_t count, choices[CHAT_POLL_OPTIONS_MAX]; /* Число/номера пунктов0..31;encode сортирует выход,decode требует порядок. */ + char custom[CHAT_POLL_CUSTOM_MAX + 1]; /* UTF-8 с NUL,пустая строка допустима. */ }; -/* Кодеки возвращают длину/0, либо -1. Decode требует точное каноническое представление. */ +/* Encode:длина>=0/-1;decode:0/-1. Память заимствована,выход при ошибке может быть частичным. + * Decode требует точное каноническое представление,UTF-8 без NUL и лишних байтов. + * Poll wire<=2048,не каждая комбинация пределов полей помещается;переполнение отклоняется. + * Answer/reaction с нулевой длиной — отмена;для encode out обязателен даже при отмене. */ int chat_poll_encode(const struct chat_poll* poll, uint8_t* out, size_t capacity); int chat_poll_decode(const uint8_t* data, size_t len, struct chat_poll* poll); int chat_answer_encode(const struct chat_poll_answer* answer, uint8_t* out, size_t capacity); @@ -50,18 +53,24 @@ int chat_feedback_validate_data(uint8_t kind, const uint8_t* data, size_t len); /* Публикация подписанного poll: 0/-1, out_id — общий ID сохранённого сообщения. */ int chat_poll_create(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_poll* poll, uint8_t out_id[32]); -/* Снимок сообщения и результатов: pending, poll, reactions, mine и ответы. 0/-1. */ +/* Снимок сообщения и результатов:pending(нет исходного сообщения),poll,reactions,mine и ответы. + * 0/-1;json обязателен,при успехе получает u_malloc-строку,при отказе NULL. Не меняет данные/версии. */ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], char** json); /* JSON-команды poll_create, vote, react, feedback, reaction_catalog. 0/-1. - * Ошибка возвращает JSON с error; успех — снимок или каталог. + * Command — NUL JSON<=16384байт,channel — канонический положительный decimal<=INT64_MAX. + * Ошибка возвращает JSON с error,при OOM/неверных аргументах возможен NULL;result обязателен. + * Успех — снимок/каталог либо подтверждение {ch,message_id,committed:true,snapshot_pending:true}, + * если изменение сохранено,но его чтение отказало. В этом случае повторяют только feedback, + * не poll_create/vote/react. Память подтверждения резервируется до изменения. * vote: choices:[0..31], custom:string; пустые значения отменяют голос. - * react: emoji:0..15; новая реакция заменяет прежнюю. Без on — переключить выбранную, clear:true — отменить. */ + * react:emoji:0..15 либо полная строка из каталога;новая заменяет прежнюю. + * Без on — переключить выбранную,on:false отменяет только выбранную,clear:true — любую. */ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result); /* Проверка отложенных ответов и диагностика при получении сообщения/нового состояния. */ void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32]); /* Команда из UI: req и следующая за ней NUL-строка выделены одним блоком через u_malloc. * Результат возвращается событием CHAT_EVT_FEEDBACK_RESULT и включает request_id. */ -struct chat_feedback_request { struct UTUN_INSTANCE* inst; uint64_t request_id; }; +struct chat_feedback_request { struct UTUN_INSTANCE* inst; uint64_t request_id; }; /* Заимствованный instance,ID результата UI. */ void chat_feedback_command_trampoline(void* arg); #ifdef __cplusplus diff --git a/src/chat/chat_profile.c b/src/chat/chat_profile.c index 23f04cd35..f3f590c87 100644 --- a/src/chat/chat_profile.c +++ b/src/chat/chat_profile.c @@ -24,6 +24,7 @@ #include "../../lib/platform_compat.h" #include +#include void chat_core_set_my_node_id(struct UTUN_INSTANCE* inst, uint64_t node_id) { struct chat_core_ctx* cc = CC(inst); @@ -63,20 +64,14 @@ static int profile_string(struct profile_json* p, const char* text) { if (p->used >= MS_USERINFO_MAX) return -1; p->text[p->used++] = '"'; for (const unsigned char* c = (const unsigned char*)text; *c; c++) { - char escape = 0; - if (*c == '"' || *c == '\\') escape = *c; - if (*c == '\n') escape = 'n'; - if (*c == '\r') escape = 'r'; - if (*c == '\t') escape = 't'; - if (*c == '\b') escape = 'b'; - if (*c == '\f') escape = 'f'; - if ((*c < 32 && !escape) || p->used + (escape ? 2 : 1) >= MS_USERINFO_MAX) return -1; - if (escape) p->text[p->used++] = '\\'; - p->text[p->used++] = escape ? escape : *c; + char encoded[7]; size_t bytes = 1; encoded[0] = (char)*c; + if (*c == '"' || *c == '\\') { encoded[0] = '\\'; encoded[1] = (char)*c; bytes = 2; } + else if (*c < 32) { snprintf(encoded, sizeof(encoded), "\\u%04x", *c); bytes = 6; } + if (p->used + bytes >= MS_USERINFO_MAX) return -1; + memcpy(p->text + p->used, encoded, bytes); p->used += bytes; } if (p->used >= MS_USERINFO_MAX) return -1; - p->text[p->used++] = '"'; p->text[p->used] = 0; - return 0; + p->text[p->used++] = '"'; p->text[p->used] = 0; return 0; } static int profile_pair(struct profile_json* p, const char* key, const char* value) { @@ -102,108 +97,212 @@ int chat_core_name_userinfo(const char* name, char* out, size_t capacity) { p.text[p.used++] = '}'; p.text[p.used] = 0; memcpy(out, p.text, p.used + 1); return 0; } +/* Локальная запись member_sync доверяет caller:перед правкой проверяем прежний JOIN явно. */ +static int profile_verify_join(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t join[64], uint64_t timestamp) { + uint8_t x[32], ed[32], body[112]; + if (topo_node_sqlite_channel_get(CC(inst)->db, channel, NULL, 0, NULL, x, ed, NULL)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: channel keys unavailable ch=%s", channel); return -1; + } + int bytes = member_sync_build_join_msg(x, ed, inst->node_id, inst->my_keys.public_key, timestamp, body, sizeof(body)); + if (bytes < 0 || sc_ed25519_verify(inst->my_ed25519_pubkey, body, (size_t)bytes, join) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: stored JOIN invalid ch=%s", channel); return -1; + } + return 0; +} + int chat_core_get_my_profile(struct UTUN_INSTANCE* inst, const char* channel, char* out, size_t capacity, uint8_t signature[64], uint64_t* timestamp) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->db || !channel || !out || !signature || !timestamp) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || cc->my_node_id != inst->node_id || !chat_channel_id(channel) || + !out || !capacity || !signature || !timestamp) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: invalid read request"); return -1; } char table[80], sql[256]; peers_table_name(channel, table, sizeof(table)); - snprintf(sql, sizeof(sql), "SELECT userinfo,update_sig,update_ts FROM \"%s\" WHERE node_id=?", table); - sqlite3_stmt* st = NULL; int result = -1; - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)cc->my_node_id); - if (sqlite3_step(st) == SQLITE_ROW) { - const char* json = (const char*)sqlite3_column_text(st, 0); size_t size = json ? strlen(json) : 0; - if (json && size <= MS_USERINFO_MAX && size < capacity && sqlite3_column_bytes(st, 1) == 64 && sqlite3_column_int64(st, 2) > 0) { - memcpy(out, json, size + 1); memcpy(signature, sqlite3_column_blob(st, 1), 64); - *timestamp = (uint64_t)sqlite3_column_int64(st, 2); result = 0; - } - } - sqlite3_finalize(st); + snprintf(sql, sizeof(sql), "SELECT userinfo,update_sig,update_ts,join_sig,ed25519_pubkey,source,join_ts FROM \"%s\" WHERE node_id=?", table); + sqlite3_stmt* st = NULL; const char* stage = "prepare"; + char profile[MS_USERINFO_MAX + 1]; uint8_t update[64], join[64], body[MS_USERINFO_MAX + 73]; uint64_t version, join_ts; + int rc = sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)cc->my_node_id); } + if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); } + if (rc != SQLITE_ROW) goto error; + stage = "fields"; rc = SQLITE_CORRUPT; + int json_type = sqlite3_column_type(st, 0); + int bytes = sqlite3_column_bytes(st, 0); const char* json = (const char*)sqlite3_column_text(st, 0); + const void *sig = sqlite3_column_blob(st, 1), *joined = sqlite3_column_blob(st, 3), *key = sqlite3_column_blob(st, 4); + if (json_type != SQLITE_TEXT || !json || bytes > MS_USERINFO_MAX || strlen(json) != (size_t)bytes || + sqlite3_column_type(st, 1) != SQLITE_BLOB || sqlite3_column_bytes(st, 1) != 64 || !sig || + sqlite3_column_type(st, 3) != SQLITE_BLOB || sqlite3_column_bytes(st, 3) != 64 || !joined || + sqlite3_column_type(st, 4) != SQLITE_BLOB || sqlite3_column_bytes(st, 4) != 32 || !key || memcmp(key, inst->my_ed25519_pubkey, 32) || + sqlite3_column_type(st, 2) != SQLITE_INTEGER || sqlite3_column_int64(st, 2) <= 0 || sqlite3_column_int(st, 5) || + sqlite3_column_type(st, 6) != SQLITE_INTEGER || sqlite3_column_int64(st, 6) <= 0) goto error; + if ((size_t)bytes >= capacity) { stage = "capacity"; rc = SQLITE_TOOBIG; goto error; } + memcpy(profile, json, (size_t)bytes + 1); memcpy(update, sig, 64); memcpy(join, joined, 64); + version = (uint64_t)sqlite3_column_int64(st, 2); join_ts = (uint64_t)sqlite3_column_int64(st, 6); + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + int length = member_sync_build_update_msg(join, version, profile, body, sizeof(body)); + if (length < 0 || profile_verify_join(inst, channel, join, join_ts) || + sc_ed25519_verify(inst->my_ed25519_pubkey, body, (size_t)length, update) != SC_OK || + chat_json_strings(cc->db, profile, MS_USERINFO_MAX, NULL, NULL)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: stored signature/JSON invalid channel=%s", channel); return -1; } - if (result) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: signed local profile unavailable channel=%s capacity=%zu", channel, capacity); - return result; + memcpy(out, profile, (size_t)bytes + 1); memcpy(signature, update, 64); *timestamp = version; return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: read channel=%s stage=%s rc=%d capacity=%zu: %s", channel, stage, rc, capacity, sqlite3_errmsg(cc->db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: read cleanup finalize rc=%d", rc); + return -1; } -/* Обновить профиль целиком одной подписанной записью, сохранив не затронутые поля. */ -static int profile_update_channel(struct UTUN_INSTANCE* inst, const char* ch, int avatar, uint64_t server, const char* file, - const struct chat_profile_option* options, size_t option_count) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !cc->db) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: update before initialization"); return -1; - } - uint64_t myid = cc->my_node_id; - char tbl[80]; peers_table_name(ch, tbl, sizeof(tbl)); - uint8_t join_sig[64]; uint64_t join_ts = 0; - if (topo_node_sqlite_member_get_join(cc->db, ch, myid, join_sig, &join_ts) != 0) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: own signed member missing channel=%s", ch); return -1; - } - struct profile_json profile = { .text = "{", .used = 1, .avatar = avatar, .options = options, .option_count = option_count }; - if (profile_pair(&profile, "name", inst->name) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: display name exceeds profile limit channel=%s", ch); return -1; - } - char query[256]; snprintf(query, sizeof(query), "SELECT update_ts,userinfo,update_sig FROM \"%s\" WHERE node_id=?", tbl); - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, query, -1, &st, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: query failed channel=%s: %s", ch, sqlite3_errmsg(cc->db)); return -1; - } - sqlite3_bind_int64(st, 1, (sqlite3_int64)myid); - if (sqlite3_step(st) != SQLITE_ROW) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: cannot read own member channel=%s: %s", ch, sqlite3_errmsg(cc->db)); - sqlite3_finalize(st); return -1; - } - uint64_t local_uts = (uint64_t)sqlite3_column_int64(st, 0); - int signed_profile = local_uts && sqlite3_column_bytes(st, 2) == 64 - && memcmp(sqlite3_column_blob(st, 2), (uint8_t[64]){0}, 64); - const char* old = (const char*)sqlite3_column_text(st, 1); - char before[MS_USERINFO_MAX + 1] = ""; - if (old && strlen(old) > MS_USERINFO_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: stored attributes exceed limit channel=%s", ch); - sqlite3_finalize(st); return -1; - } - if (old) memcpy(before, old, strlen(old) + 1); - if (old && old[0] && json_flat_parse(old, profile_copy_pair, &profile) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: cannot preserve attributes channel=%s", ch); - sqlite3_finalize(st); return -1; - } - sqlite3_finalize(st); - for (size_t i = 0; i < option_count; i++) { - if (options[i].value && profile_pair(&profile, options[i].key, options[i].value) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: option exceeds profile limit channel=%s key=%s", ch, options[i].key); - return -1; +struct profile_current { + uint8_t join[64], signature[64]; + uint64_t join_ts, version; + char json[MS_USERINFO_MAX + 1]; + int signed_profile; +}; + +/* 1 — свой полный member,0 — отсутствует/placeholder,-1 — ошибка. Cursor закрыт до подписи/изменения. */ +static int profile_read_current(struct UTUN_INSTANCE* inst, const char* channel, struct profile_current* out) { + sqlite3* db = CC(inst)->db; char table[80], sql[384]; peers_table_name(channel, table, sizeof(table)); + snprintf(sql, sizeof(sql), "SELECT join_sig,join_ts,update_ts,userinfo,update_sig,x25519_pubkey,ed25519_pubkey,source" + " FROM \"%s\" WHERE node_id=?", table); + sqlite3_stmt* st = NULL; const char* stage = "prepare"; int found = 0; + int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); } + if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); } + if (rc == SQLITE_DONE) goto done; + if (rc != SQLITE_ROW) goto error; + if (sqlite3_column_int(st, 7)) goto done; + stage = "fields"; rc = SQLITE_CORRUPT; + int json_type = sqlite3_column_type(st, 3); + int bytes = sqlite3_column_bytes(st, 3), sig_bytes = sqlite3_column_bytes(st, 4); + const char* json = (const char*)sqlite3_column_text(st, 3); + const void *join = sqlite3_column_blob(st, 0), *signature = sqlite3_column_blob(st, 4); + const void *x = sqlite3_column_blob(st, 5), *ed = sqlite3_column_blob(st, 6); + if (sqlite3_column_type(st, 0) != SQLITE_BLOB || sqlite3_column_bytes(st, 0) != 64 || !join || + sqlite3_column_type(st, 1) != SQLITE_INTEGER || sqlite3_column_int64(st, 1) <= 0 || + sqlite3_column_type(st, 2) != SQLITE_INTEGER || sqlite3_column_int64(st, 2) < 0 || + json_type != SQLITE_TEXT || !json || bytes > MS_USERINFO_MAX || strlen(json) != (size_t)bytes || + sqlite3_column_type(st, 5) != SQLITE_BLOB || sqlite3_column_bytes(st, 5) != 32 || !x || + sqlite3_column_type(st, 6) != SQLITE_BLOB || sqlite3_column_bytes(st, 6) != 32 || !ed || + memcmp(x, inst->my_keys.public_key, 32) || memcmp(ed, inst->my_ed25519_pubkey, 32)) goto error; + out->join_ts = (uint64_t)sqlite3_column_int64(st, 1); out->version = (uint64_t)sqlite3_column_int64(st, 2); + if (out->version) { + if (sqlite3_column_type(st, 4) != SQLITE_BLOB || sig_bytes != 64 || !signature) goto error; + memcpy(out->signature, signature, 64); out->signed_profile = 1; + } else if (sig_bytes && (sig_bytes != 64 || !signature || memcmp(signature, (uint8_t[64]){0}, 64))) goto error; + memcpy(out->json, json, (size_t)bytes + 1); memcpy(out->join, join, 64); found = 1; +done: + stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + if (found && profile_verify_join(inst, channel, out->join, out->join_ts)) return -1; + if (found && out->signed_profile) { + uint8_t body[MS_USERINFO_MAX + 73]; + int bytes = member_sync_build_update_msg(out->join, out->version, out->json, body, sizeof(body)); + if (bytes < 0 || sc_ed25519_verify(inst->my_ed25519_pubkey, body, (size_t)bytes, out->signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: stored signature invalid channel=%s", channel); return -1; } } + return found; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: current record ch=%s stage=%s rc=%d: %s", channel, stage, rc, sqlite3_errmsg(db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: current record cleanup finalize rc=%d", rc); + return -1; +} + +/* Одинаковые поля не меняют версию из-за порядка ключей или способа JSON escaping. */ +static int profile_equal(sqlite3* db, const char* a, const char* b) { + sqlite3_stmt* st = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT NOT EXISTS(SELECT key,value FROM json_each(?1) EXCEPT SELECT key,value FROM json_each(?2))" + " AND NOT EXISTS(SELECT key,value FROM json_each(?2) EXCEPT SELECT key,value FROM json_each(?1))", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, a, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 2, b, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int same = rc == SQLITE_ROW && sqlite3_column_int(st, 0), final = sqlite3_finalize(st); + if (rc != SQLITE_ROW || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: comparison rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(db)); return -1; + } + return same; +} + +/* Вызывается под собственной блокировкой;возвращает commit-флаги member_sync либо-1,без callbacks. */ +static int profile_update_locked(struct UTUN_INSTANCE* inst, const char* ch, int avatar, uint64_t server, const char* file, + const struct chat_profile_option* options, size_t option_count) { + struct profile_current current = {0}; + int found = profile_read_current(inst, ch, ¤t); + if (found <= 0) { + if (!found) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: own full member unavailable channel=%s", ch); + return -1; + } + if (avatar && server && !member_files_member(inst, strtoull(ch, NULL, 10), server, 1)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: avatar server has no storage role channel=%s", ch); return -1; + } + struct profile_json profile = {.text = "{", .used = 1, .avatar = avatar, .options = options, .option_count = option_count}; + if (profile_pair(&profile, "name", inst->name) < 0 || + (current.signed_profile && chat_json_strings(CC(inst)->db, current.json, MS_USERINFO_MAX, profile_copy_pair, &profile))) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: cannot preserve/encode signed attributes channel=%s", ch); return -1; + } + for (size_t i = 0; i < option_count; i++) if (options[i].value && profile_pair(&profile, options[i].key, options[i].value) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: option exceeds limit channel=%s index=%zu", ch, i); return -1; + } if (avatar && server) { char id[17]; snprintf(id, sizeof(id), "%016llx", (unsigned long long)server); if (profile_pair(&profile, "avatar_server", id) < 0 || profile_pair(&profile, "avatar_file", file) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: avatar exceeds profile limit channel=%s", ch); return -1; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: avatar exceeds limit channel=%s", ch); return -1; } } - if (profile.used >= MS_USERINFO_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: attributes exceed profile limit channel=%s", ch); return -1; - } + if (profile.used >= MS_USERINFO_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: attributes exceed limit channel=%s", ch); return -1; } profile.text[profile.used++] = '}'; profile.text[profile.used] = 0; - if (signed_profile && !strcmp(before, profile.text)) { - DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "profile: unchanged channel=%s name=%s ts=%llu", ch, inst->name, - (unsigned long long)local_uts); return 0; + if (current.signed_profile) { + int equal = profile_equal(CC(inst)->db, current.json, profile.text); + if (equal < 0) return -1; + if (equal) { DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "profile: unchanged channel=%s ts=%llu", ch, (unsigned long long)current.version); return 0; } } - if (local_uts >= INT64_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: version exhausted channel=%s", ch); return -1; } - time_t seconds = ntp_time_get_seconds(inst); - uint64_t now = seconds > 0 ? (uint64_t)seconds : 0; - uint64_t update_ts = now > local_uts ? now : local_uts + 1; - uint8_t umsg[MS_USERINFO_MAX + 73], update_sig[64]; - int ulen = member_sync_build_update_msg(join_sig, update_ts, profile.text, umsg, sizeof(umsg)); - if (ulen < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: cannot build signature channel=%s", ch); return -1; } - if (sc_ed25519_sign(inst->my_ed25519_privkey, umsg, ulen, update_sig) != SC_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: signature failed channel=%s", ch); return -1; + if (current.version == INT64_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: version exhausted channel=%s", ch); return -1; } + time_t seconds = ntp_time_get_seconds(inst); uint64_t now = seconds > 0 ? (uint64_t)seconds : 0; + uint64_t version = now > current.version ? now : current.version + 1; + uint8_t body[MS_USERINFO_MAX + 73], signature[64]; + int bytes = member_sync_build_update_msg(current.join, version, profile.text, body, sizeof(body)); + if (bytes < 0 || sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: signing failed channel=%s", ch); return -1; } - int rc = member_sync_put(inst, ch, myid, inst->my_keys.public_key, inst->my_ed25519_pubkey, join_sig, join_ts, - update_sig, update_ts, profile.text, NULL, NULL, 0, 0, NULL); - if (rc < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: update failed channel=%s result=%d", ch, rc); return -1; } - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: update my member ch=%s name=%s ts=%llu", CC_ID, ch, inst->name, - (unsigned long long)update_ts); - return 0; + int changes = member_sync_put(inst, ch, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, + current.join, current.join_ts, signature, version, profile.text, NULL, NULL, 0, 0, NULL); + if (changes < 0 || !(changes & MS_APPLY_CHANGED)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: new version not saved channel=%s flags=%d", ch, changes); return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "profile: staged channel=%s ts=%llu bytes=%zu", ch, (unsigned long long)version, profile.used); + return changes; +} + +static int profile_update_channel(struct UTUN_INSTANCE* inst, const char* channel, int avatar, uint64_t server, const char* file, + const struct chat_profile_option* options, size_t option_count) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || cc->my_node_id != inst->node_id || !chat_channel_id(channel) || + !sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: inactive core/invalid channel/external transaction"); return -1; + } + char ch[20]; memcpy(ch, channel, strlen(channel) + 1); + int rc = sqlite3_exec(cc->db, "BEGIN IMMEDIATE /* profile */", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: begin ch=%s rc=%d: %s", ch, rc, sqlite3_errmsg(cc->db)); return -1; } + int changes = profile_update_locked(inst, ch, avatar, server, file, options, option_count); + if (changes > 0) { + rc = sqlite3_exec(cc->db, "COMMIT /* profile */", NULL, NULL, NULL); + if (rc == SQLITE_OK) { + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "profile: committed ch=%s node=%016llx avatar=%d", + ch, (unsigned long long)inst->node_id, avatar); + member_sync_record_committed(inst, ch, inst->node_id, (unsigned)changes); return 0; + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: commit ch=%s rc=%d: %s", ch, rc, sqlite3_errmsg(cc->db)); changes = -1; + } + if (!sqlite3_get_autocommit(cc->db)) { + rc = sqlite3_exec(cc->db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: rollback ch=%s rc=%d: %s", ch, rc, sqlite3_errmsg(cc->db)); changes = -1; } + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: transaction remains open ch=%s;requesting owner stop/DB close", ch); + utun_instance_stop(inst); changes = -1; + } + } + return changes < 0 ? -1 : 0; } /* Проверить заменяемые ключи до построения/подписи нового блока мембера. */ @@ -229,17 +328,24 @@ int chat_core_set_my_profile_options(struct UTUN_INSTANCE* inst, const char* cha } void chat_core_update_my_member(struct UTUN_INSTANCE* inst) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !cc->db) return; - sqlite3_stmt* cs = NULL; - if (sqlite3_prepare_v2(cc->db, "SELECT channel_id FROM channels", -1, &cs, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: channel query failed: %s", sqlite3_errmsg(cc->db)); return; + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !sqlite3_get_autocommit(cc->db)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: refresh requires active chat without external transaction"); return; } - while (sqlite3_step(cs) == SQLITE_ROW) { - const char* ch = (const char*)sqlite3_column_text(cs, 0); - if (ch) profile_update_channel(inst, ch, 0, 0, NULL, NULL, 0); + char** rows = NULL; int count = 0, columns = 0; + int rc = sqlite3_get_table(cc->db, "SELECT channel_id,length(CAST(channel_id AS BLOB)) FROM channels ORDER BY channel_id", + &rows, &count, &columns, NULL); + if (rc != SQLITE_OK || (count && columns != 2)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: channel scan rc=%d: %s", rc, sqlite3_errmsg(cc->db)); sqlite3_free_table(rows); return; } - sqlite3_finalize(cs); + for (int i = 0; i < count; i++) { + const char *ch = rows[(i + 1) * columns], *bytes = rows[(i + 1) * columns + 1]; + if (!chat_channel_id(ch) || !bytes || strlen(ch) != strtoul(bytes, NULL, 10)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: invalid stored channel index=%d", i); continue; + } + profile_update_channel(inst, ch, 0, 0, NULL, NULL, 0); + } + sqlite3_free_table(rows); } int chat_core_set_my_avatar(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t server, const char* file) { @@ -250,21 +356,17 @@ int chat_core_set_my_avatar(struct UTUN_INSTANCE* inst, const char* ch_id, uint6 for (int i = 0; i < 64; i++) if (!((file[i] >= '0' && file[i] <= '9') || (file[i] >= 'a' && file[i] <= 'f'))) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: invalid avatar hash"); return -1; } - uint64_t group = strtoull(ch_id, NULL, 10); - if (!member_files_member(inst, group, server, 1)) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile: avatar server has no storage role channel=%s", ch_id); return -1; - } } - int rc = profile_update_channel(inst, ch_id, 1, server, file, NULL, 0); - if (!rc) DEBUG_INFO(DEBUG_CATEGORY_CHAT, "profile: avatar published channel=%s server=%016llx file=%s", - ch_id, (unsigned long long)server, server ? file : "none"); - return rc; + return profile_update_channel(inst, ch_id, 1, server, file, NULL, 0); } void chat_core_set_my_avatar_trampoline(void* arg) { struct chat_avatar_req* q = arg; + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: null avatar request"); return; } char ch[32]; snprintf(ch, sizeof(ch), "%llu", (unsigned long long)q->group); - int rc = chat_core_set_my_avatar(q->inst, ch, q->server, q->file); + int rc = -1; + if (!memchr(q->file, 0, sizeof(q->file))) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile: unterminated avatar request"); + else rc = chat_core_set_my_avatar(q->inst, ch, q->server, q->file); uint8_t data[17]; mf_put64(data, q->id); mf_put64(data + 8, q->group); data[16] = rc ? 1 : 0; chat_event_post(q->inst, CHAT_EVT_AVATAR_RESULT, data, sizeof(data)); u_free(q); @@ -276,24 +378,60 @@ void chat_core_sync_my_addresses(struct UTUN_INSTANCE* inst) { chat_core_update_my_member(inst); } -void chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !key || !value) return; - sqlite3_stmt* st = NULL; - sqlite3_prepare_v2(cc->db, - "INSERT OR REPLACE INTO ui_state(key, value) VALUES(?, ?)", -1, &st, NULL); - if (st) { - sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); - sqlite3_bind_text(st, 2, value, -1, SQLITE_STATIC); - sqlite3_step(st); sqlite3_finalize(st); +int chat_core_save_ui_state(struct UTUN_INSTANCE* inst, const char* key, const char* value) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !cc->db || !key || !key[0] || !value || strlen(key) > INT_MAX || strlen(value) > INT_MAX || + !sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: invalid context/key/value or outer transaction"); return -1; } + int rc = sqlite3_exec(cc->db, "BEGIN IMMEDIATE /* ui_state */", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: begin rc=%d: %s", rc, sqlite3_errmsg(cc->db)); return -1; } + sqlite3_stmt* st = NULL; const char* stage = "prepare read"; int result = -1, same = 0; + rc = sqlite3_prepare_v2(cc->db, "SELECT value FROM ui_state WHERE key=?", -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind read"; rc = sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "step read"; rc = sqlite3_step(st); } + if (rc == SQLITE_ROW) { + int type = sqlite3_column_type(st, 0), bytes = sqlite3_column_bytes(st, 0); + const char* text = (const char*)sqlite3_column_text(st, 0); + if (type != SQLITE_NULL && (type != SQLITE_TEXT || !text || strlen(text) != (size_t)bytes)) { + stage = "stored value"; rc = SQLITE_CORRUPT; goto error; + } + same = text && !strcmp(text, value); + } else if (rc != SQLITE_DONE) goto error; + stage = "finalize read"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + if (same) { DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "UI state: unchanged key=%s", key); result = 0; goto done; } + stage = "prepare write"; + rc = sqlite3_prepare_v2(cc->db, "INSERT INTO ui_state(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind key"; rc = sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "bind value"; rc = sqlite3_bind_text(st, 2, value, -1, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "step write"; rc = sqlite3_step(st); } + if (rc != SQLITE_DONE) goto error; + if (sqlite3_changes(cc->db) != 1) { stage = "changes != 1"; rc = SQLITE_CONSTRAINT; goto error; } + stage = "finalize write"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; + stage = "commit"; rc = sqlite3_exec(cc->db, "COMMIT /* ui_state */", NULL, NULL, NULL); if (rc != SQLITE_OK) goto error; + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "UI state: committed key=%s bytes=%zu", key, strlen(value)); return 0; +error: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: key=%s stage=%s rc=%d: %s", key, stage, rc, sqlite3_errmsg(cc->db)); +done: + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: cleanup finalize rc=%d", rc); + if (!sqlite3_get_autocommit(cc->db)) { + rc = sqlite3_exec(cc->db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: rollback rc=%d: %s", rc, sqlite3_errmsg(cc->db)); result = -1; } + if (!sqlite3_get_autocommit(cc->db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: transaction remains open;requesting owner stop/DB close"); + utun_instance_stop(inst); result = -1; + } + } + return result; } void chat_core_save_ui_state_trampoline(void* arg) { struct save_ui_state_arg* a = (struct save_ui_state_arg*)arg; - if (!a) return; - const char* key = a->data; - const char* value = key + strlen(key) + 1; - chat_core_save_ui_state(a->inst, key, value); + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: null request"); return; } + const char* end = memchr(a->data, 0, sizeof(a->data)); + if (!end || end == a->data + sizeof(a->data) - 1 || !memchr(end + 1, 0, sizeof(a->data) - (size_t)(end + 1 - a->data))) + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "UI state: unterminated key/value request"); + else chat_core_save_ui_state(a->inst, a->data, end + 1); u_free(arg); } diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 05bd637c0..1f1364ffe 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -86,29 +86,27 @@ typedef char chat_setting_capacity_check[CHAT_SETTING_COUNT <= CHAT_SETTING_MAX_ /* ─── state helpers ─── */ static void chat_setting_reset_defaults(struct chat_setting_state* s) { - for (size_t i = 0; i < CHAT_SETTING_COUNT; i++) + memset(s, 0, sizeof(*s)); + for (size_t i = 0; i < CHAT_SETTING_COUNT; i++) { s->values[i] = g_setting_defs[i].default_val; + snprintf(s->int_text[i], sizeof(s->int_text[i]), "%d", s->values[i]); + } s->initialized = 1; } static int chat_setting_find(const char* name) { + if (!name) return -1; for (size_t i = 0; i < CHAT_SETTING_COUNT; i++) if (strcmp(g_setting_defs[i].name, name) == 0) return (int)i; return -1; } -static void chat_setting_apply(struct chat_setting_state* s) { - (void)s; - /* будет заполняться по мере появления runtime-setter'ов в модулях: - if (idx == ...) some_module_set_xxx(value); */ -} - /* ─── state-level API ─── */ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const char* value) { - if (!s || !name || !value || !*name || !*value) return -2; - - if (!s->initialized) chat_setting_reset_defaults(s); + if (!s || !name || !value || !*name) { + DEBUG_ERROR(CS_ID, "chat_setting: invalid state/name/value argument"); return -2; + } int idx = chat_setting_find(name); if (idx < 0) { @@ -122,18 +120,20 @@ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const if (def->type == CHAT_SETTING_STRING) { if (strcmp(name, "call_video_bitrate_range_kbps") == 0) { uint32_t minimum, maximum; - if (call_video_rate_parse_range(value, &minimum, &maximum)) return -2; + if (call_video_rate_parse_range(value, &minimum, &maximum)) { + DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid bitrate range '%s'", name, value); return -2; + } } if (strlen(value) >= sizeof(s->string_val[0])) { DEBUG_ERROR(CS_ID, "chat_setting: '%s' string too long bytes=%zu limit=%zu", name, strlen(value), sizeof(s->string_val[0]) - 1); return -2; } + if (!s->initialized) chat_setting_reset_defaults(s); for (int i = 0; i < s->string_count; i++) { if (s->string_idx[i] == idx) { if (strcmp(s->string_val[i], value) == 0) return 0; snprintf(s->string_val[i], sizeof(s->string_val[i]), "%s", value); DEBUG_INFO(CS_ID, "chat_setting: %s = %s", name, value); - chat_setting_apply(s); return 0; } } @@ -145,7 +145,6 @@ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const snprintf(s->string_val[s->string_count], sizeof(s->string_val[0]), "%s", value); s->string_count++; DEBUG_INFO(CS_ID, "chat_setting: %s = %s", name, value); - chat_setting_apply(s); return 0; } @@ -156,18 +155,19 @@ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const } else { char* end = NULL; long lv = strtol(value, &end, 10); - if (!end || *end != '\0' || lv < def->min_val || lv > def->max_val) { + if (end == value || *end != '\0' || lv < def->min_val || lv > def->max_val) { DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid int '%s' (range %d..%d)", name, value, def->min_val, def->max_val); return -2; } v = (int)lv; } + if (!s->initialized) chat_setting_reset_defaults(s); if (s->values[idx] == v) return 0; s->values[idx] = v; + snprintf(s->int_text[idx], sizeof(s->int_text[idx]), "%d", v); DEBUG_INFO(CS_ID, "chat_setting: %s = %s", name, value); - chat_setting_apply(s); return 0; } @@ -190,14 +190,13 @@ const char* chat_setting_get_state(const struct chat_setting_state* s, const cha return ""; } - snprintf((char*)s->int_buf, sizeof(s->int_buf), "%d", s->values[idx]); - return s->int_buf; + return s->int_text[idx]; } int chat_setting_get_int_state(const struct chat_setting_state* s, const char* name, int def) { if (!s || !s->initialized) return def; int idx = chat_setting_find(name); - return idx >= 0 ? s->values[idx] : def; + return idx >= 0 && g_setting_defs[idx].type != CHAT_SETTING_STRING ? s->values[idx] : def; } /* ─── instance-level API ─── */ @@ -208,12 +207,14 @@ static struct chat_setting_state* cs_state(struct UTUN_INSTANCE* inst) { int chat_setting_set(struct UTUN_INSTANCE* inst, const char* name, const char* value) { struct chat_setting_state* s = cs_state(inst); - if (!s) return -2; + if (!s) { DEBUG_ERROR(CS_ID, "chat_setting: instance is NULL"); return -2; } int result = chat_setting_set_state(s, name, value); if (!result && !strcmp(name, "incoming_call_policy")) call_set_phone_state(inst, inst->phone_state, (unsigned)chat_setting_get_int(inst, name, CALL_POLICY_PUBLISH_PHONE)); - if (!result && (!strcmp(name, "local_invites_enabled") || !strcmp(name, "local_broadcasts_enabled"))) - return chat_local_apply_settings(inst); + if (!result && (!strcmp(name, "local_invites_enabled") || !strcmp(name, "local_broadcasts_enabled")) && + chat_local_apply_settings(inst)) { + DEBUG_ERROR(CS_ID, "chat_setting: '%s' stored but LAN apply failed; retry setting to reapply", name); return -3; + } return result; } diff --git a/src/chat/chat_setting.h b/src/chat/chat_setting.h index 00078e514..d1401b54c 100644 --- a/src/chat/chat_setting.h +++ b/src/chat/chat_setting.h @@ -2,12 +2,13 @@ * chat_setting.h — chat-related settings (key-value API) * * Один API для всех потребителей: парсер конфига (headless) и GUI (через bridge). - * chat_setting_set(name, value) — валидация + хранение + apply к модулям. - * Возвращает 0=OK, -1=неизвестная настройка, -2=невалидное значение. + * Нулевое состояние инициализируется значениями реестра при первом успешном set. + * Ошибка валидации не меняет состояние. Чтение const, не выделяет память и не пишет в хранилище. * * Состояние — per-instance. Конфиг-парсер заполняет struct chat_setting_state * из [chatserver] ещё до создания instance (state-уровень), затем runtime-модули - * читают через instance-уровень (chat_setting_get_int(inst, ...)). + * читают через instance-уровень (chat_setting_get_int(inst, ...)). State не синхронизирован: + * до старта им владеет парсер, runtime-вызовы выполняются в uasync-потоке instance. */ #ifndef CHAT_SETTING_H @@ -27,23 +28,34 @@ extern "C" { #define CHAT_SETTING_STRING_MAX 16 struct chat_setting_state { - int values[CHAT_SETTING_MAX_SETTINGS]; - int string_idx[CHAT_SETTING_STRING_MAX]; - char string_val[CHAT_SETTING_STRING_MAX][256]; - int string_count; - int initialized; - char int_buf[16]; /* буфер для chat_setting_get_state у int-настроек */ + int values[CHAT_SETTING_MAX_SETTINGS]; /* Числовые значения по индексу реестра. */ + int string_idx[CHAT_SETTING_STRING_MAX]; /* Индексы занятых строковых настроек. */ + char string_val[CHAT_SETTING_STRING_MAX][256]; /* NUL-строки, не более255 байт. */ + int string_count; /* Число занятых string_val. */ + int initialized; /* 0 до первого успешного set. */ + char int_text[CHAT_SETTING_MAX_SETTINGS][16]; /* Текст числа, обновляемый только setter. */ }; /* ─── state-уровень (конфиг-парсер, до создания instance) ─── */ +/* s изначально обнулён. name/value заимствованы до возврата; значение копируется. + * 0 — сохранено, -1 — неизвестное имя, -2 — неверные аргументы/значение/нет места. + * Числа — decimal в диапазоне реестра; bool — 0/1,no/yes,false/true; пути допускают пустую строку. */ int chat_setting_set_state(struct chat_setting_state* s, const char* name, const char* value); +/* Заимствованная NUL-строка до изменения этой настройки/уничтожения s; другие getters её не меняют. + * NULL — неизвестное имя/NULL s/неинициализированное состояние. Для bitrate range даже до set — "300:4000". + * Незаполненный строковый путь после инициализации — "". */ const char* chat_setting_get_state(const struct chat_setting_state* s, const char* name); +/* Число/bool либо def для неизвестного/строкового имени, NULL s и неинициализированного состояния. */ int chat_setting_get_int_state(const struct chat_setting_state* s, const char* name, int def); /* ─── instance-уровень (runtime) ─── */ +/* Те же коды; NULL inst — -2. Большинство потребителей читают значение при следующем обращении/старте. + * incoming_call_policy сразу обновляет телефонную политику, local_* — LAN permissions. + * -3 — значение сохранено, применение LAN не удалось; повтор set того же значения повторяет apply. */ int chat_setting_set(struct UTUN_INSTANCE* inst, const char* name, const char* value); +/* Instance-варианты чтения с теми же lifetime/default; NULL inst даёт NULL/def. */ const char* chat_setting_get(struct UTUN_INSTANCE* inst, const char* name); int chat_setting_get_int(struct UTUN_INSTANCE* inst, const char* name, int def); diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index 9a39f4bef..70e5ee91b 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -13,8 +13,9 @@ #include "../ntp_time.h" #include "../../lib/ll_queue.h" #include "../../lib/mem.h" -#include "../../lib/platform_compat.h" #include "../../lib/strbuf.h" +#include +#include "../../lib/platform_compat.h" #include "../transport_layer/etcp.h" #include "../transport_layer/etcp_connections.h" #include "../transport_layer/stcp_link.h" @@ -59,20 +60,6 @@ static struct address_profile_text link_address_profile_text(const struct ETCP_L return result; } -static void get_node_name(struct UTUN_INSTANCE* inst, uint64_t node_id, char* out, size_t sz) { - struct chat_core_ctx* cc = CC(inst); - out[0] = '\0'; - if (!cc || !cc->db || node_id == 0) return; - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, "SELECT name FROM nodes WHERE node_id=?", -1, &st, NULL) != SQLITE_OK) return; - sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (sqlite3_step(st) == SQLITE_ROW) { - const char* n = (const char*)sqlite3_column_text(st, 0); - if (n) snprintf(out, sz, "%s", n); - } - sqlite3_finalize(st); -} - static void chat_core_collect_status(struct UTUN_INSTANCE* inst) { struct strbuf text = strbuf_new(); @@ -129,7 +116,7 @@ static void chat_core_collect_status(struct UTUN_INSTANCE* inst) { snprintf(peerhex, sizeof(peerhex), "%04llX", (unsigned long long)(pid & 0xFFFF)); char peername[64]; - get_node_name(inst, pid, peername, sizeof(peername)); + chat_core_get_node_name(inst, pid, peername, sizeof(peername)); if (peername[0]) { if (strbuf_addf(&text, "[%s]→[%s] %s ETCP:%s%s(%dL)\n", @@ -160,6 +147,7 @@ static void chat_core_collect_status(struct UTUN_INSTANCE* inst) { } post: + if (text.len > INT_MAX) goto failed; DEBUG_TRACE(DEBUG_CATEGORY_CHAT, "status snapshot: bytes=%zu", text.len); chat_event_post(inst, CHAT_EVT_STATUS_REFRESH, (const uint8_t*)strbuf_str(&text), (int)text.len); strbuf_free(&text); @@ -167,8 +155,6 @@ post: failed: DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "status snapshot: formatting/allocation failed after %zu bytes", text.len); strbuf_free(&text); - static const uint8_t error[] = "Unable to collect status: formatting/allocation failed\n"; - chat_event_post(inst, CHAT_EVT_STATUS_REFRESH, error, sizeof(error) - 1); } /* ── Бинарный список соединений: [count:2][entry:46B]* ── */ @@ -183,10 +169,11 @@ static void collect_conn_list(struct UTUN_INSTANCE* inst) { uint16_t count = 0; struct ll_entry* entry = inst->connections->head; while (entry && count < 250) { count++; entry = entry->next; } + if (entry) DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "connection list limited to250 entries"); size_t buf_sz = 2 + (size_t)count * CONN_LIST_ENTRY_SIZE; uint8_t* buf = (uint8_t*)u_malloc(buf_sz); - if (!buf) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "connection list: cannot allocate %zu bytes", buf_sz); return; } + if (!buf) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "connection list allocation failed bytes=%zu", buf_sz); return; } uint16_t* hdr = (uint16_t*)buf; *hdr = count; @@ -210,18 +197,19 @@ static void collect_conn_list(struct UTUN_INSTANCE* inst) { *p++ = flags; uint8_t lc = 0; - struct ETCP_LINK* tl = conn->links; while (tl) { lc++; tl = tl->next; } + struct ETCP_LINK* tl = conn->links; while (tl && lc < UINT8_MAX) { lc++; tl = tl->next; } *p++ = lc; uint16_t rtt = 0; if (conn->links) rtt = conn->links->rtt_last; memcpy(p, &rtt, 2); p += 2; - uint16_t inflight_kb = (uint16_t)(conn->unacked_bytes / 1024); + uint64_t inflight = conn->unacked_bytes / 1024; + uint16_t inflight_kb = inflight > UINT16_MAX ? UINT16_MAX : (uint16_t)inflight; memcpy(p, &inflight_kb, 2); p += 2; char name[32]; memset(name, 0, 32); - get_node_name(inst, ce->peer_node_id, name, 31); + chat_core_get_node_name(inst, ce->peer_node_id, name, 31); if (!name[0]) snprintf(name, 32, "%016llX", (unsigned long long)ce->peer_node_id); memcpy(p, name, 32); p += 32; } @@ -256,7 +244,7 @@ static void collect_conn_metrics(struct UTUN_INSTANCE* inst, uint64_t peer_node_ struct ETCP_CONN* conn = ce->conn; uint64_t my_id = inst->node_id; - char peername[64]; get_node_name(inst, peer_node_id, peername, sizeof(peername)); + char peername[64]; chat_core_get_node_name(inst, peer_node_id, peername, sizeof(peername)); if (!peername[0]) snprintf(peername, sizeof(peername), "%016llX", (unsigned long long)peer_node_id); int is_server = conn->links ? conn->links->is_server : -1; @@ -347,6 +335,7 @@ static void collect_conn_metrics(struct UTUN_INSTANCE* inst, uint64_t peer_node_ } post: + if (text.len > INT_MAX) goto failed; DEBUG_TRACE(DEBUG_CATEGORY_CHAT, "connection metrics: peer=%016llX bytes=%zu", (unsigned long long)peer_node_id, text.len); chat_event_post(inst, CHAT_EVT_CONN_METRICS, (const uint8_t*)strbuf_str(&text), (int)text.len); strbuf_free(&text); @@ -355,8 +344,6 @@ failed: DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "connection metrics: peer=%016llX formatting/allocation failed after %zu bytes", (unsigned long long)peer_node_id, text.len); strbuf_free(&text); - static const uint8_t error[] = "Unable to collect connection metrics: formatting/allocation failed\n"; - chat_event_post(inst, CHAT_EVT_CONN_METRICS, error, sizeof(error) - 1); } void chat_core_collect_status_trampoline(void* arg) { @@ -407,7 +394,8 @@ int chat_core_parse_member_detail(const uint8_t* data, size_t len, struct chat_m v.hop_count = data[28]; v.link_count = data[29]; v.sock_count = data[30]; size_t expected = CHAT_MEMBER_DETAIL_HEADER_SIZE + (size_t)v.hop_count * 8 + (size_t)v.link_count * CHAT_MEMBER_DETAIL_LINK_SIZE + (size_t)v.sock_count * CHAT_MEMBER_DETAIL_SOCK_SIZE; - if (!v.request_id || !v.group_id || !v.node_id || v.result > CHAT_MEMBER_DETAIL_ERROR || v.transport_flags > 7 || + if (((!v.request_id || !v.group_id || !v.node_id) && v.result != CHAT_MEMBER_DETAIL_INVALID) || + v.result > CHAT_MEMBER_DETAIL_ERROR || v.transport_flags > 7 || v.hop_count > CHAT_MEMBER_DETAIL_MAX_HOPS || v.link_count > CHAT_MEMBER_DETAIL_MAX_LINKS || v.sock_count > CHAT_MEMBER_DETAIL_MAX_SOCKS || expected != len || (v.result && (v.transport_flags || v.own_tcp_active || v.hop_count || v.link_count || v.sock_count))) { @@ -471,7 +459,12 @@ static void collect_member_detail(const struct chat_member_detail_req* req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: query request=%llu rc=%d: %s", (unsigned long long)req->request_id, rc, sqlite3_errmsg(CC(inst)->db)); } - sqlite3_finalize(st); + int final = sqlite3_finalize(st); + if (final != SQLITE_OK) { + buf[24] = CHAT_MEMBER_DETAIL_ERROR; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member detail: finalize request=%llu rc=%d: %s", + (unsigned long long)req->request_id, final, sqlite3_errmsg(CC(inst)->db)); + } } if (buf[24]) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member detail: rejected request=%llu group=%016llx node=%016llx result=%u", @@ -486,7 +479,7 @@ static void collect_member_detail(const struct chat_member_detail_req* req) { if (conn->links_up) flags |= 2; if (conn->initialized) flags |= 4; } - uint16_t own_tcp_active = 0; + uint32_t own_tcp_active = 0; if (node_id == inst->node_id) { if (inst->connections) { for (struct ll_entry* e = inst->connections->head; e; e = e->next) { @@ -506,6 +499,10 @@ static void collect_member_detail(const struct chat_member_detail_req* req) { } uint8_t link_count = 0; + if (own_tcp_active > UINT16_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member detail: active TCP count exceeds wire limit=%u", own_tcp_active); + own_tcp_active = UINT16_MAX; + } for (struct ETCP_LINK* lk = conn ? conn->links : NULL; lk && link_count < CHAT_MEMBER_DETAIL_MAX_LINKS; lk = lk->next) link_count++; @@ -570,7 +567,7 @@ static void collect_member_detail(const struct chat_member_detail_req* req) { const struct sockaddr_storage* sa = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr; if (sa->ss_family == AF_INET) sp = ntohs(((const struct sockaddr_in*)sa)->sin_port); else if (sa->ss_family == AF_INET6) sp = ntohs(((const struct sockaddr_in6*)sa)->sin6_port); - uint16_t lu = 0, ld = 0; + uint32_t lu = 0, ld = 0; { struct ll_entry* e = inst->connections ? inst->connections->head : NULL; for (; e; e = e->next) { struct conn_queue_entry* ce = (struct conn_queue_entry*)e->data; @@ -588,6 +585,11 @@ static void collect_member_detail(const struct chat_member_detail_req* req) { } } char ifname[38] = {0}; sock_ifname(s->name, ifname, sizeof(ifname)); + if (lu > UINT16_MAX || ld > UINT16_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member detail: socket link counts exceed wire limit id=%u up=%u down=%u", sid, lu, ld); + if (lu > UINT16_MAX) lu = UINT16_MAX; + if (ld > UINT16_MAX) ld = UINT16_MAX; + } for (int i = 0; i < 4; i++) *p++ = (uint8_t)(sid >> (8 * i)); *p++ = (uint8_t)sp; *p++ = (uint8_t)(sp >> 8); *p++ = (uint8_t)lu; *p++ = (uint8_t)(lu >> 8); diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index e60a7f3e5..48683b4a9 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -598,7 +598,9 @@ void chat_sync_broadcast_sound_setting(struct UTUN_INSTANCE* inst, const char* c static void cs_send_all_sound_settings(struct chat_sync* cs, uint64_t peer) { for (int i = 0; i < cs->channel_count; i++) { int on = chat_core_get_sound_on_message(cs->inst, cs->channels[i].channel_id); - cs_send_sound_setting(cs, cs->channels[i].channel_id, peer, on); + if (on >= 0) cs_send_sound_setting(cs, cs->channels[i].channel_id, peer, on); + else DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "sound setting: skip unreadable channel=%s peer=%016llx", + cs->channels[i].channel_id, (unsigned long long)peer); } } @@ -871,7 +873,9 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { cs->info_req_timer = NULL; cs->join_timer = NULL; - member_sync_add_apply_cbk(inst, cs_on_member_applied, cs); + if (member_sync_add_apply_cbk(inst, cs_on_member_applied, cs)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: member apply subscription failed", CS_ID); chat_sync_destroy(inst); return -1; + } utun_add_peer_sleep_cbk(inst, cs_on_peer_sleep, cs); chat_admin_init(inst); chat_join_init(inst); @@ -1220,13 +1224,9 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, (unsigned long long)ch_x_prefix, (unsigned long long)ch_ed_prefix, name, (unsigned long long)c_node_id); /* verify channel signature */ - uint8_t vmsg[1024]; size_t vlen = 0; - vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", ch_id) + 1; - vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", name) + 1; - memcpy(vmsg + vlen, &owner, 8); vlen += 8; - memcpy(vmsg + vlen, ch_x25519, 32); vlen += 32; - memcpy(vmsg + vlen, ch_ed, 32); vlen += 32; - if (cs_ed25519_verify(ch_ed, vmsg, vlen, ch_sig) != 0) { + uint8_t vmsg[CHAT_CHANNEL_DESCRIPTION_MAX]; + int vlen = chat_channel_description_data(ch_id, name, owner, ch_x25519, ch_ed, vmsg, sizeof(vmsg)); + if (vlen < 0 || cs_ed25519_verify(ch_ed, vmsg, (size_t)vlen, ch_sig) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP invalid ch_sig ch=%s", CS_ID, ch_id); return; } @@ -1521,13 +1521,9 @@ static void cs_handle_channel_invite(struct chat_sync* cs, uint64_t peer, memcpy(&join_key, p, 8); p += 8; /* Verify channel signature */ - uint8_t vmsg[1024]; size_t vlen = 0; - vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", ch_id) + 1; - vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", name) + 1; - memcpy(vmsg + vlen, &owner, 8); vlen += 8; - memcpy(vmsg + vlen, x25519, 32); vlen += 32; - memcpy(vmsg + vlen, ed_pub, 32); vlen += 32; - if (cs_ed25519_verify(ed_pub, vmsg, vlen, ch_sig) != 0) { + uint8_t vmsg[CHAT_CHANNEL_DESCRIPTION_MAX]; + int vlen = chat_channel_description_data(ch_id, name, owner, x25519, ed_pub, vmsg, sizeof(vmsg)); + if (vlen < 0 || cs_ed25519_verify(ed_pub, vmsg, (size_t)vlen, ch_sig) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_INVITE invalid ch_sig ch=%s from=%016llx", CS_ID, ch_id, (unsigned long long)peer); return; } diff --git a/src/chat/group_avatar.c b/src/chat/group_avatar.c index 066ef6219..2e5d6ee0b 100644 --- a/src/chat/group_avatar.c +++ b/src/chat/group_avatar.c @@ -12,6 +12,7 @@ #include "../../lib/mem.h" #include "../../lib/u_async.h" #include +#include #define GA_STATE 1 #define GA_GET 2 @@ -51,7 +52,8 @@ static int ga_message(uint64_t group, const struct group_avatar_record* record, } static int ga_verify(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* r) { - if (!inst || !CC(inst) || !group || !r->version || r->version > INT64_MAX || r->size > GROUP_AVATAR_MAX) { + if (!inst || !CC(inst) || !CC(inst)->db || !group || group > INT64_MAX || !r || + !r->version || r->version > INT64_MAX || r->size > GROUP_AVATAR_MAX) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: invalid record group=%llu", (unsigned long long)group); return -1; } uint8_t hash[32], pub[32], bytes[GROUP_AVATAR_MAX + 64]; char ch[32]; ga_channel(group, ch); @@ -72,31 +74,40 @@ static int ga_verify(struct UTUN_INSTANCE* inst, uint64_t group, const struct gr } int group_avatar_get(struct UTUN_INSTANCE* inst, uint64_t group, struct group_avatar_record* r) { - if (!inst || !CC(inst) || !CC(inst)->db || !r) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: invalid read"); return -1; } - memset(r, 0, sizeof(*r)); char ch[32]; ga_channel(group, ch); sqlite3_stmt* st = NULL; - const char* sql = "SELECT avatar_version,avatar_data,avatar_hash,avatar_signature FROM channels WHERE channel_id=?"; - int rc = -1; - if (sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, ch, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_ROW) { - r->version = (uint64_t)sqlite3_column_int64(st, 0); int size = sqlite3_column_bytes(st, 1); - if ((!r->version && !size && !sqlite3_column_bytes(st, 2) && !sqlite3_column_bytes(st, 3)) || - (r->version && r->version <= INT64_MAX && size <= GROUP_AVATAR_MAX && - sqlite3_column_bytes(st, 2) == 32 && sqlite3_column_bytes(st, 3) == 64)) { - r->size = (uint16_t)size; - if (r->version) { - if (size) memcpy(r->data, sqlite3_column_blob(st, 1), size); - memcpy(r->hash, sqlite3_column_blob(st, 2), 32); memcpy(r->signature, sqlite3_column_blob(st, 3), 64); - } - rc = 0; - } - } - sqlite3_finalize(st); + if (!inst || !CC(inst) || !CC(inst)->db || !group || group > INT64_MAX || !r) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: invalid read group=%llu", (unsigned long long)group); return -1; } - if (!rc && r->version) rc = ga_verify(inst, group, r); - if (rc) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: stored record unavailable/invalid group=%llu: %s", - (unsigned long long)group, sqlite3_errmsg(CC(inst)->db)); - return rc; + struct group_avatar_record result = {0}; char ch[32]; ga_channel(group, ch); sqlite3_stmt* st = NULL; + const char* sql = "SELECT avatar_version,avatar_data,avatar_hash,avatar_signature FROM channels WHERE channel_id=?"; + sqlite3* db = CC(inst)->db; const char* stage = "prepare"; + int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc != SQLITE_OK) goto done; + stage = "bind"; rc = sqlite3_bind_text(st, 1, ch, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto done; + stage = "step"; rc = sqlite3_step(st); if (rc != SQLITE_ROW) goto done; + result.version = (uint64_t)sqlite3_column_int64(st, 0); int size = sqlite3_column_bytes(st, 1); + stage = "record fields"; rc = SQLITE_CORRUPT; + if (sqlite3_column_type(st, 0) != SQLITE_INTEGER || result.version > INT64_MAX || size > GROUP_AVATAR_MAX) goto done; + if (!result.version) { + if (size || sqlite3_column_bytes(st, 2) || sqlite3_column_bytes(st, 3)) goto done; + } else { + if (sqlite3_column_type(st, 1) != SQLITE_BLOB || sqlite3_column_type(st, 2) != SQLITE_BLOB || + sqlite3_column_type(st, 3) != SQLITE_BLOB || sqlite3_column_bytes(st, 2) != 32 || sqlite3_column_bytes(st, 3) != 64) goto done; + const void *data = sqlite3_column_blob(st, 1), *hash = sqlite3_column_blob(st, 2), *signature = sqlite3_column_blob(st, 3); + if ((size && !data) || !hash || !signature) { rc = SQLITE_NOMEM; goto done; } + result.size = (uint16_t)size; if (size) memcpy(result.data, data, (size_t)size); + memcpy(result.hash, hash, 32); memcpy(result.signature, signature, 64); + } + rc = SQLITE_OK; +done: + { + int final = sqlite3_finalize(st); + if (rc != SQLITE_OK || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: read group=%llu stage=%s rc=%d finalize=%d: %s", + (unsigned long long)group, stage, rc, final, sqlite3_errmsg(db)); return -1; + } + } + if (result.version && ga_verify(inst, group, &result) < 0) return -1; + *r = result; return 0; } static int ga_compare(uint64_t a, const uint8_t ah[32], uint64_t b, const uint8_t bh[32]) { @@ -112,59 +123,126 @@ static void ga_changed(struct UTUN_INSTANCE* inst, uint64_t group) { memcpy(event + 1, ch, event[0]); chat_event_post(inst, CHAT_EVT_CHANNEL_UPDATED, event, event[0] + 1); } -int group_avatar_apply(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* r) { +/* Блокировка охватывает проверку допуска/версии и запись; callback здесь не вызывается. */ +static int ga_begin(struct UTUN_INSTANCE* inst, uint64_t group) { + if (!inst || !CC(inst) || !CC(inst)->initialized || !CC(inst)->db || !group || group > INT64_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: inactive core/invalid group=%llu", (unsigned long long)group); return -1; + } + sqlite3* db = CC(inst)->db; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: external transaction rejected group=%llu", (unsigned long long)group); return -1; + } + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* group avatar */", NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: begin group=%llu rc=%d: %s", + (unsigned long long)group, rc, sqlite3_errmsg(db)); + return rc == SQLITE_OK ? 0 : -1; +} + +static int ga_finish(struct UTUN_INSTANCE* inst, uint64_t group, int result) { + sqlite3* db = CC(inst)->db; int rc; + if (result > 0) { + rc = sqlite3_exec(db, "COMMIT /* group avatar */", NULL, NULL, NULL); + if (rc == SQLITE_OK) return result; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: commit group=%llu rc=%d: %s", + (unsigned long long)group, rc, sqlite3_errmsg(db)); result = -1; + } + if (!sqlite3_get_autocommit(db)) { + rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: rollback group=%llu rc=%d: %s", + (unsigned long long)group, rc, sqlite3_errmsg(db)); result = -1; + } + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: transaction remains open group=%llu; requesting owner stop/DB close", + (unsigned long long)group); utun_instance_stop(inst); result = -1; + } + } + return result; +} + +static int ga_write(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* r) { + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; char ch[32]; ga_channel(group, ch); + const char* sql = "UPDATE channels SET avatar_version=?,avatar_data=?,avatar_hash=?,avatar_signature=? WHERE channel_id=?"; + const char* stage = "prepare"; int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) { stage = "bind version"; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)r->version); } + if (rc == SQLITE_OK) { stage = "bind data"; rc = sqlite3_bind_blob(st, 2, r->data, r->size, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "bind hash"; rc = sqlite3_bind_blob(st, 3, r->hash, 32, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "bind signature"; rc = sqlite3_bind_blob(st, 4, r->signature, 64, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "bind channel"; rc = sqlite3_bind_text(st, 5, ch, -1, SQLITE_STATIC); } + if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); } + if (rc == SQLITE_DONE && sqlite3_changes(db) != 1) { stage = "changes != 1"; rc = SQLITE_CONSTRAINT; } + int final = sqlite3_finalize(st); + if (rc != SQLITE_DONE || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: write group=%llu stage=%s rc=%d finalize=%d: %s", + (unsigned long long)group, stage, rc, final, sqlite3_errmsg(db)); return -1; + } + return 1; +} + +/* Приём завершает всю работу с ga_peer до публикации события, способного удалить группу. */ +static int ga_apply_transaction(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* r) { if (!r) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: missing candidate record"); return -1; } - if (ga_verify(inst, group, r) < 0) return -1; - struct group_avatar_record current; - if (group_avatar_get(inst, group, ¤t) < 0) return -1; + if (ga_begin(inst, group) < 0) return -1; + int result = -1; struct group_avatar_record current; + if (ga_verify(inst, group, r) < 0 || group_avatar_get(inst, group, ¤t) < 0) goto done; int newer = ga_compare(r->version, r->hash, current.version, current.hash); if (r->version == current.version && memcmp(r->hash, current.hash, 32)) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: concurrent versions group=%llu version=%llu accept=%d", (unsigned long long)group, (unsigned long long)r->version, newer > 0); - if (newer <= 0) return 0; - sqlite3_stmt* st = NULL; char ch[32]; ga_channel(group, ch); int rc = -1; - const char* sql = "UPDATE channels SET avatar_version=?,avatar_data=?,avatar_hash=?,avatar_signature=? WHERE channel_id=?"; - if (sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)r->version); sqlite3_bind_blob(st, 2, r->data, r->size, SQLITE_STATIC); - sqlite3_bind_blob(st, 3, r->hash, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 4, r->signature, 64, SQLITE_STATIC); - sqlite3_bind_text(st, 5, ch, -1, SQLITE_STATIC); - if (sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(CC(inst)->db) == 1) rc = 1; - sqlite3_finalize(st); - } - if (rc < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: database update failed group=%llu: %s", - (unsigned long long)group, sqlite3_errmsg(CC(inst)->db)); - else { - DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: saved group=%llu version=%llu bytes=%u", - (unsigned long long)group, (unsigned long long)r->version, r->size); ga_changed(inst, group); - } - return rc; + result = newer > 0 ? ga_write(inst, group, r) : 0; +done: + result = ga_finish(inst, group, result); + if (result > 0) DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: saved group=%llu version=%llu bytes=%u committed=1", + (unsigned long long)group, (unsigned long long)r->version, r->size); + return result; +} + +int group_avatar_apply(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* r) { + int result = ga_apply_transaction(inst, group, r); + if (result > 0) ga_changed(inst, group); + return result; } int group_avatar_set(struct UTUN_INSTANCE* inst, uint64_t group, const uint8_t* data, size_t size) { - if (!inst || !CC(inst) || !CC(inst)->initialized || !CC(inst)->db || size > GROUP_AVATAR_MAX || - (size && !data) || !member_files_member(inst, group, inst->node_id, 0)) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: publication denied group=%llu bytes=%zu", (unsigned long long)group, size); return -1; - } - uint8_t key[32], bytes[GROUP_AVATAR_MAX + 64]; char ch[32]; ga_channel(group, ch); - if (topo_node_sqlite_channel_get_priv(CC(inst)->db, ch, key) < 0) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: publication requires group key group=%llu", (unsigned long long)group); return -1; + if (size > GROUP_AVATAR_MAX || (size && !data)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: invalid publication group=%llu bytes=%zu", (unsigned long long)group, size); return -1; } + if (ga_begin(inst, group) < 0) return -1; + int result = -1; uint8_t key[32] = {0}, bytes[GROUP_AVATAR_MAX + 64]; char ch[32]; ga_channel(group, ch); struct group_avatar_record r; - if (group_avatar_get(inst, group, &r) < 0 || r.version == INT64_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: cannot allocate version group=%llu", (unsigned long long)group); return -1; + if (!member_files_member(inst, group, inst->node_id, 0)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: publication requires active membership group=%llu", (unsigned long long)group); goto done; } + if (topo_node_sqlite_channel_get_priv(CC(inst)->db, ch, key) < 0) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "group avatar: publication requires group key group=%llu", (unsigned long long)group); goto done; + } + if (group_avatar_get(inst, group, &r) < 0) goto done; uint8_t hash[32]; SHA256(size ? data : r.data, size, hash); - if (r.version && r.size == size && !memcmp(r.hash, hash, 32)) return 0; + if (r.version && r.size == size && !memcmp(r.hash, hash, 32)) { result = 0; goto done; } + if (r.version == INT64_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: version exhausted group=%llu", (unsigned long long)group); goto done; + } int64_t us = ntp_time_get_us(inst); uint64_t now = us > 0 ? (uint64_t)us / 1000 : 0; r.version = now > r.version ? now : r.version + 1; r.size = (uint16_t)size; memcpy(r.hash, hash, 32); if (size) memcpy(r.data, data, size); - int n = ga_message(group, &r, bytes); int signed_ok = sc_ed25519_sign(key, bytes, n, r.signature); memset(key, 0, sizeof(key)); - if (signed_ok != SC_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: signing failed group=%llu", (unsigned long long)group); return -1; } - return group_avatar_apply(inst, group, &r) > 0 ? 0 : -1; + int n = ga_message(group, &r, bytes); + if (sc_ed25519_sign(key, bytes, (size_t)n, r.signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: signing failed group=%llu", (unsigned long long)group); goto done; + } + if (ga_verify(inst, group, &r) < 0) goto done; + result = ga_write(inst, group, &r); +done: + OPENSSL_cleanse(key, sizeof(key)); result = ga_finish(inst, group, result); + if (result > 0) { + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: saved group=%llu version=%llu bytes=%u committed=1", + (unsigned long long)group, (unsigned long long)r.version, r.size); ga_changed(inst, group); + } + return result < 0 ? -1 : 0; } void group_avatar_gui_set(void* arg) { struct group_avatar_gui_request* q = arg; + if (!q || !q->inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: invalid GUI request"); u_free(q); return; } int rc = group_avatar_set(q->inst, q->group, q->data, q->size); uint8_t result[17]; mf_put64(result, q->id); mf_put64(result + 8, q->group); result[16] = rc ? 1 : 0; chat_event_post(q->inst, CHAT_EVT_GROUP_AVATAR_RESULT, result, sizeof(result)); u_free(q); @@ -337,9 +415,10 @@ static void ga_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "group avatar: block received group=%llu peer=%016llx version=%llu offset=%u bytes=%u total=%u", (unsigned long long)group, (unsigned long long)peer, (unsigned long long)version, offset, block, size); if (p->rx_offset == size) { - int applied = group_avatar_apply(ctx->inst, group, p->rx); ga_release(p, 0); + int applied = ga_apply_transaction(ctx->inst, group, p->rx); ga_release(p, 0); p->requested_at = applied < 0 ? get_time_tb() : 0; p->state_pending = 1; ga_schedule(p); + if (applied > 0) ga_changed(ctx->inst, group); } goto out; malformed: @@ -386,29 +465,39 @@ static void ga_tick(void* arg) { static int ga_schema(sqlite3* db) { const char* names[] = {"avatar_version", "avatar_data", "avatar_hash", "avatar_signature"}; int present[4] = {0}; - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(db, "PRAGMA table_info(channels)", -1, &st, NULL) != SQLITE_OK) goto error; - while (sqlite3_step(st) == SQLITE_ROW) for (unsigned i = 0; i < 4; i++) - if (!strcmp((const char*)sqlite3_column_text(st, 1), names[i])) present[i] = 1; - sqlite3_finalize(st); - if (sqlite3_exec(db, "SAVEPOINT group_avatar_schema", NULL, NULL, NULL) != SQLITE_OK) goto error; + if (!db) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema database missing"); return -1; } + sqlite3_stmt* st = NULL; const char* stage = "savepoint"; int started = 0, added = 0; + int rc = sqlite3_exec(db, "SAVEPOINT group_avatar_schema", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto error; + started = 1; stage = "table info prepare"; rc = sqlite3_prepare_v2(db, "PRAGMA table_info(channels)", -1, &st, NULL); + if (rc != SQLITE_OK) goto error; + stage = "table info step"; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const char* name = (const char*)sqlite3_column_text(st, 1); + if (!name) { rc = SQLITE_CORRUPT; goto error; } + for (unsigned i = 0; i < 4; i++) if (!strcmp(name, names[i])) present[i] = 1; + } + if (rc != SQLITE_DONE) goto error; + stage = "table info finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error; for (unsigned i = 0; i < 4; i++) if (!present[i]) { char sql[128]; snprintf(sql, sizeof(sql), "ALTER TABLE channels ADD COLUMN %s %s", names[i], i ? "BLOB" : "INTEGER NOT NULL DEFAULT 0"); - if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: cannot add schema column=%s: %s", names[i], sqlite3_errmsg(db)); - if (sqlite3_exec(db, "ROLLBACK TO group_avatar_schema; RELEASE group_avatar_schema", NULL, NULL, NULL) != SQLITE_OK) - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema rollback failed: %s", sqlite3_errmsg(db)); - return -1; - } - DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: added schema column=%s", names[i]); + stage = names[i]; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) goto error; + added++; } - if (sqlite3_exec(db, "RELEASE group_avatar_schema", NULL, NULL, NULL) == SQLITE_OK) return 0; - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema commit failed: %s", sqlite3_errmsg(db)); - if (sqlite3_exec(db, "ROLLBACK TO group_avatar_schema; RELEASE group_avatar_schema", NULL, NULL, NULL) != SQLITE_OK) - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema rollback after commit error failed: %s", sqlite3_errmsg(db)); - return -1; + stage = "release"; rc = sqlite3_exec(db, "RELEASE group_avatar_schema", NULL, NULL, NULL); if (rc != SQLITE_OK) goto error; + if (added) DEBUG_INFO(DEBUG_CATEGORY_CHAT, "group avatar: schema ready added_columns=%d outer_transaction=%d", added, !sqlite3_get_autocommit(db)); + return 0; error: - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema initialization failed: %s", sqlite3_errmsg(db)); return -1; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema initialization stage=%s rc=%d: %s", stage, rc, sqlite3_errmsg(db)); + rc = sqlite3_finalize(st); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema cleanup finalize rc=%d", rc); + if (started && !sqlite3_get_autocommit(db)) { + rc = sqlite3_exec(db, "ROLLBACK TO group_avatar_schema", NULL, NULL, NULL); + if (rc == SQLITE_OK) rc = sqlite3_exec(db, "RELEASE group_avatar_schema", NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "group avatar: schema rollback rc=%d;owner must rollback/close DB: %s", + rc, sqlite3_errmsg(db)); + } + return -1; } int group_avatar_init(struct UTUN_INSTANCE* inst) { diff --git a/src/chat/group_avatar.h b/src/chat/group_avatar.h index e9bce3fa5..173d76081 100644 --- a/src/chat/group_avatar.h +++ b/src/chat/group_avatar.h @@ -1,7 +1,9 @@ /* group_avatar — общая подписанная аватарка CHAT-канала, хранение в SQLite и обмен между участниками. * После init: get читает текущую версию, set публикует картинку владельца, apply принимает проверяемую запись пира. * Картинка до 16 КиБ передаётся router-блоками до 4 КиБ; данные публикуются после проверки подписи канала. - * Все вызовы/callbacks — uasync. Изменение картинки приходит событием CHAT_EVT_CHANNEL_UPDATED. */ + * Все вызовы/callbacks — uasync. group — канонический числовой ID1..INT64_MAX существующего CHAT-канала. + * Изменение приходит CHAT_EVT_CHANNEL_UPDATED после commit. Обработчик вправе отменить задачи группы; + * остановку сервиса/destroy выполняют вне callbacks. Ключи и содержимое картинки в лог не выводятся. */ #ifndef GROUP_AVATAR_H #define GROUP_AVATAR_H #include @@ -9,9 +11,15 @@ struct UTUN_INSTANCE; struct sqlite3; +#ifdef __cplusplus +extern "C" { +#endif + // ── Схема хранения ── -// Создать поля хранения в общей SQLite; 0/-1, БД остаётся у вызывающего. +/* Добавить отсутствующие поля channels одним savepoint; 0/-1, повтор допустим, БД заимствована. + * Внешняя транзакция остаётся у вызывающего. При отказе ROLLBACK TO не выполняет RELEASE частичной схемы; + * оставшуюся открытой транзакцию владелец должен откатить/закрыть. */ int group_avatar_schema_init(struct sqlite3* db); #define GROUP_AVATAR_MAX 16384 @@ -23,36 +31,44 @@ struct group_avatar_record { }; // ── Жизненный цикл модуля ── -// Создать контекст и подключить сетевой обмен; 0/-1. Повтор допустим. +// После chat_core_init создать контекст, router-service и таймер; 0/-1. Повтор допустим, ошибка снимает новые регистрации. int group_avatar_init(struct UTUN_INSTANCE* inst); // Остановить обмен и освободить контекст; сохранённая картинка остаётся в БД. void group_avatar_destroy(struct UTUN_INSTANCE* inst); -// Отменить сетевые задачи канала перед удалением группы. +// Отменить сборки/waiters/call_soon канала перед удалением группы; БД и router-сессия остаются у своих владельцев. void group_avatar_cancel_group(struct UTUN_INSTANCE* inst, uint64_t group); // ── Чтение и изменение аватарки ── -// Скопировать запись и проверить её подпись; 0 — прочитано (version=0, если не публиковалась), -1 — ошибка/нет канала. +/* Скопировать запись и проверить SHA256/подпись:0 — прочитано (version=0, если не публиковалась), -1 — ошибка/нет канала. + * Проверяются bind/step/finalize и размеры полей; при любом отказе record не меняется. */ int group_avatar_get(struct UTUN_INSTANCE* inst, uint64_t group, struct group_avatar_record* record); -// Проверить подпись и сохранить более новую запись; 1=изменено, 0=прежняя/старая, -1=ошибка. Структура остаётся у вызывающего. +/* Проверить подпись и сохранить более новую запись:1 — committed,0 — прежняя/старая,-1 — ошибка. + * При одинаковой версии побеждает больший SHA256; запись заимствована. Проверка версии/запись защищены BEGIN IMMEDIATE. + * Внешняя транзакция запрещена даже для no-op; ошибка сохраняет прежнюю запись/события. Неудачный rollback + * с оставшейся транзакцией запрашивает stop/закрытие БД владельцем. Уведомление/STATE идут после commit. */ int group_avatar_apply(struct UTUN_INSTANCE* inst, uint64_t group, const struct group_avatar_record* record); /* Подписать и сохранить картинку: нужен приватный Ed25519 ключ канала. size=0 — удаление; data копируется. - * 0 — сохранено или картинка прежняя, -1 — ошибка/нет прав. Доставка пирам выполняется отдельно. */ + * 0 — committed или картинка прежняя, -1 — ошибка/нет прав. Членство/ключ/версия/запись читаются под одной блокировкой; + * внешняя транзакция запрещена, временный ключ очищается на всех выходах. Доставка пирам выполняется отдельно. */ int group_avatar_set(struct UTUN_INSTANCE* inst, uint64_t group, const uint8_t* data, size_t size); // ── Команда интерфейса ── struct group_avatar_gui_request { - struct UTUN_INSTANCE* inst; - uint64_t id, group; - uint16_t size; - uint8_t data[GROUP_AVATAR_MAX]; + struct UTUN_INSTANCE* inst; /* Заимствован до выполнения trampoline. */ + uint64_t id, group; /* ID команды интерфейса и канала. */ + uint16_t size; /* Размер data<=GROUP_AVATAR_MAX;0 — подписанное удаление. */ + uint8_t data[GROUP_AVATAR_MAX]; /* Готовая картинка, скопированная интерфейсом. */ }; // arg — u_malloc/u_calloc group_avatar_gui_request, освобождается здесь; результат — CHAT_EVT_GROUP_AVATAR_RESULT. void group_avatar_gui_set(void* arg); +#ifdef __cplusplus +} +#endif #endif diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index a86a1d0b2..72123ea03 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -52,6 +52,28 @@ static uint64_t member_read64(const uint8_t* p) { return n; } +/* Версия owner-блока — каноническая десятичная строка1..INT_MAX,без atoi overflow. */ +static int owner_version_pair(const char* key, const char* text, void* arg) { + if (strcmp(key, "ver")) return 0; + int value = 0; + if (*text < '1' || *text > '9') goto invalid; + for (const char* p = text; *p; p++) { + if (*p < '0' || *p > '9' || value > (INT_MAX - (*p - '0')) / 10) goto invalid; + value = value * 10 + (*p - '0'); + } + *(int*)arg = value; return 0; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member owner: invalid version format/range"); return -1; +} + +int member_sync_owner_version(sqlite3* db, const char* tags) { + if (!tags || !tags[0]) return 0; + int version = 0; + if (chat_json_strings(db, tags, MS_ADM_TAGS_MAX, owner_version_pair, &version)) return -1; + if (!version) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member owner: missing version"); return -1; } + return version; +} + int member_sync_build_owner_msg(uint64_t node_id, const char* tags, uint8_t* out, size_t capacity) { size_t len = tags ? strlen(tags) : 0; if (!tags || !out || len > MS_ADM_TAGS_MAX || capacity < len + 8) { @@ -315,12 +337,19 @@ static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int afte struct ms_props_cbk { node_props_changed_fn fn; void* arg; struct ms_props_cbk* next; }; +static int member_callback_channel(const char* channel, char out[64]) { + size_t size = channel ? strnlen(channel, 64) : 0; + if (!size || size == 64) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member callback: invalid namespace"); return -1; } + memcpy(out, channel, size + 1); return 0; +} + static void _fire_props_changed(struct UTUN_INSTANCE* inst, uint64_t node_id, const char* adm_tags, const char* channel_id) { if (!inst || !CC(inst)) return; + char channel[64]; if (member_callback_channel(channel_id, channel)) return; struct chat_core_ctx* cc = CC(inst); cc->props_dispatch_depth++; for (struct ms_props_cbk* pc = cc->props_cbks; pc; pc = pc->next) - if (pc->fn) pc->fn(node_id, adm_tags, channel_id, pc->arg); + if (pc->fn) pc->fn(node_id, adm_tags, channel, pc->arg); if (--cc->props_dispatch_depth == 0) { struct ms_props_cbk** p = &cc->props_cbks; while (*p) { @@ -362,17 +391,27 @@ static void _fire_apply_cbk(struct UTUN_INSTANCE* inst, const char* ch_id, uint6 const uint8_t* x25519, uint64_t signed_by, const uint8_t* signature) { if (!inst || !CC(inst)) return; - struct ms_apply_cbk* ac = CC(inst)->apply_cbks; - while (ac) { ac->fn(ch_id, node_id, x25519, signed_by, signature, ac->arg); ac = ac->next; } + char channel[64]; if (member_callback_channel(ch_id, channel)) return; + struct chat_core_ctx* cc = CC(inst); cc->apply_dispatch_depth++; + for (struct ms_apply_cbk* ac = cc->apply_cbks; ac; ac = ac->next) + if (ac->fn) ac->fn(channel, node_id, x25519, signed_by, signature, ac->arg); + if (--cc->apply_dispatch_depth == 0) { + struct ms_apply_cbk** p = &cc->apply_cbks; + while (*p) { + if (!(*p)->fn) { struct ms_apply_cbk* removed = *p; *p = removed->next; u_free(removed); } + else p = &(*p)->next; + } + } } -void member_sync_add_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg) { - if (!inst || !fn || !CC(inst)) return; +int member_sync_add_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg) { + if (!inst || !fn || !CC(inst)) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "apply callback: invalid registration"); return -1; } struct ms_apply_cbk* e = u_malloc(sizeof(*e)); - if (!e) return; + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "apply callback: allocation failed"); return -1; } e->fn = fn; e->arg = arg; e->next = CC(inst)->apply_cbks; CC(inst)->apply_cbks = e; + return 0; } void member_sync_remove_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg) { @@ -381,7 +420,8 @@ void member_sync_remove_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_f while (*p) { if ((*p)->fn == fn && (*p)->arg == arg) { struct ms_apply_cbk* r = *p; - *p = r->next; u_free(r); + if (CC(inst)->apply_dispatch_depth) { r->fn = NULL; r->arg = NULL; } + else { *p = r->next; u_free(r); } return; } p = &(*p)->next; @@ -403,13 +443,18 @@ static void _ms_on_bgp_node(struct TOPO_GROUP* group, uint64_t node_id, int even char peers_tbl[128]; _peers_table(group->channel_id, peers_tbl, sizeof(peers_tbl)); sqlite3_stmt* st = NULL; char sql[256]; snprintf(sql, sizeof(sql), "SELECT COALESCE(adm_tags,'') FROM \"%s\" WHERE node_id=?", peers_tbl); - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); - if (sqlite3_step(st) == SQLITE_ROW) { - const char* t = (const char*)sqlite3_column_text(st, 0); - if (t) snprintf(tags, sizeof(tags), "%s", t); - } - sqlite3_finalize(st); + int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) { + const char* text = (const char*)sqlite3_column_text(st, 0); int bytes = sqlite3_column_bytes(st, 0); + if (!text || sqlite3_column_type(st, 0) != SQLITE_TEXT || bytes > MS_ADM_TAGS_MAX || strlen(text) != (size_t)bytes) rc = SQLITE_CORRUPT; + else memcpy(tags, text, (size_t)bytes + 1); + } + int final = sqlite3_finalize(st); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member properties read node=%016llx rc=%d finalize=%d: %s", + (unsigned long long)node_id, rc, final, sqlite3_errmsg(db)); return; } DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: bgp_node ev=%d nid=0x%016llx tags_bytes=%zu", @@ -542,7 +587,10 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, local_join_ts = (uint64_t)sqlite3_column_int64(st, 1); local_update_ts = (uint64_t)sqlite3_column_int64(st, 2); const char* tags = (const char*)sqlite3_column_text(st, 3); - if (tags) snprintf(local_tags, sizeof(local_tags), "%s", tags); + int tags_type = sqlite3_column_type(st, 3), tags_bytes = sqlite3_column_bytes(st, 3); + if (tags_type != SQLITE_NULL && (tags_type != SQLITE_TEXT || !tags || tags_bytes > MS_ADM_TAGS_MAX || + strlen(tags) != (size_t)tags_bytes)) rc = SQLITE_CORRUPT; + else if (tags) memcpy(local_tags, tags, (size_t)tags_bytes + 1); local_signed_by = (uint64_t)sqlite3_column_int64(st, 4); const uint8_t* ls = (const uint8_t*)sqlite3_column_blob(st, 5); if (ls && sqlite3_column_bytes(st, 5) >= 64) memcpy(local_signature, ls, 64); @@ -554,10 +602,10 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, if (sqlite3_errcode(db) == SQLITE_NOMEM) rc = SQLITE_NOMEM; } } - sqlite3_finalize(st); - if (rc != SQLITE_ROW && rc != SQLITE_DONE) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: local record read failed ns=%s node=%016llx rc=%d: %s", - MS_ID, ch_id, (unsigned long long)m->node_id, rc, sqlite3_errmsg(db)); return MT_ERR_IO; + int final = sqlite3_finalize(st); + if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: local record read failed ns=%s node=%016llx rc=%d finalize=%d: %s", + MS_ID, ch_id, (unsigned long long)m->node_id, rc, final, sqlite3_errmsg(db)); return MT_ERR_IO; } } @@ -643,7 +691,8 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, int adm_ver = 0, adm_storage = 0; if (m->adm_tags && m->adm_tags[0] && m->adm_tags_sig && !_sig_is_zero64(m->adm_tags_sig)) { char ver_str[32]; - adm_ver = json_flat_get(m->adm_tags, "ver", ver_str, sizeof(ver_str)) == 0 ? atoi(ver_str) : 0; + adm_ver = member_sync_owner_version(db, m->adm_tags); + if (adm_ver < 0) return MT_ERR_IO; adm_storage = (json_flat_get(m->adm_tags, "storage", ver_str, sizeof(ver_str)) == 0 && strcmp(ver_str, "yes") == 0) ? 1 : 0; uint8_t ch_ed_pub[32] = {0}; int lookup = topo_node_sqlite_channel_get(db, ch_id, NULL, 0, NULL, NULL, ch_ed_pub, NULL); @@ -666,8 +715,8 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, if (block_b_ok) { int local_ver = 0; if (has_local && local_tags[0]) { - char lv[32]; - if (json_flat_get(local_tags, "ver", lv, sizeof(lv)) == 0) local_ver = atoi(lv); + local_ver = member_sync_owner_version(db, local_tags); + if (local_ver < 0) return MT_ERR_IO; } if (adm_ver > local_ver) block_b_changed = 1; else if (adm_ver < local_ver) block_b_stale = 1; @@ -776,18 +825,24 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, } void member_sync_record_committed(struct UTUN_INSTANCE* inst, const char* ns, uint64_t node_id, unsigned changes) { - merkle_sync_changed(inst, MT_DATA_MEMBERS, ns); + char channel[64]; if (!inst || member_callback_channel(ns, channel)) return; + merkle_sync_changed(inst, MT_DATA_MEMBERS, channel); if (changes & MS_APPLY_FEEDBACK) chat_feedback_member_committed(inst, node_id); - db_sync_members_changed(inst, ns); - struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, strtoull(ns, NULL, 10)) : NULL; - struct ETCP_CONN* conn = instance_find_conn(inst, node_id); + db_sync_members_changed(inst, channel); + uint64_t group_id = strtoull(channel, NULL, 10); + struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL; if (g && g->group_type == TOPO_GROUP_TYPE_CHAT) { _ms_on_bgp_node(g, node_id, 0, inst); + /* Получатель свойств вправе удалить группу/соединение;после callback берём только актуальные handles. */ + g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL; + if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT) return; + struct ETCP_CONN* conn = instance_find_conn(inst, node_id); /* Существующий UP сохраняем; новый транспорт выбирает только автопланировщик. */ if (conn && conn->links_up && !conn->close_requested && topo_group_new_conn(g, conn) < 0) - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: cannot attach UP member channel=%s node=%016llx", MS_ID, ns, + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: cannot attach UP member channel=%s node=%016llx", MS_ID, channel, (unsigned long long)node_id); - topo_group_connect_changed(g); + g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group_id) : NULL; + if (g && g->group_type == TOPO_GROUP_TYPE_CHAT) topo_group_connect_changed(g); } } @@ -800,17 +855,31 @@ int member_sync_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record begin: %s", MS_ID, sqlite3_errmsg(db)); return -1; } int rc = member_apply_record(inst, ch_id, from_peer, m); - if (rc < 0 && sqlite3_exec(db, "ROLLBACK TO member_record", NULL, NULL, NULL) != SQLITE_OK) - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record rollback: %s", MS_ID, sqlite3_errmsg(db)); - if (sqlite3_exec(db, "RELEASE member_record", NULL, NULL, NULL) != SQLITE_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record commit: %s", MS_ID, sqlite3_errmsg(db)); - if (sqlite3_exec(db, "ROLLBACK TO member_record; RELEASE member_record", NULL, NULL, NULL) != SQLITE_OK) - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record cleanup: %s", MS_ID, sqlite3_errmsg(db)); - return -1; + if (rc < 0) goto error; + int sql_rc = sqlite3_exec(db, "RELEASE member_record", NULL, NULL, NULL); + if (sql_rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record commit ch=%s rc=%d: %s", MS_ID, ch_id, sql_rc, sqlite3_errmsg(db)); goto error; } if (own && rc >= 0 && (rc & (MS_APPLY_CHANGED | MS_APPLY_FEEDBACK))) member_sync_record_committed(inst, ch_id, m->node_id, (unsigned)rc); return rc; +error: + if (!sqlite3_get_autocommit(db)) { + int rollback = sqlite3_exec(db, "ROLLBACK TO member_record", NULL, NULL, NULL); + if (rollback == SQLITE_OK) rollback = sqlite3_exec(db, "RELEASE member_record", NULL, NULL, NULL); + if (rollback != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, + "%s: record rollback ch=%s rc=%d own=%d;partial savepoint not released: %s", MS_ID, ch_id, rollback, own, sqlite3_errmsg(db)); + if (own && !sqlite3_get_autocommit(db)) { + rollback = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); + if (rollback != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record full rollback ch=%s rc=%d: %s", + MS_ID, ch_id, rollback, sqlite3_errmsg(db)); + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record transaction remains open;requesting owner stop/DB close", MS_ID); + utun_instance_stop(inst); + } + } + } + return -1; } /* ── Верификация локальной записи при чтении из БД ── diff --git a/src/chat/member_sync.h b/src/chat/member_sync.h index 861737ebf..f36d2853c 100644 --- a/src/chat/member_sync.h +++ b/src/chat/member_sync.h @@ -56,6 +56,10 @@ int member_sync_verified_ed25519(struct UTUN_INSTANCE* inst, const char* channel /* UTF-8 JSON bytes, excluding NUL. A complete record fits in one Merkle page. */ #define MS_ADM_TAGS_MAX 8192 #define MS_USERINFO_MAX 1024 +/* Проверить весь строковый JSON owner-блока,уникальность ключей и ver1..INT_MAX без ведущих нулей. + * Возвращает версию,0 для NULL/пустого блока,-1 при ошибке формата/SQL. Подпись здесь не проверяется. */ +int member_sync_owner_version(struct sqlite3* db, const char* tags); + /* Байты для подписи владельца: tags без NUL, затем node_id[8] в порядке байтов хоста. * Возвращает длину или -1, если данные/буфер не подходят. */ int member_sync_build_owner_msg(uint64_t node_id, const char* tags, uint8_t* out, size_t capacity); @@ -106,7 +110,9 @@ int member_sync_validate_pubkey(struct UTUN_INSTANCE* inst, const char* ch_id, typedef void (*member_apply_cbk_fn)(const char* ch_id, uint64_t node_id, const uint8_t* x25519, uint64_t signed_by, const uint8_t* signature, void* arg); -void member_sync_add_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg); +/* Регистрация0/-1;аргументы callback заимствованы только до его возврата. Снятие себя/других подписок + * и вложенный dispatch допустимы;освобождение откладывается. Destroy сервиса — вне callbacks. */ +int member_sync_add_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg); void member_sync_remove_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn fn, void* arg); /* @@ -210,6 +216,8 @@ int member_sync_build_join_msg(const uint8_t* ch_x25519, const uint8_t* ch_ed25 * MS_APPLY_STALE (локальная версия новее; её доставит обратный проход). * При внешней транзакции caller после commit вызывает member_sync_record_committed * для изменённой записи; до commit автоматических уведомлений нет. + * Отказ ROLLBACK TO не выполняет RELEASE частичных изменений. Владелец внешней транзакции + * при ошибке откатывает её целиком;собственная транзакция пытается полный ROLLBACK,а при его отказе запрашивает stop/DB close. * Возвращает битовую маску MS_APPLY_* (0 — без изменения индекса), <0 — ошибка. */ int member_sync_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, diff --git a/src/chat/member_sync_doc.md b/src/chat/member_sync_doc.md index d8dfef9d3..824a69837 100644 --- a/src/chat/member_sync_doc.md +++ b/src/chat/member_sync_doc.md @@ -19,6 +19,10 @@ ## Записи и применение У записи два независимо версионируемых блока: участник (join_ts, update_ts) и владелец (adm_tags.ver). +Непустой owner-блок — целый JSON-object с уникальными ключами и строковыми значениями. +ver — каноническая десятичная строка1..INT_MAX,без знака/ведущих нулей/суффикса; +пустой блок имеет версию0. Проверка member_sync_owner_version не заменяет проверку подписи. +Неверный формат или SQL-отказ не превращаются в нулевую версию для замены записи. Удаление используется для повреждённых записей. Конфликт одинаковых версий с разным содержимым не разрешается порядком доставки: отсутствие прогресса завершает Merkle-раунд ошибкой конфликта. @@ -26,11 +30,22 @@ Затем записи применяются под savepoint member_page. Ошибка откатывает всю страницу вместе с индексом. Уведомления GUI/топологии и changed выполняются после commit. Каждый member_sync_apply_record также имеет savepoint, поэтому ошибка пересчёта не оставляет запись без индекса. +Неудачный ROLLBACK TO не сопровождается RELEASE частичных данных:собственная операция +пробует полный ROLLBACK,при чужой транзакции полный откат обязан выполнить caller. При внешней транзакции вызывающая сторона после commit вызывает member_sync_record_committed с полной маской результата для каждой изменённой записи: одного changed недостаточно для уведомления свойств и планировщика группы. Локальный put доверяет блоку участника и подписи приглашения; подпись непустого блока владельца проверяется также локально. +Редактор chat_member_tags загружает проверенный owner-снимок и хранит его до commit. +Собственная BEGIN IMMEDIATE сравнивает исходное содержимое/подпись с текущими; +устаревший editor возвращает-2 без перезаписи. Версия/история ролей и подготовленный +editor обновляются только после commit;SQL/подпись/память сохраняют возможность повтора. +No-op сохраняет версию/подпись без commit hook/уведомлений. admin/moder дописывают +e/d,не удаляют историю. GUI trampoline применяет частичный объект +к загруженному снимку,сохраняя неизвестные поля и роли,не указанные формой. +Контракты загрузки/замены/commit и срок жизни editor описаны в chat_core.h. + MS_APPLY_FEEDBACK отмечает изменение удостоверения, trusted key или deleted, влияющее на feedback. Такое изменение и пересчёт зависимых feedback-листьев во всех каналах входят в транзакцию MEMBERS. Ошибка пересчёта откатывает запись, общий ключ и оба индекса; сохранённый feedback не удаляется. diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index 3bd92a6e0..68c5bb9fc 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -9,7 +9,11 @@ * при недоступности пира — через dm_mailbox (суперузлы). * - содержимое шифруется E2E (AES-256-CCM), запись подписывается Ed25519. * - * Два независимых направленных потока (я→пир и пир→я) с монотонным seq. + * Два независимых направленных потока (я→пир и пир→я) с монотонным исходящим seq. + * Все API и трамплины выполняются в uasync-потоке с живым instance/SQLite/router. + * Входные строки/тела заимствуются на время вызова; функции не забирают их владение. + * Запросы трамплинов — один блок u_malloc/u_calloc с NUL-строками; обработчик освобождает блок. + * Instance должен пережить запрос и его отложенное завершение; остановка сериализуется через uasync. */ #ifndef DM_CORE_H @@ -29,7 +33,9 @@ extern "C" { /* ── Каноническое тело DM-сообщения (общий wire-формат dm_core и dm_mailbox) ── * [conv_id:8][seq:8][ts:8][author:8][ct_len:1][ct:ct_len][data_len:2][data_enc:data_len][media?:56][sig:64] * media присутствует только для ct="file", подписан вместе с сообщением. - * data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. */ + * data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. + * Числа native-endian; seq=1..INT64_MAX, ts — миллисекунды NTP-времени instance. + * ETCP_RT_ID_DM=0x34: [MSG=1][тело] либо [ACK=2][квитанция:136]; router-пакет подписан. */ #define DM_MSG_FIXED_HDR 33 /* conv_id+seq+ts+author(32) + ct_len(1) — до ct */ #define DM_MSG_SIG_SIZE 64 #define DM_DATA_MAX 1024 @@ -54,22 +60,29 @@ static inline int dm_msg_len(const uint8_t* msg, size_t avail, size_t* out_len) return 1; } -/* Общие операции протокола DM; все вызовы в uasync-потоке. - * group_id — только область маршрутизации, идентичность беседы от неё не зависит. - * route_group выбирает общую группу с живым путём, возвращает 1/0. - * verify_message проверяет каноническое тело и подпись автора для recipient. - * accept_message возвращает 0 только после commit (включая точный повтор). - * Квитанция: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64]. - * verify_receipt проверяет подпись получателя и при msg!=NULL точное соответствие телу. - * accept_receipt удаляет только соответствующее исходящее pending-сообщение. */ +/* Выбрать общую CHAT-группу с путём router к peer, сначала preferred (0=без предпочтения). + * 1 и *group_id при успехе; 0 при отсутствии пути/ошибке, выход не меняется. + * group_id обязателен; наличие пути не гарантирует успешной последующей отправки. */ int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id); +/* Проверить точную длину msg/len, conv/author/recipient/seq, descriptor и подпись автора из nodes. + * 0=корректно, -1=отказ. Нужна SQLite, владелец dm_core не требуется. + * Не расшифровывает тело и не проверяет членство/роль router-отправителя: это обязанность вызывающего. */ int dm_verify_message(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len); +/* Проверить и расшифровать входящее тело для локального узла, атомарно сохранить историю/беседу/media. + * 0 и receipt после commit либо точного повтора; -1 при отказе. Для file нужен живой dm_media. + * Точный удалённый повтор подтверждается по отпечатку без восстановления истории; конфликт отклоняется. + * receipt — обязательные 136 байт; при -1 буфер не использовать. Ошибка подписи квитанции возможна после commit. */ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len, uint8_t receipt[DM_RECEIPT_SIZE]); +/* receipt — ровно 136 доступных байт: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64]. + * Проверяет идентичность и подпись B по первым 72 байтам; при msg!=NULL также точное тело msg/len. + * 0=корректно, -1=отказ. Не проверяет наличие outbox и ничего не записывает. */ int dm_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE], const uint8_t* msg, size_t len); +/* Для локального автора проверить receipt и удалить только точный outbox; история/file receipt сохраняются. + * 0=удалено либо outbox уже отсутствует, -1=ошибка/конфликт. Требуется запущенный dm_core. */ int dm_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE]); /* Жизненный цикл в uasync: init после chat_core_init с SQLite/router/ua ядра; 0=успех, -1=ошибка. - * Повторный init запущенного владельца возвращает 0. Для send/accept нужен также dm_media_init. + * Повторный init запущенного владельца возвращает 0. Для file-send/accept нужен также dm_media_init. * destroy отменяет invite/таймер и снимает bind; БД/outbox сохраняются. Вызывать до chat_core_destroy. */ int dm_core_init(struct UTUN_INSTANCE* inst); void dm_core_destroy(struct UTUN_INSTANCE* inst); @@ -99,9 +112,16 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type, const uint8_t* data, uint32_t data_len); -/* Локальное удаление; generation защищает повторно открытую беседу от старого запроса. */ -struct dm_delete_req { struct UTUN_INSTANCE* inst; char conv_id[64]; uint64_t generation, request_id; }; +/* Локальное удаление по dm_history_delete; generation защищает повторно открытую беседу. + * CHAT_EVT_DM_DELETE_RESULT: [request_id:8][conv:u64][generation:8][success:u8], всего 25 байт native-endian. */ +struct dm_delete_req { + struct UTUN_INSTANCE* inst; /* Заимствованное ядро до завершения запроса. */ + char conv_id[64]; /* Десятичный ID беседы с NUL. */ + uint64_t generation, /* Ненулевое ожидаемое поколение. */ + request_id; /* Корреляция результата с запросом UI. */ +}; void dm_delete_trampoline(void* arg); +/* После commit удаления отменить ожидающий invite этой беседы; квитанции/другие беседы не меняет. */ void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv); /* До 200 видимых бесед по last_ts DESC: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq,generation}]. @@ -113,7 +133,9 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf * count=1..200 задаёт предел; count<=0 или count>200 означает 200. * offset>=0 отсчитывается от новых сообщений; offset=0 выбирает последние count. * Выбранная страница возвращается по возрастанию ts, seq, dir. - * buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. */ + * buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. + * pending относится к квитанции сообщения. media_status: delivered/ready/downloading/expired/pending. + * hidden здесь не фильтруется; интерфейс выбирает видимую беседу по списку/поколению. */ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int count, int offset, char* buf, size_t buf_size, size_t* out_len); @@ -123,20 +145,20 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c * пира в таблице nodes (как headless hc_handle_dm_start), после чего вызывает * dm_start(). Результат — событие CHAT_EVT_DM_CONV_UPDATED. */ struct dm_start_req { - struct UTUN_INSTANCE* inst; - uint64_t peer_node_id; - char source_ch_id[64]; - char peer_name[128]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + uint64_t peer_node_id; /* Другой участник общей группы; ключи читаются из nodes. */ + char source_ch_id[64]; /* Десятичный ID общей группы либо пустая строка. */ + char peer_name[128]; /* UTF-8 имя с NUL; dm_start ограничит до 63 байт. */ }; void dm_start_trampoline(void* arg); /* Отправить текстовое сообщение в беседу (content_type="text"). */ struct dm_send_req { - struct UTUN_INSTANCE* inst; - char conv_id[64]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + char conv_id[64]; /* Десятичный ID беседы с NUL. */ uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */ - uint32_t data_len; - uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */ + uint32_t data_len; /* Число байт текста, до DM_DATA_MAX. */ + uint8_t* data; /* Указывает на data_len байт в том же блоке после структуры. */ }; void dm_send_trampoline(void* arg); @@ -151,12 +173,15 @@ enum dm_invite_result { DM_INVITE_SEND_ERROR = -3, DM_INVITE_CANCELLED = -4, DM_INVITE_BUSY = -5 }; struct dm_invite_req { - struct UTUN_INSTANCE* inst; - uint64_t request_id, peer_node_id, channel_id, generation; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро до итогового 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]; - int cancel; - struct chat_invite_build* pending; /* только uasync */ + char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */ + int cancel; /* 0=создать, 1=отменить ожидающий request_id. */ + struct chat_invite_build* pending; /* Внутренний handle; запрос создаётся обнулённым. */ }; void dm_invite_trampoline(void* arg); @@ -164,15 +189,18 @@ void dm_invite_trampoline(void* arg); * [conv_id_len:1][conv_id:var][generation:8][JSON:utf8]. Все чтения в uasync. * Ненулевое generation проверяется до чтения; 0 захватывает текущее поколение. GUI отбрасывает старый снимок. */ struct dm_messages_req { - struct UTUN_INSTANCE* inst; - char conv_id[64]; + struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ + char conv_id[64]; /* Десятичный ID с NUL, также для запроса маршрута звонка. */ uint64_t generation; /* Поколение выбранного PM; для запроса маршрута не используется. */ }; void dm_messages_trampoline(void* arg); -/* Живой BGP-маршрут PM; group_id=0 в снимке означает недоступность. Все вызовы в uasync. */ +/* 1 при живом BGP-маршруте общей группы, 0 при недоступности/ошибке. peer/group_id обязательны. + * При корректных аргументах выходы сначала обнуляются; peer может быть известен и без маршрута. + * Трамплин выдаёт CHAT_EVT_DM_CALL_ROUTE: [conv_len:1][conv][peer:8][group:8]; group=0 без маршрута. */ int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id); void dm_call_route_trampoline(void* arg); /* аргумент: dm_messages_req */ -struct dm_conversations_req { struct UTUN_INSTANCE* inst; }; +/* Снимок бесед публикуется как CHAT_EVT_DM_CONVERSATIONS с JSON без NUL. */ +struct dm_conversations_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ }; void dm_conversations_trampoline(void* arg); #ifdef __cplusplus diff --git a/src/dm/dm_history.h b/src/dm/dm_history.h index 22eb42121..9c906de58 100644 --- a/src/dm/dm_history.h +++ b/src/dm/dm_history.h @@ -1,7 +1,9 @@ /* Локальная история DM: поколения, скрытые беседы и точные отпечатки удалённых сообщений. * Все операции выполняются в uasync с SQLite ядра. init вызывается после создания DM-таблиц. * Удаление скрывает беседу и сохраняет её ключи/счётчики в dm_conversations; show открывает её снова. - * Поколение передавать в отложенные операции и проверять current перед применением результата. */ + * Поколение передавать в отложенные операции и проверять current перед применением результата. + * Беседой/seq владеет dm_core, файлами/загрузками — dm_media; этот модуль связывает их удаление. + * Аргументы заимствованы на время вызова; самостоятельные транзакции выполняются без внешней транзакции. */ #ifndef DM_HISTORY_H #define DM_HISTORY_H #include @@ -10,7 +12,8 @@ extern "C" { #endif struct UTUN_INSTANCE; -/* Создать служебные таблицы, сбросить busy файлов после restart. 0=успех, -1=ошибка. +/* Создать dm_history, dm_deleted_messages и dm_local_files; сбросить busy после restart. + * 0=успех, -1=ошибка. Вызывает dm_core_init; повторять при живых workers нельзя: они ещё держат пути. * inst и его topo_sqlite_db обязательны; частично созданные таблицы при ошибке остаются. */ int dm_history_init(struct UTUN_INSTANCE* inst); /* Поколение существующей беседы: 1 до первого удаления; 0 при отсутствии/ошибке. @@ -28,8 +31,11 @@ int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv); int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt); /* Удалить видимую беседу текущего generation