Merge desktop audit with updated chat contracts

Keep the complete member snapshot limit at 65535 and preserve SQL finalization checks, unsigned sorting, and owned GUI commands. Retain dynamic ETCP diagnostics with checked event lengths and no partial event on allocation failure. Validated three native and six GUI test targets and rebuilt vibechat.
This commit is contained in:
evgeny committed 2026-10-27 11:12:51 +01:00
commit 6429d4ca23
76 files changed
+8626 -1543

No files matched your search

+786
View File
@@ -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<seconds>/d<seconds> при смене роли,включая удаление желаемого ключа.
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.
+77 -12
View File
@@ -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`.
+102 -1
View File
@@ -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.
+122 -6
View File
@@ -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 и очистка после их завершения удаляют оставшиеся
`<UUID>.uenc.tmp.<UUID>` / `<UUID>.file.tmp.<UUID>` в `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/<channel>/<UUID>.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.
+23 -2
View File
@@ -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.
+23 -2
View File
@@ -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.
+16
View File
@@ -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;
+7 -1
View File
@@ -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
+147 -10
View File
@@ -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;внешняя транзакция остаётся нетронутой.
+41
View File
@@ -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;
}
+2 -1
View File
@@ -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 при ошибке; остановка не проверяет остаток ввода.
* Синтаксис проверяется нестрого: допускаются пропущенные и завершающие запятые;
+9 -1
View File
@@ -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;
+3
View File
@@ -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})
+755 -177
View File
File diff suppressed because it is too large. Load diff
+32 -14
View File
@@ -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
}
+179 -142
View File
@@ -32,30 +32,34 @@
#include <openssl/sha.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/crypto.h>
#include <errno.h>
#include <stdlib.h>
/* ─── 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);
+458 -317
View File
@@ -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 <string.h>
#include <stdio.h>
#include <limits.h>
#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:<ts>" */
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);
}
+87 -44
View File
@@ -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_<id>_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/<sha256>.json внутри дерева своего UID/канала. server=0 удаляет ссылку.
/* Ссылка avatars/<sha256>.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<seconds>. Приватный ключ очищается.
* Уведомления после 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];
+23 -2
View File
@@ -19,6 +19,8 @@
#include <stdint.h>
#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);
+8 -1
View File
@@ -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) {
+4 -3
View File
@@ -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 отключает подтверждение; вызывается один раз после сохранения либо ошибки подготовки.
+98 -43
View File
@@ -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) {
+23 -10
View File
@@ -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
+4 -2
View File
@@ -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) {
+6 -2
View File
@@ -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);
+49 -65
View File
@@ -21,14 +21,14 @@
#include "../../lib/platform_compat.h"
#include <stdlib.h>
#include <limits.h>
/* ─── 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) {
+34 -23
View File
@@ -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. */
+132 -41
View File
@@ -29,6 +29,8 @@
#include <openssl/rand.h>
#include <stdlib.h>
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) {
+117 -43
View File
@@ -11,6 +11,8 @@
#include <string.h>
#include <errno.h>
#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 всегда идут вместе. */
+19 -10
View File
@@ -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
+266 -128
View File
@@ -24,6 +24,7 @@
#include "../../lib/platform_compat.h"
#include <openssl/evp.h>
#include <limits.h>
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, &current);
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);
}
+22 -21
View File
@@ -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;
}
+21 -9
View File
@@ -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);
+31 -29
View File
@@ -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 <limits.h>
#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);
+12 -16
View File
@@ -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;
}
+168 -79
View File
@@ -12,6 +12,7 @@
#include "../../lib/mem.h"
#include "../../lib/u_async.h"
#include <openssl/sha.h>
#include <openssl/crypto.h>
#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, &current) < 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, &current) < 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) {
+27 -11
View File
@@ -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 <stdint.h>
@@ -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
+104 -35
View File
@@ -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;
}
/* ── Верификация локальной записи при чтении из БД ──
+9 -1
View File
@@ -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,
+15
View File
@@ -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<seconds>/d<seconds>,не удаляют историю. GUI trampoline применяет частичный объект
к загруженному снимку,сохраняя неизвестные поля и роли,не указанные формой.
Контракты загрузки/замены/commit и срок жизни editor описаны в chat_core.h.
MS_APPLY_FEEDBACK отмечает изменение удостоверения, trusted key или deleted, влияющее на feedback.
Такое изменение и пересчёт зависимых feedback-листьев во всех каналах входят в транзакцию MEMBERS.
Ошибка пересчёта откатывает запись, общий ключ и оба индекса; сохранённый feedback не удаляется.
+59 -31
View File
@@ -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
+10 -4
View File
@@ -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 <stdint.h>
@@ -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<INT64_MAX; DM/core/media должны быть запущены.
* Самостоятельная транзакция: отпечатки, удаление истории/outbox/медиа, hidden=1, generation+1.
* 0 после commit и синхронной отмены операций; -1 при отказе с rollback SQL-изменений.
* Файлы убираются отдельно владельцем media; встречный узел и его историю вызов не меняет. */
* 0 после commit, отмены invite/transfer и запуска уборки; -1 при отказе с rollback SQL-изменений.
* Это не ожидание workers: они сохраняют busy пути и отбрасывают результат старого поколения.
* Файлы убираются отдельно по durable манифесту после release; ошибку удаления повторяет media.
* Квитанция файла сохраняется только если уже была: удаление не подтверждает скачивание.
* Встречный узел, его история и промежуточные S/M-копии этим вызовом не меняются. */
int dm_history_delete(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation);
#ifdef __cplusplus
}
+11 -3
View File
@@ -6,7 +6,13 @@
* Копия удаляется только по квитанции получателя после commit в dm_core.
* Квитанция сохраняется отдельно для повторов PUT и доставки отправителю.
*
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); контрольные пакеты подписаны router.
* Роль S — supernode=yes в подписанных adm_tags общей CHAT-группы A/B; storage её не заменяет.
* При PUT от A в одной транзакции фиксируются сообщение и, для file, задание dm_mailbox_media.
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); все пакеты подписаны router, числа native-endian:
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8].
* STORED подтверждает запись на S, только подписанная B квитанция подтверждает доставку сообщения.
* Текст не имеет TTL. Таймер 1с обрабатывает до 32 задач с retry_at через 5с.
* Входные буферы заимствуются на время вызова; их копией в пакете/SQLite владеет соответствующий owner.
*/
#ifndef DM_MAILBOX_H
@@ -37,10 +43,12 @@ void dm_mailbox_destroy(struct UTUN_INSTANCE* inst);
int dm_mailbox_put(struct UTUN_INSTANCE* inst, uint64_t recipient, uint64_t sender,
const uint8_t* msg, size_t msg_len);
/* Передать точную квитанцию всем доступным суперузлам общей группы сторон.
/* Попытаться передать точную квитанцию всем доступным суперузлам общих групп сторон.
* receipt — DM_RECEIPT_SIZE байт (см. dm_core.h), проверяется подпись получателя.
* Это попытка отправки текущим доступным S; собственную durable очередь пересылки вызов не создаёт.
* Источник продолжает повторять PUT до получения этой квитанции. */
* Источник продолжает повторять PUT до получения этой квитанции.
* На S сохранение receipt и удаление точного pending атомарны; повтор PUT возвращает receipt.
* Локальный S здесь пропускается; вызов нужен endpoint, а не для локального применения на S. */
void dm_mailbox_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
#ifdef __cplusplus
+11 -3
View File
@@ -2,6 +2,11 @@
* метаданных получателю и освобождение назначений по DELETE_ACK/EXPIRED либо подтверждённому TTL.
* Жизненный цикл принадлежит dm_mailbox, control принимает dm_media. Все API в uasync.
* Координатор хранится в dm_media_jobs/dm_media_holders SQLite ядра; таймер — только триггер повторов.
* Таймер 1с обрабатывает до 8 задач, retry_at через 5с; выбранный M сохраняется до STORE.
* Выбор M: storage=yes в общей группе A/B, достижимый узел по node_id, кроме A/B и прежних назначений.
* S может одновременно быть M: local control проходит тот же обработчик без router-соединения к себе.
* SQLite и группы принадлежат ядру; instance должен пережить owner. Входные тела заимствованы до возврата.
* Из C++ этот внутренний C API включать внутри extern "C".
*/
#ifndef DM_MAILBOX_MEDIA_H
#define DM_MAILBOX_MEDIA_H
@@ -17,21 +22,24 @@ struct dm_mb_media* dm_mailbox_media_create(struct UTUN_INSTANCE* inst);
/* Отменить таймер и освободить owner, сохранив задания/назначения в БД. NULL допустим. */
void dm_mailbox_media_destroy(struct dm_mb_media* media);
/* Вызвать в транзакции PUT после проверки роли S, получателя и подписи полного msg/len.
* 0=не file либо зарегистрирован точный body/UUID, -1=ошибка/конфликт. Не отправляет STORE.
* 0=не file либо зарегистрирован точный SHA256 полного body для UUID, -1=ошибка/конфликт. Не отправляет STORE.
* media обязателен для file; commit/rollback принадлежит вызывающему. */
int dm_mailbox_media_register(struct dm_mb_media* media, uint64_t group, uint64_t recipient,
const uint8_t* msg, size_t len);
/* После проверки router-подписи и размера команды в dm_media: group/source — доверенные поля router.
* body заимствован на время вызова; RECEIPT=152, STORED=24, DELETE_ACK/EXPIRED=16, REJECT=17 байт.
* RECEIPT имеет формат dm_media.h; STORED=[UUID:16][expires:8], ACK/EXPIRED=[UUID:16], REJECT=[UUID:16][reason:1].
* STORED/REJECT/ACK относятся к сохранённому назначению M; RECEIPT проверяется отдельно подписью B.
* EXPIRED/REJECT атомарно освобождают назначение и сбрасывают текущий holder; ошибка откатывает оба изменения.
* Таймер завершает stored-назначение по expires даже при offline B/потерянном EXPIRED, также после restart.
* После квитанции тело удаляется только при отсутствии неосвобождённых назначений; ошибка сканирования откладывает очистку.
* После квитанции повторяется DELETE до 16 неосвобождённым M за проход cleanup.
* Тело удаляется только при отсутствии неосвобождённых назначений; ошибка сканирования откладывает очистку.
* Для неподтверждённого STORE повтор запрашивает результат из custody/tombstone M; квитанция B не отменяется. */
void dm_mailbox_media_recv(struct dm_mb_media* media, uint64_t group, uint64_t source,
uint8_t cmd, const uint8_t* body, size_t len);
/* Проверить 152-байтовую квитанцию и попытаться отправить доступным S общей группы.
* За один вызов выбирается до64 подходящих S на группу; поздние копии также получают подтверждение. */
* За один вызов выбирается до 64 подходящих S на группу, включая локальный S; A/B исключаются.
* Это попытка отправки; durable повтор обеспечивает квитанция в dm_files/dm_media_jobs, а не сам вызов. */
void dm_mailbox_media_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
#endif
+59 -15
View File
@@ -1,10 +1,13 @@
/* dm_media — файлы личных бесед и ограниченное ciphertext-хранилище M.
* Шифрование/проверка/публикация файлов в media_async; БД и сеть в uasync.
* Шифрование/проверка/публикация файлов в media_async; все API ниже, БД и сеть — в uasync.
* dm_core вызывает track внутри своей транзакции. Квитанция файла независима
* от квитанции сообщения; создаётся после проверки, публикации и commit.
* от квитанции сообщения; публикуется после проверки, публикации файла и commit.
* Суперузлом и заданиями удаления владеет dm_mailbox_media.
* Квота M проверяется после успешного чтения занятого места и finalize SQL-запроса.
* Ошибки SQL логируются без REJECT, сохраняя штатный повтор STORE со стороны S.
* Instance/SQLite/группы должны пережить workers и их callbacks; остановка — quiesce, drain, destroy.
* Входные строки/буферы заимствуются до возврата, кроме явно переданного временного файла.
* Из C++ этот C API включать внутри extern "C", как в клиентах Qt/JNI.
*/
#ifndef DM_MEDIA_H
#define DM_MEDIA_H
@@ -15,10 +18,17 @@
struct UTUN_INSTANCE;
#define DM_MEDIA_DESC_SIZE 56 /* UUID:16, plaintext_size:8, SHA256(ciphertext):32 */
#define DM_MEDIA_DESC_SIZE 56 /* UUID:16, plaintext_size:8 native-endian, SHA256(ciphertext):32. */
#define DM_MEDIA_RECEIPT_SIZE 152
#define DM_MEDIA_MAX_SIZE (1024ULL * 1024 * 1024)
/* ETCP_RT_ID_DM_MEDIA=0x38: [cmd:1][body], router-подпись обязательна; числа native-endian.
* OFFER: [recipient:8][holder:8][expires:8][каноническое file-сообщение]. A предлагает себя с expires=0.
* STORE: [recipient:8][file-сообщение] от S к M; STORED: [UUID:16][expires:8].
* RECEIPT/DELETE: ["DMFILE01":8][conv:8][seq:8][author:8][recipient:8][UUID:16][SHA256(message):32][sig_B:64].
* B подписывает первые 88 байт; DELETE переносит ту же квитанцию, не создаёт новую.
* DELETE_ACK/EXPIRED: [UUID:16]; REJECT: [UUID:16][reason:1], reason=1 — недостаток квоты.
* EXPIRED завершает промежуточное хранение, а не подтверждает доставку B. */
#define DM_MEDIA_OFFER 1
#define DM_MEDIA_STORE 2
#define DM_MEDIA_STORED 3
@@ -28,47 +38,81 @@ struct UTUN_INSTANCE;
#define DM_MEDIA_EXPIRED 7
#define DM_MEDIA_REJECT 8
/* После dm_core_init с SQLite/config/router/ua и media_async ядра: таблицы, каталоги, file_transfer, bind и таймер.
* 0=успех, -1=ошибка, в том числе повторный init живого owner. При отказе созданные таблицы/каталоги остаются.
* Корень: db_path/dm_media и db_path/dm_pending; без db_path используется текущий каталог.
* После restart сбрасываются downloading/retry_at; история, custody и квитанции сохраняются. */
int dm_media_init(struct UTUN_INSTANCE* inst);
/* quiesce до media_async_destroy; destroy после workers, до групп/core/mailbox. */
/* Идемпотентно запретить новые задачи, отменить таймер/bind и file_transfer с его CM handles.
* Вызвать до media_async_destroy; callbacks отмены ещё используют живые owner/SQLite/группы. NULL допустим. */
void dm_media_quiesce(struct UTUN_INSTANCE* inst);
/* После drain/destroy media_async, до групп/core/mailbox: освободить owner, сохранить durable данные.
* При живых jobs/cleanup_active логирует ошибку и оставляет owner для повторного destroy. NULL допустим. */
void dm_media_destroy(struct UTUN_INSTANCE* inst);
/* Передать подготовку копии и enqueue file-сообщения в media_async.
* Строки conv/path и info копируются при вызове; сам файл должен быть доступен worker до завершения.
* 0=submit вызван, -1=отказ до submit. Submit может синхронно завершиться ошибкой через callback;
* готовность/ошибка подготовки сообщается CHAT_EVT_ATTACHMENT_PREPARATION, включая до возврата0.
* remove_source=1 передаёт worker собственный временный файл приложения для удаления; при -1 его убирает caller. */
* готовность/ошибка подготовки сообщается CHAT_EVT_ATTACHMENT_PREPARATION, включая до возврата 0.
* Беседа должна быть видимой; до 4 одновременных подготовок, plaintext до DM_MEDIA_MAX_SIZE.
* remove_source=1 передаёт worker собственный временный файл приложения для удаления; при -1 его убирает caller.
* Успех подготовки означает commit file-сообщения, не доставку. Пользовательский исходник: remove_source=0. */
int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, const struct attachment_info* info,
int remove_source);
struct dm_file_req {
struct UTUN_INSTANCE* inst;
char conv[64];
uint64_t generation;
char path[1024];
struct attachment_info info;
struct UTUN_INSTANCE* inst; /* Заимствованное ядро до callback. */
char conv[64]; /* Десятичный ID беседы с NUL. */
uint64_t generation; /* Ожидаемое поколение; 0=захватить при входе в uasync. */
char path[1024]; /* Путь исходника с NUL; копируется при принятии. */
struct attachment_info info; /* Проверенные метаданные вложения. */
int remove_source; /* Только принадлежащий приложению временный файл. */
};
/* arg — один блок u_malloc/u_calloc(dm_file_req); трамплин освобождает его, также при отказе.
* Проверяет generation до submit; непринятый remove_source удаляет через worker. */
void dm_send_file_trampoline(void* arg);
/* Внутри транзакции dm_core, 0=записано/точный повтор. Для text — пустая операция. */
/* Внутри транзакции dm_core после проверки канонического msg/len; dir: 0=входящее, 1=исходящее.
* 0=записано/точный повтор UUID и тела, -1=ошибка/конфликт; rollback делает вызывающий.
* Для не-file — пустая операция. Исходящий file требует уже подготовленного .file нужного размера. */
int dm_media_track(struct UTUN_INSTANCE* inst, int dir, const uint8_t* msg, size_t len);
/* Заимствованный descriptor внутри точного канонического file-тела либо NULL.
* Проверяет только формат/длину, не подпись и не размер/хеш файла; указатель живёт вместе с msg. */
const uint8_t* dm_message_media(const uint8_t* msg, size_t len);
/* Ciphertext bytes для plain_size<=DM_MEDIA_MAX_SIZE: plaintext плюс 16 байт на порцию 32768 (для пустого — 16). */
uint64_t dm_media_cipher_size(uint64_t plain_size);
/* receipt — обязательные 152 доступных байта; подпись B/идентичность проверяются по nodes SQLite.
* При msg!=NULL проверяется соответствие каноническому file-телу msg/len, UUID и полному SHA256.
* 0=корректно, -1=отказ. Ничего не записывает, факт скачивания этим вызовом не устанавливается. */
int dm_media_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt, const uint8_t* msg, size_t len);
/* Для локального A проверить 152-байтовую квитанцию и сохранить её в точном исходящем dm_files.
* 0=сохранена/повтор либо точное подтверждение уже удалённого файла; -1=ошибка/неизвестное тело.
* Историю и собственный файл A не удаляет; text outbox от этой квитанции не меняется. */
int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
/* Удаление: prepare внутри транзакции, committed после commit (callbacks отмены синхронны). */
/* Внутри транзакции dm_history_delete: сохранить свои пути в манифесте поколения и удалить dm_files беседы.
* 0=успех, -1=ошибка; требует живого dm_media, commit/rollback делает вызывающий. Не удаляет файлы/transfer. */
int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation);
/* Только после commit: отменить transfer удалённых UUID и начать асинхронную уборку старых поколений.
* Callback отмены transfer синхронен; ожидания workers здесь нет, busy защищает их пути. */
void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv);
/* Владение путями worker: hold до запуска, release после завершения. Уборка ждёт busy=0. */
/* Зарегистрировать принадлежащий приложению path и увеличить busy до запуска worker/transfer.
* conv/generation должны быть текущими и видимыми; 0=захват, -1=ошибка/чужое поколение пути. */
int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path);
/* Освободить один успешный hold после callback; SQL/нарушение счётчика логируются.
* Уборка старого поколения ждёт освобождения всех holds; busy=-1 означает захват уборщиком. */
void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path);
/* Общие операции control для endpoint/M и mailbox/S. Все пакеты подписаны. */
/* Отправить cmd/body/len через подписанный router в group к peer; нужен живой dm_media.
* 0=пакет принят транспортом, ненулевое значение=ошибка/backpressure. Durable очередь не создаёт.
* Локальный peer обрабатывается синхронно: 0 означает вызов обработчика, не его durable успех.
* body заимствован до возврата; вызывающий соблюдает формат команды, size<=DM_MEDIA_BODY_MAX+24. */
int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
uint8_t cmd, const uint8_t* body, size_t len);
/* 1, если node/author/recipient — живые мемберы group и node имеет role=yes в adm_tags.
* role=NULL проверяет только членство; 0 при отсутствии полномочий/ошибке SQL. Не проверяет сетевой путь. */
int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node,
const char* role, uint64_t author, uint64_t recipient);
/* Опубликовать CHAT_EVT_DM_MSG_RECEIVED [conv_len:1][conv:decimal][local_node:8] для обновления снимка UI. */
void dm_media_notify(struct UTUN_INSTANCE* inst, uint64_t conv);
#endif
+11 -4
View File
@@ -1,4 +1,7 @@
/* Внутренние SQL/control операции двух владельцев DM media; снаружи не использовать. */
/* Внутренние SQL/control операции dm_media и dm_mailbox_media; снаружи не использовать.
* Все вызовы в uasync с живыми inst/topo_sqlite_db. Workers не обращаются к этим helpers.
* Соединением SQLite владеет ядро; вызовы не закрывают его и не меняют внешнее владение транзакцией.
* Из C++ включать внутри extern "C". */
#ifndef DM_MEDIA_PRIV_H
#define DM_MEDIA_PRIV_H
@@ -6,17 +9,21 @@
#include "dm_core.h"
#include <sqlite3.h>
/* Верхняя граница канонического тела с ct<=255, CCM tag, descriptor и подписью; без cmd/router-заголовка. */
#define DM_MEDIA_BODY_MAX (DM_MSG_FIXED_HDR + 255 + 2 + DM_DATA_MAX + 16 + DM_MEDIA_DESC_SIZE + DM_MSG_SIG_SIZE)
/* SQLite ядра, только uasync: prepare возвращает statement либо NULL с логом ошибки.
* inst/db/sql обязательны; вызывающий связывает параметры и освобождает statement. */
* inst/db/sql обязательны; вызывающий проверяет bind/step для чтения и всегда освобождает statement.
* Для записи владение можно передать dm_media_step; prepare сам транзакцию не начинает. */
sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql);
/* Шаг записи, затем finalize (включая ошибку): statement передаётся во владение helper.
* 0 только при SQLITE_DONE и успешном finalize, иначе -1 с логом; внутри транзакции rollback делает вызывающий. */
* 0 только при SQLITE_DONE и успешном finalize, иначе -1 с логом; NULL даёт -1 после ошибки prepare.
* Не предназначен для SELECT; bind проверяет вызывающий, внутри транзакции rollback делает вызывающий. */
int dm_media_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st);
/* Выполнить SQL без результата: 0/-1 с логом. Владение транзакцией определяется SQL вызывающего. */
int dm_media_exec(struct UTUN_INSTANCE* inst, const char* sql);
/* Заимствованный координатор mailbox либо NULL при остановленном/отсутствующем inst. */
/* Заимствованный координатор mailbox либо NULL при остановленном/отсутствующем inst.
* Не освобождать; действителен до dm_mailbox_destroy в том же uasync-потоке. */
struct dm_mb_media* dm_mailbox_get_media(struct UTUN_INSTANCE* inst);
#endif
+2 -1
View File
@@ -818,7 +818,8 @@ int media_download_start(struct UTUN_INSTANCE* inst, uint64_t group_id,
/* дедуп: та же медиа уже качается */
if (md_dl_find(inst, result->media_id)) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: DUPLICATE download media=%02x%02x... ignored", MDL_ID, result->media_id[0], result->media_id[1]);
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "%s: duplicate request ignored, existing transfer retained media=%02x%02x...",
MDL_ID, result->media_id[0], result->media_id[1]);
return -1;
}
char channel[32]; snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group_id);
+17 -10
View File
@@ -116,20 +116,27 @@ int member_files_member(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t nod
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "member_files: membership query failed group=%llu: %s",
(unsigned long long)group, sqlite3_errmsg(inst->topo_sqlite_db)); return 0;
}
sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
int rc = sqlite3_step(st);
char role[16]; const char* tags = (const char*)sqlite3_column_text(st, 0);
int storage_role = tags && json_flat_get(tags, "storage", role, sizeof(role)) == 0 && !strcmp(role, "yes");
int allowed = rc == SQLITE_ROW && !sqlite3_column_int(st, 1) && !sqlite3_column_int(st, 3) &&
(!storage || (storage_role && sqlite3_column_bytes(st, 2) == 64));
const char* stage = "bind"; int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(st); }
int source = -1, deleted = -1, signature = 0, storage_role = 0, allowed = 0;
if (rc == SQLITE_ROW) {
char role[16]; const char* tags = (const char*)sqlite3_column_text(st, 0);
source = sqlite3_column_int(st, 1); deleted = sqlite3_column_int(st, 3); signature = sqlite3_column_bytes(st, 2);
storage_role = tags && json_flat_get(tags, "storage", role, sizeof(role)) == 0 && !strcmp(role, "yes");
allowed = !source && !deleted && (!storage || (storage_role && signature == 64));
}
DEBUG_TRACE(DEBUG_CATEGORY_MEDIA,
"member_files: ACL allowed=%d group=%llu node=%llu require_storage=%d row=%d storage=%d source=%d signature=%d deleted=%d",
allowed, (unsigned long long)group, (unsigned long long)node, storage, rc == SQLITE_ROW,
storage_role, sqlite3_column_int(st, 1), sqlite3_column_bytes(st, 2), sqlite3_column_int(st, 3));
storage_role, source, signature, deleted);
if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA,
"member_files: access check failed group=%llu node=%llu require_storage=%d sqlite_rc=%d: %s",
(unsigned long long)group, (unsigned long long)node, storage, rc, sqlite3_errmsg(inst->topo_sqlite_db));
sqlite3_finalize(st); return allowed;
"member_files: access check failed group=%llu node=%llu require_storage=%d stage=%s rc=%d (%s): %s",
(unsigned long long)group, (unsigned long long)node, storage, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(inst->topo_sqlite_db));
int final = sqlite3_finalize(st);
if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA,
"member_files: access finalize failed group=%llu node=%llu sqlite_rc=%d: %s",
(unsigned long long)group, (unsigned long long)node, final, sqlite3_errmsg(inst->topo_sqlite_db));
return allowed && final == SQLITE_OK;
}
int mf_server_allowed(struct member_files* f, uint64_t group, uint64_t peer, const struct mf_message* q) {
+40 -19
View File
@@ -478,9 +478,10 @@ static int radio_load_muted_members(struct radio_ctx* ctx) {
}
members = next; members[count++] = (uint64_t)sqlite3_column_int64(st, 0);
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read muted members: %s", sqlite3_errmsg(db)); u_free(members); return -1;
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read muted members rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(db));
u_free(members); return -1;
}
u_free(ctx->muted_members); ctx->muted_members = members; ctx->muted_count = count; ctx->muted_loaded = 1;
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio: loaded local mutes grp=%016llx count=%d",
@@ -524,34 +525,54 @@ int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio: invalid local mute grp=%016llx node=%016llx muted=%d",
(unsigned long long)group_id, (unsigned long long)node_id, muted); return -1;
}
if (!ctx->muted_loaded && radio_load_muted_members(ctx) < 0) return -1;
int slot = -1;
sqlite3* db = inst->topo_sqlite_db;
if (!db || !sqlite3_get_autocommit(db)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute requires database without external transaction grp=%016llx",
(unsigned long long)group_id); return -1;
}
int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* radio mute */", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute begin grp=%016llx rc=%d: %s",
(unsigned long long)group_id, rc, sqlite3_errmsg(db)); return -1;
}
sqlite3_stmt* st = NULL; const char* stage = "load cache"; int slot = -1;
if (!ctx->muted_loaded && radio_load_muted_members(ctx) < 0) { rc = SQLITE_ERROR; goto error; }
for (int i = 0; i < ctx->muted_count; i++) if (ctx->muted_members[i] == node_id) { slot = i; break; }
/* Выделить память до записи БД: после commit применение не может отказать. */
if (muted && slot < 0) {
uint64_t* next = u_realloc(ctx->muted_members, (size_t)(ctx->muted_count + 1) * sizeof(*next));
if (!next) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: grow muted member cache failed"); return -1; }
if (!next) { stage = "reserve cache"; rc = SQLITE_NOMEM; goto error; }
ctx->muted_members = next;
}
sqlite3* db = inst->topo_sqlite_db;
char sql[160];
snprintf(sql, sizeof(sql), "UPDATE \"peers_%llu\" SET radio_muted=? WHERE node_id=?", (unsigned long long)group_id);
sqlite3_stmt* st = NULL;
if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: prepare local mute: %s", db ? sqlite3_errmsg(db) : "no database"); return -1;
}
sqlite3_bind_int(st, 1, muted); sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id);
int rc = sqlite3_step(st); sqlite3_finalize(st);
if (rc != SQLITE_DONE || sqlite3_changes(db) != 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: save local mute grp=%016llx node=%016llx rc=%d changed=%d: %s",
(unsigned long long)group_id, (unsigned long long)node_id, rc, sqlite3_changes(db), sqlite3_errmsg(db)); return -1;
}
stage = "prepare"; rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); if (rc != SQLITE_OK) goto error;
stage = "bind mute"; rc = sqlite3_bind_int(st, 1, muted); if (rc != SQLITE_OK) goto error;
stage = "bind member"; rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id); if (rc != SQLITE_OK) goto error;
stage = "step"; rc = sqlite3_step(st); if (rc != SQLITE_DONE) goto error;
stage = "changes"; if (sqlite3_changes(db) != 1) { rc = SQLITE_CONSTRAINT; goto error; }
stage = "finalize"; rc = sqlite3_finalize(st); st = NULL; if (rc != SQLITE_OK) goto error;
stage = "commit"; rc = sqlite3_exec(db, "COMMIT /* radio mute */", NULL, NULL, NULL); if (rc != SQLITE_OK) goto error;
if (muted && slot < 0) ctx->muted_members[ctx->muted_count++] = node_id;
else if (!muted && slot >= 0) ctx->muted_members[slot] = ctx->muted_members[--ctx->muted_count];
radio_refresh_member_mute(inst, group_id, node_id);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: local member mute grp=%016llx node=%016llx muted=%d",
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio: local member mute grp=%016llx node=%016llx muted=%d committed=1",
(unsigned long long)group_id, (unsigned long long)node_id, muted);
radio_refresh_member_mute(inst, group_id, node_id);
return 0;
error:
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute grp=%016llx node=%016llx muted=%d stage=%s rc=%d: %s",
(unsigned long long)group_id, (unsigned long long)node_id, muted, stage, rc, sqlite3_errmsg(db));
rc = sqlite3_finalize(st);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute cleanup finalize rc=%d", rc);
if (!sqlite3_get_autocommit(db)) {
rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute rollback rc=%d: %s", rc, sqlite3_errmsg(db));
if (!sqlite3_get_autocommit(db)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: local mute transaction remains open; requesting owner stop/DB close");
utun_instance_stop(inst);
}
}
return -1;
}
/* ── подписка (вкл/выкл прослушивание) ── */
+4
View File
@@ -39,6 +39,10 @@ int radio_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t n
int radio_member_silenced(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id);
/* Изменилось временное подавление; настройки в БД и подписка остаются прежними. */
void radio_refresh_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id);
/* Сохранить muted=0/1 для другого участника существующей CHAT-группы;0 — committed,-1 — отказ.
* Собственная BEGIN IMMEDIATE,чужая транзакция запрещена. Память резервируется до записи;
* cache и mute callback меняются после commit. При отказе настройка/звук сохраняются;
* неудачный rollback с открытой транзакцией запрашивает stop/закрытие БД владельцем. */
int radio_set_member_muted(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id, int muted);
/* После удаления записи участника из БД удалить её из локального кэша. */
void radio_forget_member_mute(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t node_id);
+184 -103
View File
@@ -10,6 +10,7 @@
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <openssl/crypto.h>
#define PEERS_JOIN_SIG_SIZE 64
#define PEERS_JOIN_TS_SIZE 8
@@ -491,9 +492,10 @@ int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id) {
const char* column = (const char*)sqlite3_column_text(st, 1);
if (column && !strcmp(column, "radio_muted")) has_radio_muted = 1;
}
sqlite3_finalize(st);
if (rc != SQLITE_DONE) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read member schema ch=%s: %s", channel_id, sqlite3_errmsg(db)); return -1;
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: read member schema ch=%s step=%d finalize=%d: %s",
channel_id, rc, final, sqlite3_errmsg(db)); return -1;
}
if (!has_radio_muted) {
snprintf(sql, sizeof(sql), "ALTER TABLE \"%s\" ADD COLUMN radio_muted INTEGER NOT NULL DEFAULT 0", peers_tbl);
@@ -510,7 +512,15 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
const uint8_t* x25519_pub, const uint8_t* x25519_priv,
const uint8_t* ed25519_pub, const uint8_t* ed25519_priv,
const uint8_t* signature) {
if (!db || !channel_id || !name || !x25519_pub || !ed25519_pub || !signature) return -1;
if (!db || !channel_id || !name || !x25519_pub || !ed25519_pub || !signature) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: invalid database/description arguments"); return -1;
}
int rc = sqlite3_exec(db, "SAVEPOINT channel_put", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: begin channel=%s rc=%d: %s", channel_id, rc, sqlite3_errmsg(db)); return -1;
}
sqlite3_stmt* stmt = NULL;
const char* stage = "allocate peers schema";
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
@@ -548,38 +558,36 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
int ddl_sz = snprintf(NULL, 0, ddl_tmpl, peers_tbl);
char* ddl = u_malloc(ddl_sz + 1);
if (ddl) {
snprintf(ddl, ddl_sz + 1, ddl_tmpl, peers_tbl);
char* err = NULL;
sqlite3_exec(db, ddl, NULL, NULL, &err);
if (err) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "topo_node_sqlite_channel_put peers: %s", err); sqlite3_free(err); }
u_free(ddl);
}
if (!ddl) { rc = SQLITE_NOMEM; goto error; }
snprintf(ddl, ddl_sz + 1, ddl_tmpl, peers_tbl); stage = "create peers schema";
rc = sqlite3_exec(db, ddl, NULL, NULL, NULL); u_free(ddl);
if (rc != SQLITE_OK) goto error;
if (topo_node_sqlite_member_local_init(db, channel_id) < 0) return -1;
stage = "local member schema";
if (topo_node_sqlite_member_local_init(db, channel_id) < 0) { rc = SQLITE_ERROR; goto error; }
int idx_sz = snprintf(NULL, 0,
"CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl);
char* idx_sql = u_malloc(idx_sz + 1);
if (idx_sql) {
snprintf(idx_sql, idx_sz + 1,
"CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl);
sqlite3_exec(db, idx_sql, NULL, NULL, NULL);
u_free(idx_sql);
}
stage = "allocate type index";
if (!idx_sql) { rc = SQLITE_NOMEM; goto error; }
snprintf(idx_sql, idx_sz + 1,
"CREATE INDEX IF NOT EXISTS \"idx_%s_type_rtt\" ON \"%s\" (node_type, node_RTT)", peers_tbl, peers_tbl);
stage = "create type index"; rc = sqlite3_exec(db, idx_sql, NULL, NULL, NULL); u_free(idx_sql);
if (rc != SQLITE_OK) goto error;
int sidx_sz = snprintf(NULL, 0,
"CREATE INDEX IF NOT EXISTS \"idx_%s_storage\" ON \"%s\" (storage)", peers_tbl, peers_tbl);
char* sidx_sql = u_malloc(sidx_sz + 1);
if (sidx_sql) {
snprintf(sidx_sql, sidx_sz + 1,
"CREATE INDEX IF NOT EXISTS \"idx_%s_storage\" ON \"%s\" (storage)", peers_tbl, peers_tbl);
sqlite3_exec(db, sidx_sql, NULL, NULL, NULL);
u_free(sidx_sql);
}
stage = "allocate storage index";
if (!sidx_sql) { rc = SQLITE_NOMEM; goto error; }
snprintf(sidx_sql, sidx_sz + 1,
"CREATE INDEX IF NOT EXISTS \"idx_%s_storage\" ON \"%s\" (storage)", peers_tbl, peers_tbl);
stage = "create storage index"; rc = sqlite3_exec(db, sidx_sql, NULL, NULL, NULL); u_free(sidx_sql);
if (rc != SQLITE_OK) goto error;
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db,
stage = "prepare channel";
rc = sqlite3_prepare_v2(db,
"INSERT INTO channels(channel_id, name, owner_node_id,"
" x25519_pubkey, x25519_privkey, ed25519_pubkey, ed25519_privkey, signature)"
" VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(channel_id) DO UPDATE SET"
@@ -587,22 +595,45 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
" x25519_privkey=COALESCE(excluded.x25519_privkey,channels.x25519_privkey),"
" ed25519_privkey=COALESCE(excluded.ed25519_privkey,channels.ed25519_privkey)"
" WHERE channels.x25519_pubkey=excluded.x25519_pubkey AND channels.ed25519_pubkey=excluded.ed25519_pubkey",
-1, &stmt, NULL) != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, channel_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, name, -1, SQLITE_STATIC);
sqlite3_bind_int64(stmt, 3, (sqlite3_int64)owner_node_id);
sqlite3_bind_blob(stmt, 4, x25519_pub, 32, SQLITE_STATIC);
if (x25519_priv) sqlite3_bind_blob(stmt, 5, x25519_priv, 32, SQLITE_STATIC);
else sqlite3_bind_null(stmt, 5);
sqlite3_bind_blob(stmt, 6, ed25519_pub, 32, SQLITE_STATIC);
if (ed25519_priv) sqlite3_bind_blob(stmt, 7, ed25519_priv, 32, SQLITE_STATIC);
else sqlite3_bind_null(stmt, 7);
sqlite3_bind_blob(stmt, 8, signature, 64, SQLITE_STATIC);
int rc = sqlite3_step(stmt); sqlite3_finalize(stmt);
if (rc == SQLITE_DONE && !sqlite3_changes(db)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel: rejected identity change channel=%s", channel_id); return -1;
-1, &stmt, NULL);
if (rc == SQLITE_OK) { stage = "bind channel"; rc = sqlite3_bind_text(stmt, 1, channel_id, -1, SQLITE_STATIC); }
if (rc == SQLITE_OK) { stage = "bind name"; rc = sqlite3_bind_text(stmt, 2, name, -1, SQLITE_STATIC); }
if (rc == SQLITE_OK) { stage = "bind owner"; rc = sqlite3_bind_int64(stmt, 3, (sqlite3_int64)owner_node_id); }
if (rc == SQLITE_OK) { stage = "bind X25519 public"; rc = sqlite3_bind_blob(stmt, 4, x25519_pub, 32, SQLITE_STATIC); }
if (rc == SQLITE_OK) {
stage = "bind X25519 private";
rc = x25519_priv ? sqlite3_bind_blob(stmt, 5, x25519_priv, 32, SQLITE_STATIC) : sqlite3_bind_null(stmt, 5);
}
return rc == SQLITE_DONE ? 0 : -1;
if (rc == SQLITE_OK) { stage = "bind Ed25519 public"; rc = sqlite3_bind_blob(stmt, 6, ed25519_pub, 32, SQLITE_STATIC); }
if (rc == SQLITE_OK) {
stage = "bind Ed25519 private";
rc = ed25519_priv ? sqlite3_bind_blob(stmt, 7, ed25519_priv, 32, SQLITE_STATIC) : sqlite3_bind_null(stmt, 7);
}
if (rc == SQLITE_OK) { stage = "bind signature"; rc = sqlite3_bind_blob(stmt, 8, signature, 64, SQLITE_STATIC); }
if (rc == SQLITE_OK) { stage = "write channel"; rc = sqlite3_step(stmt); }
if (rc != SQLITE_DONE) goto error;
stage = "channel row count";
if (sqlite3_changes(db) != 1) { rc = SQLITE_CONSTRAINT; goto error; }
stage = "finalize channel"; rc = sqlite3_finalize(stmt); stmt = NULL;
if (rc != SQLITE_OK) goto error;
stage = "release channel"; rc = sqlite3_exec(db, "RELEASE channel_put", NULL, NULL, NULL);
if (rc == SQLITE_OK) return 0;
error:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: channel=%s stage=%s rc=%d (%s) db=%s",
channel_id, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db));
{
int final = sqlite3_finalize(stmt);
if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: cleanup finalize channel=%s rc=%d", channel_id, final);
}
if (sqlite3_get_autocommit(db)) return -1;
rc = sqlite3_exec(db, "ROLLBACK TO channel_put", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: rollback channel=%s rc=%d; transaction must be rolled back or closed", channel_id, rc);
return -1;
}
rc = sqlite3_exec(db, "RELEASE channel_put", NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel put: rollback release channel=%s rc=%d", channel_id, rc);
return -1;
}
/* Удаляет канал и производные таблицы целиком; savepoint сохраняет внешнюю транзакцию. */
@@ -623,19 +654,36 @@ int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id) {
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_DONE) goto fail;
sqlite3_finalize(st); st = NULL;
rc = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_OK) goto fail;
/* IGNORE может вернуть DONE, сохранив канал: это не успешное удаление. */
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_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_DONE) goto fail;
rc = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_OK) goto fail;
rc = sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='merkle_tree_hash'", -1, &st, NULL);
if (rc != SQLITE_OK) goto fail;
rc = sqlite3_step(st);
if (rc != SQLITE_ROW && rc != SQLITE_DONE) goto fail;
int has_merkle = rc == SQLITE_ROW;
sqlite3_finalize(st); st = NULL;
rc = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_OK) goto fail;
if (has_merkle) {
rc = sqlite3_prepare_v2(db, "DELETE FROM merkle_tree_hash WHERE substr(namespace,instr(namespace,':')+1)=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_DONE) goto fail;
sqlite3_finalize(st); st = NULL;
rc = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_OK) goto fail;
rc = sqlite3_prepare_v2(db, "SELECT 1 FROM merkle_tree_hash WHERE substr(namespace,instr(namespace,':')+1)=? LIMIT 1",
-1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, channel_id, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc != SQLITE_DONE) goto fail;
rc = sqlite3_finalize(st); st = NULL;
if (rc != SQLITE_OK) goto fail;
}
if ((rc = sqlite3_exec(db, "RELEASE delete_topo_channel", NULL, NULL, NULL)) != SQLITE_OK) goto fail;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "channel deleted: channel=%s tables=peers/msg/feedback merkle=%d", channel_id, has_merkle);
@@ -679,7 +727,9 @@ int topo_node_sqlite_member_block_put(sqlite3* db, const char* channel_id, uint6
const uint8_t* x25519_pubkey, const uint8_t* ed25519_pubkey,
const char* userinfo, const uint8_t* creator_sig,
uint64_t signed_by, const uint8_t* signature) {
if (!db || !channel_id || !x25519_pubkey || !ed25519_pubkey) return -1;
if (!db || !channel_id || !x25519_pubkey || !ed25519_pubkey) {
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member block: invalid database/channel/key arguments"); return -1;
}
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
char sql[768];
snprintf(sql, sizeof(sql),
@@ -699,48 +749,66 @@ int topo_node_sqlite_member_block_put(sqlite3* db, const char* channel_id, uint6
" signature=excluded.signature,"
" source=0", peers_tbl);
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id);
sqlite3_bind_blob(stmt, 2, x25519_pubkey, 32, SQLITE_STATIC);
sqlite3_bind_blob(stmt, 3, ed25519_pubkey, 32, SQLITE_STATIC);
if (join_sig) sqlite3_bind_blob(stmt, 4, join_sig, PEERS_JOIN_SIG_SIZE, SQLITE_STATIC);
else { static const unsigned char zsig[PEERS_JOIN_SIG_SIZE] = {0}; sqlite3_bind_blob(stmt, 4, zsig, PEERS_JOIN_SIG_SIZE, SQLITE_STATIC); }
sqlite3_bind_int64(stmt, 5, (sqlite3_int64)join_ts);
if (update_sig) sqlite3_bind_blob(stmt, 6, update_sig, PEERS_UPDATE_SIG_SIZE, SQLITE_STATIC);
else sqlite3_bind_null(stmt, 6);
sqlite3_bind_int64(stmt, 7, (sqlite3_int64)update_ts);
sqlite3_bind_text(stmt, 8, userinfo ? userinfo : "", -1, SQLITE_STATIC);
if (creator_sig) sqlite3_bind_blob(stmt, 9, creator_sig, PEERS_JOIN_SIG_SIZE, SQLITE_STATIC);
else sqlite3_bind_null(stmt, 9);
sqlite3_bind_int64(stmt, 10, (sqlite3_int64)signed_by);
if (signature) sqlite3_bind_blob(stmt, 11, signature, 64, SQLITE_STATIC);
else sqlite3_bind_null(stmt, 11);
int rc = sqlite3_step(stmt); sqlite3_finalize(stmt);
return rc == SQLITE_DONE ? 0 : -1;
const char* stage = "prepare";
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) { stage = "bind node"; rc = sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id); }
if (rc == SQLITE_OK) { stage = "bind X25519"; rc = sqlite3_bind_blob(stmt, 2, x25519_pubkey, 32, SQLITE_STATIC); }
if (rc == SQLITE_OK) { stage = "bind Ed25519"; rc = sqlite3_bind_blob(stmt, 3, ed25519_pubkey, 32, SQLITE_STATIC); }
if (rc == SQLITE_OK) {
static const uint8_t zero_signature[PEERS_JOIN_SIG_SIZE] = {0}; stage = "bind JOIN signature";
rc = sqlite3_bind_blob(stmt, 4, join_sig ? join_sig : zero_signature, PEERS_JOIN_SIG_SIZE, SQLITE_STATIC);
}
if (rc == SQLITE_OK) { stage = "bind JOIN timestamp"; rc = sqlite3_bind_int64(stmt, 5, (sqlite3_int64)join_ts); }
if (rc == SQLITE_OK) {
stage = "bind profile signature";
rc = update_sig ? sqlite3_bind_blob(stmt, 6, update_sig, PEERS_UPDATE_SIG_SIZE, SQLITE_STATIC) : sqlite3_bind_null(stmt, 6);
}
if (rc == SQLITE_OK) { stage = "bind profile timestamp"; rc = sqlite3_bind_int64(stmt, 7, (sqlite3_int64)update_ts); }
if (rc == SQLITE_OK) { stage = "bind profile"; rc = sqlite3_bind_text(stmt, 8, userinfo ? userinfo : "", -1, SQLITE_STATIC); }
if (rc == SQLITE_OK) {
stage = "bind creator signature";
rc = creator_sig ? sqlite3_bind_blob(stmt, 9, creator_sig, PEERS_JOIN_SIG_SIZE, SQLITE_STATIC) : sqlite3_bind_null(stmt, 9);
}
if (rc == SQLITE_OK) { stage = "bind inviter"; rc = sqlite3_bind_int64(stmt, 10, (sqlite3_int64)signed_by); }
if (rc == SQLITE_OK) {
stage = "bind invitation signature";
rc = signature ? sqlite3_bind_blob(stmt, 11, signature, 64, SQLITE_STATIC) : sqlite3_bind_null(stmt, 11);
}
if (rc == SQLITE_OK) { stage = "write"; rc = sqlite3_step(stmt); }
int changed = rc == SQLITE_DONE ? sqlite3_changes(db) : 0;
if (rc != SQLITE_DONE || changed != 1) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC,
"member block: channel=%s node=%016llx stage=%s rc=%d (%s) changed=%d db=%s",
channel_id, (unsigned long long)node_id, stage, rc, sqlite3_errstr(rc), changed, sqlite3_errmsg(db));
int final = sqlite3_finalize(stmt);
if (final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member block: finalize channel=%s node=%016llx rc=%d",
channel_id, (unsigned long long)node_id, final);
return rc == SQLITE_DONE && changed == 1 && final == SQLITE_OK ? 0 : -1;
}
/* Обновляет только блок владельца (adm_tags/adm_tags_sig/storage),
* НЕ трогая блок мембера. */
int topo_node_sqlite_member_owner_put(sqlite3* db, const char* channel_id, uint64_t node_id,
const char* adm_tags, const uint8_t* adm_tags_sig, int storage) {
if (!db || !channel_id) return -1;
if (!db || !channel_id) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member owner: missing database/channel"); return -1; }
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
char sql[256];
snprintf(sql, sizeof(sql),
"UPDATE \"%s\" SET adm_tags=?, adm_tags_sig=?, storage=?, deleted=? WHERE node_id=?", peers_tbl);
sqlite3_stmt* u = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &u, NULL) != SQLITE_OK) return -1;
if (adm_tags && adm_tags[0]) sqlite3_bind_text(u, 1, adm_tags, -1, SQLITE_STATIC);
else sqlite3_bind_null(u, 1);
if (adm_tags_sig) sqlite3_bind_blob(u, 2, adm_tags_sig, 64, SQLITE_STATIC);
else sqlite3_bind_null(u, 2);
sqlite3_bind_int(u, 3, storage);
sqlite3_bind_int(u, 4, adm_tags_deleted(adm_tags));
sqlite3_bind_int64(u, 5, (sqlite3_int64)node_id);
int rc = sqlite3_step(u); sqlite3_finalize(u);
if (rc != SQLITE_DONE) {
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member_owner_put: ch=%s node=%016llx rc=%d: %s",
channel_id, (unsigned long long)node_id, rc, sqlite3_errmsg(db));
const char* stage = "prepare"; int rc = sqlite3_prepare_v2(db, sql, -1, &u, NULL);
if (rc == SQLITE_OK) {
stage = "bind tags"; rc = adm_tags && adm_tags[0] ? sqlite3_bind_text(u, 1, adm_tags, -1, SQLITE_STATIC) : sqlite3_bind_null(u, 1);
}
if (rc == SQLITE_OK) { stage = "bind signature"; rc = adm_tags_sig ? sqlite3_bind_blob(u, 2, adm_tags_sig, 64, SQLITE_STATIC) : sqlite3_bind_null(u, 2); }
if (rc == SQLITE_OK) { stage = "bind storage"; rc = sqlite3_bind_int(u, 3, storage); }
if (rc == SQLITE_OK) { stage = "bind deleted"; rc = sqlite3_bind_int(u, 4, adm_tags_deleted(adm_tags)); }
if (rc == SQLITE_OK) { stage = "bind member"; rc = sqlite3_bind_int64(u, 5, (sqlite3_int64)node_id); }
if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(u); }
if (rc == SQLITE_DONE && sqlite3_changes(db) != 1) { stage = "changes != 1"; rc = SQLITE_CONSTRAINT; }
int final = sqlite3_finalize(u);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "member owner: ch=%s node=%016llx stage=%s rc=%d finalize=%d: %s",
channel_id, (unsigned long long)node_id, stage, rc, final, sqlite3_errmsg(db));
return -1;
}
return 0;
@@ -777,6 +845,7 @@ int topo_node_sqlite_node_update_verified(sqlite3* db, uint64_t node_id,
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 6, (sqlite3_int64)update_ts);
}
if (rc == SQLITE_OK) { stage = "step"; rc = sqlite3_step(stmt); }
if (rc == SQLITE_DONE && sqlite3_changes(db) != 1) { stage = "changes != 1"; rc = SQLITE_CONSTRAINT; }
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_BGP, "verified node write: node=%016llx stage=%s rc=%d: %s",
(unsigned long long)node_id, stage, rc, sqlite3_errmsg(db));
int final_rc = sqlite3_finalize(stmt);
@@ -919,38 +988,50 @@ done:
return result;
}
int topo_node_sqlite_channel_get_priv(sqlite3* db, const char* channel_id,
uint8_t* ed25519_priv_out) {
if (!db || !channel_id || !ed25519_priv_out) return -1;
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db,
"SELECT ed25519_privkey FROM channels WHERE channel_id=?", -1, &stmt, NULL) != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, channel_id, -1, SQLITE_STATIC);
if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); return -1; }
const uint8_t* blob = (const uint8_t*)sqlite3_column_blob(stmt, 0);
if (!blob || sqlite3_column_bytes(stmt, 0) < 32) { sqlite3_finalize(stmt); return -1; }
memcpy(ed25519_priv_out, blob, 32);
sqlite3_finalize(stmt);
return 0;
/* Выходные ключи публикуются только после успешного завершения SELECT. */
static int channel_private_keys(sqlite3* db, const char* channel, uint8_t* x25519, uint8_t* ed25519) {
if (!db || !channel || !ed25519) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "channel private keys: missing argument"); return -1;
}
const char* sql = x25519 ? "SELECT x25519_privkey, ed25519_privkey FROM channels WHERE channel_id=?" :
"SELECT ed25519_privkey FROM channels WHERE channel_id=?";
sqlite3_stmt* st = NULL; uint8_t keys[64] = {0}; int columns = x25519 ? 2 : 1, result = -1;
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, channel, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto done;
stage = "step"; rc = sqlite3_step(st); if (rc != SQLITE_ROW) goto done;
for (int i = 0; i < columns; i++) {
if (sqlite3_column_type(st, i) == SQLITE_NULL) { rc = SQLITE_DONE; goto done; }
stage = "key size/type"; rc = SQLITE_CORRUPT;
if (sqlite3_column_type(st, i) != SQLITE_BLOB || sqlite3_column_bytes(st, i) != 32) goto done;
const void* key = sqlite3_column_blob(st, i); if (!key) { rc = SQLITE_NOMEM; goto done; }
memcpy(keys + i * 32, key, 32);
}
rc = SQLITE_OK;
done:
{
int final = sqlite3_finalize(st);
if (rc == SQLITE_OK && final == SQLITE_OK) {
if (x25519) memcpy(x25519, keys, 32);
memcpy(ed25519, keys + (columns - 1) * 32, 32); result = 0;
} else if (rc == SQLITE_DONE && final == SQLITE_OK) {
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "channel private keys unavailable channel=%s", channel);
} else DEBUG_ERROR(DEBUG_CATEGORY_BGP, "channel private keys channel=%s stage=%s rc=%d finalize=%d: %s",
channel, stage, rc, final, sqlite3_errmsg(db));
}
OPENSSL_cleanse(keys, sizeof(keys)); return result;
}
int topo_node_sqlite_channel_get_priv(sqlite3* db, const char* channel_id, uint8_t* ed25519_priv_out) {
return channel_private_keys(db, channel_id, NULL, ed25519_priv_out);
}
int topo_node_sqlite_channel_get_privs(sqlite3* db, const char* channel_id,
uint8_t* x25519_priv_out, uint8_t* ed25519_priv_out) {
if (!db || !channel_id || !x25519_priv_out || !ed25519_priv_out) return -1;
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db,
"SELECT x25519_privkey, ed25519_privkey FROM channels WHERE channel_id=?", -1, &stmt, NULL) != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, channel_id, -1, SQLITE_STATIC);
if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); return -1; }
const uint8_t* x = (const uint8_t*)sqlite3_column_blob(stmt, 0);
const uint8_t* e = (const uint8_t*)sqlite3_column_blob(stmt, 1);
if (!x || sqlite3_column_bytes(stmt, 0) < 32 || !e || sqlite3_column_bytes(stmt, 1) < 32) {
sqlite3_finalize(stmt); return -1;
if (!x25519_priv_out) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "channel private keys: X25519 output missing"); return -1;
}
memcpy(x25519_priv_out, x, 32);
memcpy(ed25519_priv_out, e, 32);
sqlite3_finalize(stmt);
return 0;
return channel_private_keys(db, channel_id, x25519_priv_out, ed25519_priv_out);
}
/* Импорт пары: savepoint охватывает UPDATE и его завершение, внешний commit остаётся вызывающему. */
+18 -6
View File
@@ -49,6 +49,9 @@ int topo_node_sqlite_get_member_channels(sqlite3* db, uint64_t node_id,
/* Создать peers_<id> и сохранить канал; 0/-1. Ключи — по 32, signature — 64 байта, privkey необязательны.
* При обновлении публичные ключи обязаны совпасть; имя/владелец/подпись заменяются, NULL privkey сохраняет прежний.
* Свой savepoint включает peers/индексы/описание; проверяет DDL,все bind/step/changes1/finalize/release.
* Ошибка сохраняет прежние данные/схему. Внешняя транзакция допустима и не завершается.
* Отказ rollback не выполняет release:caller должен откатить/закрыть оставшуюся транзакцию.
* Подпись/пары ключей не проверяет. Табличный суффикс: первые 63 символа, вне [A-Za-z0-9_] заменены на '_'. */
int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id,
const char* name, uint64_t owner_node_id,
@@ -61,7 +64,10 @@ int topo_node_sqlite_member_local_init(sqlite3* db, const char* channel_id);
/* Полное локальное удаление канала: DROP peers_<id>/msg_<id>, DELETE channels,
* Удаляются feedback_<id> и merkle_tree_hash всех datasets канала (если таблица есть).
* 0/-1; savepoint откатывает в том числе DROP TABLE, внешняя транзакция сохраняется. */
* Проверяются finalize и отсутствие канала/хешей после DELETE, включая RAISE(IGNORE).
* Локальные каскадные triggers (в том числе attachment) входят в ту же транзакцию.
* 0/-1; savepoint откатывает в том числе DROP TABLE, внешняя транзакция сохраняется.
* При ошибке rollback вызывающий проверяет autocommit; открытая транзакция требует остановки/закрытия БД владельцем. */
int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id);
/* Вставить source=1 с нулевой join_sig; существующую строку не менять. 0/-1; ключи по 32 байта.
@@ -73,6 +79,7 @@ int topo_node_sqlite_member_placeholder_put(sqlite3* db, const char* channel_id,
* signed_by/signature — дерево приглашений: кто подписал pubkey узла (0 = корень, ключом группы).
* 0/-1; подписи по 64, ключи по 32 байта. NULL join_sig даёт нулевую подпись, userinfo=NULL даёт ''.
* Сбрасывает source=0; свежесть/подписи проверяет вызывающий, owner/local-поля сохраняются. */
/* Проверяет все bind,step,changes1 и finalize;при отказе caller откатывает свою транзакцию записи/индекса. */
int topo_node_sqlite_member_block_put(sqlite3* db, const char* channel_id, uint64_t node_id,
const uint8_t* join_sig, uint64_t join_ts,
const uint8_t* update_sig, uint64_t update_ts,
@@ -90,8 +97,10 @@ int topo_node_sqlite_member_in_channel(sqlite3* db, const char* channel_id, uint
* БД заполняет проверяющий подписи member_sync/chat_join; BGP-placeholder не предоставляет доступ. */
int topo_node_sqlite_member_allowed(sqlite3* db, const char* channel_id, uint64_t node_id);
/* Обновить owner-блок и deleted из adm_tags; 0/-1, отсутствующая строка даёт 0 без вставки.
* adm_tags/adm_tags_sig могут быть NULL; ненулевая подпись — 64 байта, проверяется вызывающим. */
/* Обновить owner-блок и deleted из adm_tags в одной существующей строке;0/-1,отсутствующая строка — ошибка.
* adm_tags/adm_tags_sig могут быть NULL;ненулевая подпись64 байта,проверяется вызывающим.
* Проверяет все bind/step/changes=1/finalize.
* Работает в транзакции caller;при отказе caller откатывает её до любых runtime-уведомлений. */
int topo_node_sqlite_member_owner_put(sqlite3* db, const char* channel_id, uint64_t node_id,
const char* adm_tags, const uint8_t* adm_tags_sig, int storage);
@@ -104,7 +113,8 @@ int topo_node_sqlite_member_del(sqlite3* db, const char* channel_id, uint64_t no
/* Записать проверенные вызывающим ключи/имя в nodes; 0/-1. Пустое имя сохраняет прежнее при UPDATE.
* Ключи заменяются независимо от update_ts; update_ts хранит максимум, created_at сохраняется, если не NULL.
* Новая строка online=1, при UPDATE online сохраняется. Подпись и свежесть здесь не проверяются. */
* Новая строка online=1, при UPDATE online сохраняется. Подпись и свежесть здесь не проверяются.
* Проверяет changes=1:подавленная trigger запись не считается успехом. Транзакцией управляет caller. */
int topo_node_sqlite_node_update_verified(sqlite3* db, uint64_t node_id,
const char* name, const uint8_t* x25519, const uint8_t* ed25519, uint64_t update_ts, time_t created_at);
@@ -115,11 +125,13 @@ int topo_node_sqlite_channel_get(sqlite3* db, const char* channel_id,
char* name_out, int name_sz, uint64_t* owner_node_id,
uint8_t* x25519_pub, uint8_t* ed25519_pub, uint8_t* signature);
/* Прочитать первые 32 байта Ed25519 privkey; 0/-1, отсутствующий/короткий ключ даёт -1. */
/* Прочитать Ed25519 privkey ровно32 байта BLOB; 0/-1, отсутствие/повреждение/SQL-ошибка даёт -1.
* Проверяются bind/step/finalize; при отказе выход не меняется. */
int topo_node_sqlite_channel_get_priv(sqlite3* db, const char* channel_id,
uint8_t* ed25519_priv_out);
/* Прочитать оба канальных privkey (x25519 + ed25519). Возвращает 0 если оба есть. */
/* Прочитать оба канальных privkey (x25519 + ed25519), каждый ровно32 байта BLOB.
* 0 — оба прочитаны, -1 — отказ с сохранением обоих выходов. Временные ключи очищаются. */
int topo_node_sqlite_channel_get_privs(sqlite3* db, const char* channel_id,
uint8_t* x25519_priv_out, uint8_t* ed25519_priv_out);
+10
View File
@@ -38,6 +38,10 @@ node_update_verified записывает уже проверенные ключ
В nodes.update_ts сохраняется максимум прежнего и входного значения; это не фильтр замены ключей.
created_at сохраняется, если уже не NULL. Новая строка получает online=1,
обновление существующей строки online не меняет.
Проверяются bind/step/changes=1/finalize;подавленная trigger запись возвращает ошибку.
member_owner_put также требует ровно одну изменённую строку,проверяет все SQL-этапы
и принимает NULL adm_tags/подпись. Эти операции используют транзакцию caller:
caller откатывает ошибку до публикации runtime-состояния и уведомлений.
## Снимок и bootstrap
@@ -84,6 +88,9 @@ update_rtt обновляет node_addresses.rtt и peers_*.node_RTT в един
channel_put требует публичные ключи по 32 байта и подпись 64 байта.
При конфликте channel_id публичные ключи должны совпасть: меняются имя/владелец/подпись,
переданные privkey заменяют прежние, NULL сохраняет их. Криптографическую проверку делает вызывающий.
SAVEPOINT channel_put атомарно объединяет описание с peers-таблицей, локальной схемой и индексами.
Проверяются выделение SQL, все bind/step/changes=1/finalize и RELEASE; при отказе частичная схема не фиксируется.
Если ROLLBACK TO сам завершился ошибкой, RELEASE не выполняется: открытая транзакция требует отката/закрытия БД владельцем.
Суффикс таблиц строится из первых 63 символов channel_id: вне [A-Za-z0-9_] заменяются на '_'.
Штатные channel_id — десятичные uint64. Строка channels.channel_id хранит исходное значение.
@@ -91,6 +98,7 @@ channel_put требует публичные ключи по 32 байта и
member_placeholder_put вставляет source=1 с нулевой join_sig; существующую строку не меняет.
member_block_put заменяет ключи/join/update/userinfo/дерево приглашений и устанавливает source=0,
сохраняя owner-блок и локальные поля. NULL join_sig записывается как 64 нулевых байта.
Все 11 bind, step, changes=1 и finalize проверяются; функция работает в транзакции вызывающего.
member_owner_put меняет adm_tags/подпись/storage/deleted без вставки отсутствующей строки.
Placeholder не участвует в Merkle-хеше; сетевой формат и проверка версий принадлежат member_sync.
@@ -103,6 +111,8 @@ channel_get и get_ed25519_pubkey различают отсутствие стр
и отказ аргументов/SQL/повреждённый ненулевой размер (TOPO_SQLITE_LOOKUP_ERROR=-2).
Успех — 0; key lookup не меняет выходной ключ при отказе. Проверяющий подпись caller
может отложить проверку при отсутствии зависимости, но должен прервать её при SQL-ошибке.
channel_get_priv/channel_get_privs принимают только BLOB ровно32 байта для каждого ключа.
Они проверяют prepare/bind/step/finalize,не меняют выходы при отказе и очищают временные копии ключей.
member_del физически удаляет строку без tombstone и пересчёта Merkle.
channel_delete удаляет peers/msg/feedback, канал с приватными ключами и хеши datasets его namespace
+1 -1
View File
@@ -848,8 +848,8 @@ int chat_service_start(struct UTUN_INSTANCE* instance) {
if (chat_attachment_init(instance) < 0) goto fail;
if (member_files_init(instance) < 0 || dm_mailbox_init(instance) < 0 || dm_core_init(instance) < 0 ||
dm_media_init(instance) < 0 || call_init(instance) < 0 || radio_call_init(instance) < 0) goto fail;
chat_media_backfill(instance);
chat_media_startup_backfill_init(instance);
chat_media_backfill(instance);
/* Все headless listeners используют IPv4:port; port=0 выбирает ОС. Ошибка отключает только этот listener. */
const struct {
const char* bind;
+59 -2
View File
@@ -117,6 +117,7 @@ check_PROGRAMS += \
test_chat_attachment \
test_attachment_results \
test_attachment_decisions \
test_attachment_requests \
test_db_sync_integrity \
test_reality_fallback \
test_etcp_padding \
@@ -299,6 +300,13 @@ test_socket_monitor_LDFLAGS = -Wl,--wrap=socket -Wl,--wrap=bind -Wl,--wrap=close
if OS_LINUX
test_socket_monitor_LDFLAGS += -Wl,--wrap=recvfrom -Wl,--wrap=__recvfrom_chk
check_PROGRAMS += test_config_parser
check_PROGRAMS += test_chat_setting
check_PROGRAMS += test_chat_channel
check_PROGRAMS += test_group_avatar_sql
check_PROGRAMS += test_chat_member
check_PROGRAMS += test_chat_json
check_PROGRAMS += test_chat_profile
check_PROGRAMS += test_chat_preferences
check_PROGRAMS += test_instance_contract
test_instance_contract_SOURCES = test_instance_contract.c
test_instance_contract_LDADD = $(LIBUTUN) $(COMMON_LIBS)
@@ -306,6 +314,43 @@ test_instance_contract_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=etcp_create
test_config_parser_SOURCES = test_config_parser.c
test_config_parser_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_config_parser_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl
test_chat_setting_SOURCES = test_chat_setting.c
test_chat_setting_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_setting_LDFLAGS = -Wl,--wrap=chat_local_apply_settings
test_chat_channel_SOURCES = test_chat_channel.c
test_chat_channel_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_channel_LDFLAGS = -Wl,--wrap=EVP_DigestSign -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 \
-Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=sqlite3_bind_text -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_bind_null \
-Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_changes -Wl,--wrap=sqlite3_finalize -Wl,--wrap=u_malloc_impl \
-Wl,--wrap=EVP_PKEY_CTX_new_id -Wl,--wrap=EVP_PKEY_keygen_init -Wl,--wrap=EVP_PKEY_keygen \
-Wl,--wrap=EVP_PKEY_get_raw_public_key -Wl,--wrap=EVP_PKEY_get_raw_private_key -Wl,--wrap=RAND_bytes
test_group_avatar_sql_SOURCES = test_group_avatar_sql.c
test_group_avatar_sql_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_group_avatar_sql_LDFLAGS = -Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_bind_text \
-Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_changes -Wl,--wrap=sqlite3_finalize \
-Wl,--wrap=sc_ed25519_sign
test_chat_member_SOURCES = test_chat_member.c
test_chat_member_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_member_LDFLAGS = -Wl,--wrap=sqlite3_finalize -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_int64 \
-Wl,--wrap=sqlite3_bind_int -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_changes -Wl,--wrap=sqlite3_exec \
-Wl,--wrap=u_malloc_impl -Wl,--wrap=u_realloc_impl
test_chat_json_SOURCES = test_chat_json.c
test_chat_json_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_json_LDFLAGS = -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_text -Wl,--wrap=sqlite3_bind_int64 \
-Wl,--wrap=sqlite3_bind_int -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_finalize -Wl,--wrap=sqlite3_column_text \
-Wl,--wrap=u_realloc_impl -Wl,--wrap=u_calloc_impl
test_chat_profile_SOURCES = test_chat_profile.c
test_chat_preferences_SOURCES = test_chat_preferences.c
test_chat_preferences_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_preferences_LDFLAGS = -Wl,--wrap=sqlite3_finalize -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_text \
-Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_changes -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_column_text \
-Wl,--wrap=chat_sync_broadcast_sound_setting -Wl,--wrap=chat_core_apply_group_autoconnect
test_chat_profile_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_chat_profile_LDFLAGS = -Wl,--wrap=sqlite3_finalize -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_text \
-Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_bind_int -Wl,--wrap=sqlite3_bind_null \
-Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_column_text -Wl,--wrap=sqlite3_exec -Wl,--wrap=sc_ed25519_sign \
-Wl,--wrap=sc_ed25519_verify -Wl,--wrap=u_malloc_impl -Wl,--wrap=member_sync_add_apply_cbk -Wl,--wrap=sqlite3_changes \
-Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl -Wl,--wrap=u_strdup_impl
else
test_socket_monitor_LDFLAGS += -Wl,--wrap=read
endif
@@ -464,9 +509,15 @@ test_encrypted_attachment_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_encrypted_attachment_LDFLAGS = $(test_clock_ldflags)
if OS_LINUX
test_encrypted_attachment_LDFLAGS += -Wl,--wrap=uasync_set_timeout -Wl,--wrap=media_download_handle_chunk \
-Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=etcp_router_conn_send
-Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=etcp_router_conn_send -Wl,--wrap=etcp_router_bind
endif
if OS_LINUX
test_attachment_requests_SOURCES = test_attachment_requests.c
test_attachment_requests_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_attachment_requests_LDFLAGS = -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob \
-Wl,--wrap=sqlite3_bind_text -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_bind_int -Wl,--wrap=sqlite3_step \
-Wl,--wrap=sqlite3_finalize -Wl,--wrap=sqlite3_changes -Wl,--wrap=etcp_router_has_route -Wl,--wrap=etcp_router_conn_get \
-Wl,--wrap=etcp_router_send_q_has_room -Wl,--wrap=etcp_router_conn_send -Wl,--wrap=uasync_set_timeout
test_attachment_decisions_SOURCES = test_attachment_decisions.c
test_attachment_decisions_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_attachment_decisions_LDFLAGS = -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob \
@@ -482,7 +533,8 @@ test_chat_attachment_SOURCES = test_chat_attachment.c
test_chat_attachment_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_chat_attachment_LDFLAGS = -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob \
-Wl,--wrap=sqlite3_bind_text -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_finalize \
-Wl,--wrap=media_async_submit
-Wl,--wrap=media_async_submit -Wl,--wrap=sqlite3_changes -Wl,--wrap=uasync_set_timeout -Wl,--wrap=sqlite3_bind_int \
-Wl,--wrap=media_download_start
endif
test_attachment_send_SOURCES = test_attachment_send.c
test_attachment_send_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
@@ -884,6 +936,9 @@ test_merkle_sync_SOURCES = test_merkle_sync.c $(test_clock_sources)
test_chat_feedback_SOURCES = test_chat_feedback.c
test_chat_feedback_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib
test_chat_feedback_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_chat_feedback_LDFLAGS = -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob \
-Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_bind_int -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_changes \
-Wl,--wrap=sqlite3_finalize
test_merkle_protocol_SOURCES = test_merkle_protocol.c
test_merkle_protocol_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib
test_merkle_protocol_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
@@ -1305,6 +1360,8 @@ test_tcp_io_flush_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_chat_poll_SOURCES = test_chat_poll.c
test_chat_poll_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib
test_chat_poll_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS)
test_chat_poll_LDFLAGS = -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob -Wl,--wrap=sqlite3_bind_text \
-Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_finalize -Wl,--wrap=u_malloc_impl
test_chat_poll_network_SOURCES = test_chat_poll_network.c
test_chat_poll_network_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib
+574
View File
@@ -0,0 +1,574 @@
/* Настоящие core/SQLite/подписи; вход после router, независимый reader в ACK/событии. */
#define _GNU_SOURCE
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/sha.h>
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_attachment.h"
#include "../src/chat/chat_core_priv.h"
#include "../src/chat/chat_event.h"
#include "../src/chat/member_sync.h"
#include "../src/routing_layer/topo_node_sqlite.h"
#include "../src/routing_layer/etcp_router.h"
#include "../src/transport_layer/etcp.h"
#include "../lib/platform_compat.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
enum operation { NO_OPERATION, IDENTITY, MEMBER, AUTHOR, READ, INSERT, OUTBOX, PENDING, ELIGIBLE, PUMP_READ, PUMP_RETRY };
enum failure { NO_FAILURE, BEGIN, PREPARE, BIND, STEP, FINALIZE, CHANGES, COMMIT };
static enum failure fault;
static enum operation fault_operation, last_operation;
static int fault_index, fail_rollback;
static unsigned fault_hits, cases;
struct snapshot {
uint8_t key[32];
int exists, incoming, state, command, error, key_bytes;
int64_t created, retry;
};
struct fixture {
struct UASYNC* ua;
struct UTUN_INSTANCE* inst;
struct ETCP_ROUTER_CONN route; /* Только наблюдение ACK; сеть/crypto проверяет encrypted_attachment. */
sqlite3* reader;
char directory[256], channel[32];
uint8_t message[32], id[16], hash[32], key[32];
uint64_t peer;
struct snapshot expected;
int64_t started;
int allow_result, route_up, room, send_rc, corrupt, flags, outgoing, pump_only;
unsigned acks, events;
};
static struct fixture* active;
static timeout_callback_t attachment_tick;
/* Вызываем зарегистрированный production callback без ожидания секунды; старый таймер отменяем. */
extern void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*);
void* __wrap_uasync_set_timeout(struct UASYNC* ua, int delay, void* arg, timeout_callback_t callback, const char* name) {
if (name && !strcmp(name, "attachment keys")) attachment_tick = callback;
return __real_uasync_set_timeout(ua, delay, arg, callback, name);
}
static enum operation operation(const char* query) {
if (!query) return NO_OPERATION;
static const char* prefixes[] = {NULL, "SELECT crypto_id,cipher_hash,key FROM attachment_keys WHERE",
"SELECT x25519_pubkey,ed25519_pubkey,signed_by,signature,join_sig,join_ts", "SELECT 1 FROM msg_",
"SELECT incoming,state FROM attachment_key_requests WHERE", "INSERT INTO attachment_key_requests(",
"INSERT INTO attachment_key_outbox(", "SELECT channel,message_id,crypto_id,author,key,cipher_hash,cipher_path,plain_path",
"SELECT 1 FROM attachment_keys a WHERE", "SELECT channel,message_id,peer,command FROM attachment_key_outbox",
"UPDATE attachment_key_outbox SET retry_at=unixepoch()+5 WHERE"};
for (enum operation op = IDENTITY; op <= PUMP_RETRY; op++)
if (!strncmp(query, prefixes[op], strlen(prefixes[op]))) return op;
return NO_OPERATION;
}
static int take(enum failure point, enum operation op, int index) {
if (fault != point || fault_operation != op || (point == BIND && fault_index != index)) return 0;
fault = NO_FAILURE; fault_hits++;
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "request test: injected point=%d operation=%d bind=%d", point, op, index); return 1;
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* query, int (*cb)(void*, int, char**, char**), void* arg, char** error) {
if (active && db == active->inst->topo_sqlite_db) {
const char* begin = active->pump_only ? "BEGIN IMMEDIATE /* attachment outbox */" : "BEGIN IMMEDIATE";
const char* commit = active->pump_only ? "COMMIT /* attachment outbox */" : "COMMIT";
if (!strcmp(query, begin) && take(BEGIN, NO_OPERATION, 0)) return SQLITE_BUSY;
if (!strcmp(query, commit) && take(COMMIT, NO_OPERATION, 0)) return SQLITE_BUSY;
if (!strcmp(query, "ROLLBACK") && fail_rollback) {
fail_rollback = 0; DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "request test: injected rollback failure"); return SQLITE_IOERR;
}
}
return __real_sqlite3_exec(db, query, cb, arg, error);
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* query, int length, sqlite3_stmt** st, const char** tail) {
enum operation op = operation(query);
if (op && take(PREPARE, op, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, query, length, st, tail);
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* value, int length, void (*destroy)(void*)) {
if (take(BIND, operation(sqlite3_sql(st)), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, value, length, destroy);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* value, int length, void (*destroy)(void*)) {
if (take(BIND, operation(sqlite3_sql(st)), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, value, length, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take(BIND, operation(sqlite3_sql(st)), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (take(BIND, operation(sqlite3_sql(st)), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
last_operation = operation(sqlite3_sql(st));
if (last_operation && take(STEP, last_operation, 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
enum operation op = st ? operation(sqlite3_sql(st)) : NO_OPERATION;
int failed = op && take(FINALIZE, op, 0);
int rc = __real_sqlite3_finalize(st); return failed ? SQLITE_IOERR : rc;
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_operation && take(CHANGES, last_operation, 0)) return 0;
return __real_sqlite3_changes(db);
}
static void sql(struct fixture* f, const char* query) {
int rc = sqlite3_exec(f->inst->topo_sqlite_db, query, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "request test SQL rc=%d: %s", rc, sqlite3_errmsg(f->inst->topo_sqlite_db));
assert(rc == SQLITE_OK);
}
/* Проверяем SQLite timestamps её же часами: libc time() может отставать у границы секунды. */
static int64_t database_time(struct fixture* f) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT unixepoch()", -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int64_t now = sqlite3_column_int64(st, 0);
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); return now;
}
static struct snapshot snapshot(struct fixture* f) {
sqlite3_stmt* st = NULL; struct snapshot result = {0};
assert(sqlite3_prepare_v2(f->reader, "SELECT a.key,a.error,r.message_id IS NOT NULL,coalesce(r.incoming,-1),coalesce(r.state,-1),"
"coalesce(r.created_at,0),coalesce(o.command,0),coalesce(o.retry_at,0) FROM attachment_keys a"
" LEFT JOIN attachment_key_requests r ON r.channel=a.channel AND r.message_id=a.message_id AND r.peer=?3"
" LEFT JOIN attachment_key_outbox o ON o.channel=a.channel AND o.message_id=a.message_id AND o.peer=?3"
" WHERE a.channel=?1 AND a.message_id=?2", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 2, f->message, 32, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 3, (sqlite3_int64)f->peer) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
result.key_bytes = sqlite3_column_bytes(st, 0); assert(result.key_bytes == 0 || result.key_bytes == 32);
if (result.key_bytes) memcpy(result.key, sqlite3_column_blob(st, 0), 32);
result.error = sqlite3_column_int(st, 1); result.exists = sqlite3_column_int(st, 2);
result.incoming = sqlite3_column_int(st, 3); result.state = sqlite3_column_int(st, 4);
result.created = sqlite3_column_int64(st, 5); result.command = sqlite3_column_int(st, 6); result.retry = sqlite3_column_int64(st, 7);
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
return result;
}
static void equal(struct fixture* f) {
struct snapshot current = snapshot(f);
if (f->expected.created < 0) {
int64_t now = database_time(f);
if (current.created < f->started || current.created > now)
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "request test: created=%lld outside SQLite bounds=%lld..%lld",
(long long)current.created, (long long)f->started, (long long)now);
assert(current.created >= f->started && current.created <= now); f->expected.created = current.created;
}
assert(current.key_bytes == f->expected.key_bytes && !memcmp(current.key, f->expected.key, 32));
assert(current.error == f->expected.error && current.exists == f->expected.exists);
assert(current.incoming == f->expected.incoming && current.state == f->expected.state && current.created == f->expected.created);
assert(current.command == f->expected.command && current.retry == f->expected.retry);
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT key,error FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_int(st, 1) == 7);
uint8_t key[32] = {99}; assert(!memcmp(sqlite3_column_blob(st, 0), key, 32));
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
extern int __real_etcp_router_has_route(struct UTUN_INSTANCE*, uint64_t, uint64_t);
int __wrap_etcp_router_has_route(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer) {
if (active && inst == active->inst && group == active->route.group_id && peer == active->peer) return active->route_up;
return __real_etcp_router_has_route(inst, group, peer);
}
extern struct ETCP_ROUTER_CONN* __real_etcp_router_conn_get(struct UTUN_INSTANCE*, uint64_t, uint64_t, uint8_t);
struct ETCP_ROUTER_CONN* __wrap_etcp_router_conn_get(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t service) {
if (active && inst == active->inst && group == active->route.group_id && peer == active->peer && service == ETCP_RT_ID_ATTACHMENT_KEYS)
return &active->route;
return __real_etcp_router_conn_get(inst, group, peer, service);
}
extern int __real_etcp_router_send_q_has_room(struct UTUN_INSTANCE*, uint64_t, uint64_t, uint8_t);
int __wrap_etcp_router_send_q_has_room(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t service) {
if (active && inst == active->inst && group == active->route.group_id && peer == active->peer && service == ETCP_RT_ID_ATTACHMENT_KEYS)
return active->room;
return __real_etcp_router_send_q_has_room(inst, group, peer, service);
}
extern int __real_etcp_router_conn_send(struct ETCP_ROUTER_CONN*, const uint8_t*, size_t, int);
int __wrap_etcp_router_conn_send(struct ETCP_ROUTER_CONN* conn, const uint8_t* data, size_t length, int flags) {
if (!active || conn != &active->route) return __real_etcp_router_conn_send(conn, data, length, flags);
assert(active->allow_result && length == 85 && !memcmp(data, "AK01", 4) && data[4] == (active->outgoing ? 1 : 2));
assert(flags == (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED) && sqlite3_get_autocommit(active->inst->topo_sqlite_db));
assert(!memcmp(data + 5, active->message, 32) && !memcmp(data + 37, active->id, 16) && !memcmp(data + 53, active->hash, 32));
if (active->outgoing) {
active->expected.retry = snapshot(active).retry;
assert(active->expected.retry >= active->started + 5 && active->expected.retry <= database_time(active) + 5);
}
equal(active); active->acks++; return active->send_rc;
}
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int bytes) {
if (type != CHAT_EVT_ATTACHMENT_KEYS_CHANGED) return;
struct fixture* f = active;
if (f && !f->allow_result) {
struct snapshot seen = snapshot(f);
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "request test: unexpected event outgoing=%d committed request=%d state=%d command=%d autocommit=%d",
f->outgoing, seen.exists, seen.state, seen.command, sqlite3_get_autocommit(inst->topo_sqlite_db));
}
assert(f && f->inst == inst && f->allow_result && sqlite3_get_autocommit(inst->topo_sqlite_db));
assert(bytes == (int)strlen(f->channel) + 1 && data[0] == strlen(f->channel) && !memcmp(data + 1, f->channel, strlen(f->channel)));
equal(f); f->events++;
}
static void receive(struct fixture* f, int success) {
f->expected = snapshot(f); f->allow_result = success; f->acks = f->events = 0; f->started = database_time(f);
if (success) {
if (!f->expected.exists) { f->expected.exists = 1; f->expected.incoming = 1; f->expected.state = 0; f->expected.created = -1; }
if (f->expected.state) { f->expected.command = f->expected.state == 3 ? 4 : 3; f->expected.retry = 0; }
}
struct ll_entry* entry = ll_alloc_lldgram(ROUTER_SVC_PAYLOAD_OFF + 85); assert(entry);
entry->len = ROUTER_SVC_PAYLOAD_OFF + 85; memset(entry->dgram, 0, entry->len);
entry->dgram[0] = ETCP_RT_ID_ATTACHMENT_KEYS; entry->dgram[ROUTER_SVC_FLAGS_OFF] = f->flags;
memcpy(entry->dgram + ROUTER_SVC_SRC_OFF, &f->peer, 8); memcpy(entry->dgram + ROUTER_SVC_DST_OFF, &f->inst->node_id, 8);
memcpy(entry->dgram + ROUTER_SVC_GROUP_OFF, &f->route.group_id, 8);
uint8_t* body = entry->dgram + ROUTER_SVC_PAYLOAD_OFF; memcpy(body, "AK01", 4); body[4] = 1;
memcpy(body + 5, f->message, 32); memcpy(body + 37, f->id, 16); memcpy(body + 53, f->hash, 32);
if (f->corrupt) body[53] ^= 1;
struct ETCP_CONN conn = {.instance = f->inst};
assert(f->inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS]);
f->inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS](&conn, entry);
equal(f); assert(f->events == (unsigned)success && f->acks == (unsigned)(success && f->route_up && f->room));
}
static void reset(struct fixture* f, int state, int queued) {
char query[768];
snprintf(query, sizeof(query), "DELETE FROM attachment_key_requests WHERE channel='%s';DELETE FROM attachment_key_outbox WHERE channel='%s'",
f->channel, f->channel); sql(f, query);
if (state >= 0) {
snprintf(query, sizeof(query), "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)"
" SELECT channel,message_id,%lld,1,%d,123 FROM attachment_keys WHERE channel='%s'",
(long long)(sqlite3_int64)f->peer, state, f->channel); sql(f, query);
}
if (queued) {
snprintf(query, sizeof(query), "INSERT INTO attachment_key_outbox(channel,message_id,peer,command,retry_at)"
" SELECT channel,message_id,peer,%d,17 FROM attachment_key_requests WHERE channel='%s'", state == 3 ? 4 : 3, f->channel);
sql(f, query);
}
f->route_up = f->room = 1; f->send_rc = f->corrupt = 0; f->flags = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
}
static void setup(struct fixture* f) {
memset(f, 0, sizeof(*f)); strcpy(f->directory, "/tmp/utun-attachment-request-XXXXXX"); assert(mkdtemp(f->directory));
char config[1024], path[512];
snprintf(config, sizeof(config), "[global]\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", f->directory);
f->ua = uasync_create(); assert(f->ua); f->inst = utun_instance_create_from_str(f->ua, config);
assert(f->inst && !utun_core_start(f->inst) && !chat_service_start(f->inst));
chat_core_create_channel_auto(f->inst, "request contract");
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->inst->topo_sqlite_db, "SELECT channel_id FROM channels WHERE name='request contract'", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW); snprintf(f->channel, sizeof(f->channel), "%s", sqlite3_column_text(st, 0));
assert(sqlite3_finalize(st) == SQLITE_OK);
uint8_t x[32], ed[32], private_key[32], peer_ed[32], seed[64], join[64], certificate[64], body[112];
struct SC_MYKEYS peer; assert(sc_generate_keypair(&peer) == SC_OK);
f->peer = sc_derive_node_id_from_pubkey(peer.public_key); assert(f->peer && f->peer != f->inst->node_id);
assert(sc_derive_ed25519_pubkey(peer.private_key, peer_ed) == SC_OK); SHA512(peer.private_key, 32, seed);
assert(topo_node_sqlite_channel_get(f->inst->topo_sqlite_db, f->channel, NULL, 0, NULL, x, ed, NULL) == 0);
assert(topo_node_sqlite_channel_get_priv(f->inst->topo_sqlite_db, f->channel, private_key) == 0);
assert(member_sync_build_join_msg(x, ed, f->peer, peer.public_key, 1, body, sizeof(body)) == 112);
assert(sc_ed25519_sign(seed, body, sizeof(body), join) == SC_OK && member_sync_sign_pubkey(private_key, peer.public_key, certificate) == 0);
assert(member_sync_put(f->inst, f->channel, f->peer, peer.public_key, peer_ed, join, 1,
NULL, 0, "{}", NULL, NULL, 0, 0, certificate) >= 0);
assert(member_sync_verified_ed25519(f->inst, f->channel, f->peer, peer_ed) == 0);
struct chat_msg_submit request = {.inst = f->inst, .data = (uint8_t*)"request identity", .data_len = 16};
strcpy(request.channel_id, f->channel); strcpy(request.content_type, "text");
assert(chat_core_submit_message(f->inst, &request) == 0); memcpy(f->message, request.message_id, 32);
f->key[0] = 55; f->id[0] = 2; f->hash[0] = 3;
assert(sqlite3_prepare_v2(f->inst->topo_sqlite_db, "INSERT INTO attachment_keys(channel,crypto_id,message_id,author,key,cipher_hash,cipher_path,plain_path)"
" VALUES(?,?,?,?,?,?,'','')", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK && sqlite3_bind_blob(st, 3, f->message, 32, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 4, (sqlite3_int64)f->inst->node_id) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 5, f->key, 32, SQLITE_STATIC) == SQLITE_OK && sqlite3_bind_blob(st, 6, f->hash, 32, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
sql(f, "INSERT INTO attachment_keys(channel,crypto_id,author,key,cipher_path,plain_path,error)"
" VALUES('900',zeroblob(16),999,x'6300000000000000000000000000000000000000000000000000000000000000','','',7)");
snprintf(path, sizeof(path), "%s/chats.db", f->directory);
assert(sqlite3_open_v2(path, &f->reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
f->route.inst = f->inst; f->route.group_id = strtoull(f->channel, NULL, 10); f->route.remote_node_id = f->peer;
f->route.svc_id = ETCP_RT_ID_ATTACHMENT_KEYS; active = f; chat_event_set_handler(f->inst, event); reset(f, -1, 0);
}
static void cleanup(struct fixture* f) {
chat_event_set_handler(f->inst, NULL); active = NULL; utun_instance_destroy(f->inst);
assert(sqlite3_close(f->reader) == SQLITE_OK); uasync_destroy(f->ua, 0);
}
static void failed_then_retry(struct fixture* f, int state, int queued, enum failure point, enum operation op, int index) {
reset(f, state, queued); fault = point; fault_operation = op; fault_index = index; unsigned previous = fault_hits;
receive(f, 0); assert(!fault && fault_hits == previous + 1 && sqlite3_get_autocommit(f->inst->topo_sqlite_db));
receive(f, 1); receive(f, 1); cases++;
}
static void test_faults(void) {
struct fixture f; setup(&f); const int states[] = {-1, 0, 2, 3, 4};
for (unsigned i = 0; i < sizeof(states) / sizeof(states[0]); i++) for (int queued = 0; queued <= (states[i] >= 2); queued++) {
int state = states[i]; failed_then_retry(&f, state, queued, BEGIN, NO_OPERATION, 0);
for (enum operation op = IDENTITY; op <= OUTBOX; op++) {
if ((op == INSERT && state >= 0) || (op == OUTBOX && state < 2)) continue;
failed_then_retry(&f, state, queued, PREPARE, op, 0);
int bindings = op == IDENTITY ? 2 : op == MEMBER ? 1 : op == AUTHOR || op == OUTBOX ? 4 : 3;
for (int index = 1; index <= bindings; index++) failed_then_retry(&f, state, queued, BIND, op, index);
failed_then_retry(&f, state, queued, STEP, op, 0); failed_then_retry(&f, state, queued, FINALIZE, op, 0);
if (op == INSERT || op == OUTBOX) failed_then_retry(&f, state, queued, CHANGES, op, 0);
}
failed_then_retry(&f, state, queued, COMMIT, NO_OPERATION, 0);
}
cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment requests: %u SQL faults/retries, new and duplicate 0/2/3/4, exact committed snapshots", cases);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void test_controls(void) {
struct fixture f; setup(&f); const int states[] = {-1, 0, 2, 3, 4};
for (unsigned i = 0; i < sizeof(states) / sizeof(states[0]); i++) {
reset(&f, states[i], 0); sql(&f, "BEGIN;UPDATE attachment_keys SET error=8 WHERE channel='900'");
receive(&f, 0); assert(!sqlite3_get_autocommit(f.inst->topo_sqlite_db));
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f.inst->topo_sqlite_db, "SELECT error FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) == 8 && sqlite3_finalize(st) == SQLITE_OK);
sql(&f, "ROLLBACK"); receive(&f, 1);
}
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_requests BEGIN SELECT RAISE(ABORT,'request'); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_requests BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_outbox BEGIN SELECT RAISE(ABORT,'queue'); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_outbox BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_request BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(ABORT,'upsert'); END",
"CREATE TEMP TRIGGER reject_request BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(IGNORE); END"
};
for (unsigned i = 0; i < sizeof(triggers) / sizeof(triggers[0]); i++) {
reset(&f, i < 2 ? -1 : 2, i >= 4); sql(&f, triggers[i]); receive(&f, 0);
sql(&f, "DROP TRIGGER reject_request"); receive(&f, 1);
}
for (int state = -1; state <= 2; state += 3) {
reset(&f, state, 0); unsigned commits = 0; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto, &commits);
receive(&f, 0); assert(commits == 1); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); receive(&f, 1);
}
for (int state = 0; state <= 5; state++) if (state == 1 || state == 5) { reset(&f, state, 1); receive(&f, 0); }
reset(&f, 0, 0); char query[256];
snprintf(query, sizeof(query), "UPDATE attachment_key_requests SET incoming=0 WHERE channel='%s'", f.channel);
sql(&f, query); receive(&f, 0);
reset(&f, -1, 0); f.corrupt = 1; receive(&f, 0); f.corrupt = 0;
f.flags = ROUTER_FLAG_SIGNED; receive(&f, 0); f.flags |= ROUTER_FLAG_ENCRYPTED;
snprintf(query, sizeof(query), "UPDATE peers_%s SET deleted=1 WHERE node_id=%lld", f.channel, (long long)(sqlite3_int64)f.peer);
sql(&f, query); receive(&f, 0);
snprintf(query, sizeof(query), "UPDATE peers_%s SET deleted=0 WHERE node_id=%lld", f.channel, (long long)(sqlite3_int64)f.peer);
sql(&f, query); receive(&f, 1); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment requests: foreign transaction preserved, ABORT/IGNORE, commit veto, direction/state/identity/member checks");
}
static void test_ack_retry(void) {
struct fixture f; setup(&f);
for (int unavailable = 0; unavailable < 3; unavailable++) {
reset(&f, -1, 0);
if (unavailable == 0) f.route_up = 0;
else if (unavailable == 1) f.room = 0;
else f.send_rc = -1;
receive(&f, 1); struct snapshot saved = snapshot(&f);
f.route_up = f.room = 1; f.send_rc = 0; receive(&f, 1);
assert(saved.created == f.expected.created && f.expected.command == 0);
}
cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment requests: missing route, backpressure and send failure retain request; duplicate retries ACK without new consent");
}
static void test_rollback_failure(void) {
struct fixture f; setup(&f); fault = COMMIT; fault_operation = NO_OPERATION; fail_rollback = 1; receive(&f, 0);
assert(!fault && !fail_rollback && !f.inst->running && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
assert(!f.inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS]);
chat_event_set_handler(f.inst, NULL); active = NULL; utun_instance_destroy(f.inst);
equal(&f); assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment requests: failed rollback sends no ACK/event, quiesces/stops core, owner close rolls back request");
}
/* Получатель уже имеет проверенный ciphertext; именно эти локальные строки потребляет таймер. */
static void outgoing_reset(struct fixture* f, int queued) {
reset(f, -1, 0); f->outgoing = 1;
char query[768];
snprintf(query, sizeof(query), "UPDATE attachment_keys SET author=%lld,key=NULL,downloaded=1,wanted=1,ready=0,decrypting=0,error=0"
" WHERE channel='%s'", (long long)(sqlite3_int64)f->peer, f->channel); sql(f, query);
if (queued) {
snprintf(query, sizeof(query), "INSERT INTO attachment_key_outbox(channel,message_id,peer,command,retry_at)"
" SELECT channel,message_id,author,4,9223372036854775807 FROM attachment_keys WHERE channel='%s'", f->channel); sql(f, query);
}
}
static void outgoing_tick(struct fixture* f, int create, int sends) {
f->expected = snapshot(f); f->allow_result = create || sends; f->acks = f->events = 0; f->started = database_time(f);
if (create) {
f->expected.exists = 1; f->expected.incoming = 0; f->expected.state = 0;
f->expected.created = -1; f->expected.command = 1; f->expected.retry = 0;
}
assert(attachment_tick && CC(f->inst)->attachment_timer);
uasync_cancel_timeout(f->ua, CC(f->inst)->attachment_timer); attachment_tick(f->inst);
if (sends || (create && (!f->route_up || !f->room))) {
f->expected.retry = snapshot(f).retry;
assert(f->expected.retry >= f->started + 5 && f->expected.retry <= database_time(f) + 5);
}
equal(f); assert(f->events == (unsigned)create && f->acks == (unsigned)sends);
}
static void test_outgoing_controls(void) {
struct fixture f; setup(&f); outgoing_reset(&f, 0);
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_requests BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_requests BEGIN SELECT RAISE(ABORT,'request'); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_outbox BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_request BEFORE INSERT ON attachment_key_outbox BEGIN SELECT RAISE(ABORT,'outbox'); END",
"CREATE TEMP TRIGGER reject_request BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_request BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(ABORT,'upsert'); END"
};
for (unsigned i = 0; i < sizeof(triggers) / sizeof(triggers[0]); i++) {
outgoing_reset(&f, i >= 4); sql(&f, triggers[i]); outgoing_tick(&f, 0, 0);
sql(&f, "DROP TRIGGER reject_request"); outgoing_tick(&f, 1, 1); outgoing_tick(&f, 0, 0);
}
outgoing_reset(&f, 0); sql(&f, "BEGIN;UPDATE attachment_keys SET error=8 WHERE channel='900'");
outgoing_tick(&f, 0, 0); assert(!sqlite3_get_autocommit(f.inst->topo_sqlite_db));
sql(&f, "ROLLBACK"); outgoing_tick(&f, 1, 1);
/* Уже сохранённая очередь во внешней транзакции также не уходит в сеть. */
sql(&f, "UPDATE attachment_key_outbox SET retry_at=0;BEGIN;UPDATE attachment_keys SET error=8 WHERE channel='900'");
outgoing_tick(&f, 0, 0); assert(!sqlite3_get_autocommit(f.inst->topo_sqlite_db));
sql(&f, "ROLLBACK"); outgoing_tick(&f, 0, 1);
outgoing_reset(&f, 0); unsigned commits = 0; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto, &commits);
outgoing_tick(&f, 0, 0); assert(commits == 1); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL);
outgoing_tick(&f, 1, 1); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS outgoing requests: ABORT/IGNORE, commit veto, foreign transaction postpones creation and send");
}
static void test_outgoing_faults(void) {
struct fixture f; setup(&f); unsigned count = 0;
for (int queued = 0; queued < 2; queued++) {
const enum operation operations[] = {NO_OPERATION, PENDING, ELIGIBLE, INSERT, OUTBOX, NO_OPERATION};
for (unsigned i = 0; i < sizeof(operations) / sizeof(operations[0]); i++) {
enum operation op = operations[i];
int binds = op == ELIGIBLE || op == INSERT ? 3 : op == OUTBOX ? 4 : 0;
int writes = op == INSERT || op == OUTBOX;
int points = op ? 3 + binds + writes : 1;
for (int point = 0; point < points; point++) {
outgoing_reset(&f, queued);
fault = !op ? (i ? COMMIT : BEGIN) : point == 0 ? PREPARE : point <= binds ? BIND :
point == binds + 1 ? STEP : point == binds + 2 ? FINALIZE : CHANGES;
fault_operation = op; fault_index = point; unsigned previous = fault_hits;
outgoing_tick(&f, 0, 0); assert(!fault && fault_hits == previous + 1 && sqlite3_get_autocommit(f.inst->topo_sqlite_db));
outgoing_tick(&f, 1, 1); outgoing_tick(&f, 0, 0); count++;
}
}
}
cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS outgoing requests: %u SQL faults/retries, both outbox paths, exact committed snapshots", count);
}
static void test_outgoing_rollback_failure(void) {
struct fixture f; setup(&f); outgoing_reset(&f, 0);
fault = COMMIT; fault_operation = NO_OPERATION; fail_rollback = 1; outgoing_tick(&f, 0, 0);
assert(!fault && !fail_rollback && !f.inst->running && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
assert(!CC(f.inst)->attachment_timer && !f.inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS]);
chat_event_set_handler(f.inst, NULL); active = NULL; utun_instance_destroy(f.inst);
equal(&f); assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS outgoing requests: rollback failure stops core without event/send or rearming timer");
}
static void test_outgoing_restart(void) {
struct fixture f; setup(&f);
for (int unavailable = 0; unavailable < 3; unavailable++) {
outgoing_reset(&f, 0);
if (!unavailable) f.route_up = 0;
else if (unavailable == 1) f.room = 0;
else f.send_rc = -1;
outgoing_tick(&f, 1, unavailable == 2);
struct snapshot saved = snapshot(&f);
chat_event_set_handler(f.inst, NULL); active = NULL; utun_instance_destroy(f.inst);
char config[1024];
snprintf(config, sizeof(config), "[global]\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", f.directory);
f.inst = utun_instance_create_from_str(f.ua, config);
assert(f.inst && !utun_core_start(f.inst) && !chat_service_start(f.inst));
f.route.inst = f.inst; f.route_up = f.room = 1; f.send_rc = 0; active = &f;
chat_event_set_handler(f.inst, event);
struct snapshot restored = snapshot(&f);
assert(restored.exists && restored.incoming == 0 && restored.state == 0 && restored.command == 1 && restored.retry == 0);
assert(restored.created == saved.created); outgoing_tick(&f, 0, 1);
}
cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS outgoing requests: no route/backpressure/send error preserve queue and date across real instance restart");
}
static void pump_reset(struct fixture* f) {
outgoing_reset(f, 0); f->pump_only = 1;
char query[768];
snprintf(query, sizeof(query), "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)"
" SELECT channel,message_id,author,0,0,123 FROM attachment_keys WHERE channel='%s';"
" INSERT INTO attachment_key_outbox(channel,message_id,peer,command,retry_at)"
" SELECT channel,message_id,author,1,0 FROM attachment_keys WHERE channel='%s'", f->channel, f->channel); sql(f, query);
}
static void test_pump(void) {
struct fixture f; setup(&f); unsigned count = 0;
const enum operation operations[] = {NO_OPERATION, PUMP_READ, PUMP_RETRY, NO_OPERATION};
for (unsigned op = 0; op < sizeof(operations) / sizeof(operations[0]); op++) {
int bindings = operations[op] == PUMP_RETRY ? 3 : 0, points = operations[op] ? 3 + bindings + (bindings != 0) : 1;
for (int point = 0; point < points; point++) {
pump_reset(&f); fault_operation = operations[op]; fault_index = point;
fault = !operations[op] ? (op ? COMMIT : BEGIN) : point == 0 ? PREPARE : point <= bindings ? BIND :
point == bindings + 1 ? STEP : point == bindings + 2 ? FINALIZE : CHANGES;
unsigned previous = fault_hits; outgoing_tick(&f, 0, 0);
assert(!fault && fault_hits == previous + 1 && sqlite3_get_autocommit(f.inst->topo_sqlite_db));
outgoing_tick(&f, 0, 1); outgoing_tick(&f, 0, 0); count++;
}
}
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_retry BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_retry BEFORE UPDATE ON attachment_key_outbox BEGIN SELECT RAISE(ABORT,'retry'); END"
};
for (unsigned i = 0; i < 2; i++) {
pump_reset(&f); sql(&f, triggers[i]); outgoing_tick(&f, 0, 0);
sql(&f, "DROP TRIGGER reject_retry"); outgoing_tick(&f, 0, 1);
}
pump_reset(&f); unsigned commits = 0; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto, &commits);
outgoing_tick(&f, 0, 0); assert(commits == 1); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); outgoing_tick(&f, 0, 1);
pump_reset(&f); fault = COMMIT; fault_operation = NO_OPERATION; fail_rollback = 1; outgoing_tick(&f, 0, 0);
assert(!fault && !fail_rollback && !f.inst->running && !CC(f.inst)->attachment_timer && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
chat_event_set_handler(f.inst, NULL); active = NULL; utun_instance_destroy(f.inst); equal(&f);
assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS outbox:%u SQL faults, ABORT/IGNORE/veto/rollback failure; no send before retry commit, exact queue/date preserved", count);
}
int main(int argc, char** argv) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_INFO);
utun_instance_set_tun_init_enabled(0); size_t baseline = u_get_allocated_count();
if (argc != 2 || strcmp(argv[1], "--outgoing")) { test_faults(); test_controls(); test_ack_retry(); test_rollback_failure(); }
test_outgoing_controls(); test_outgoing_faults(); test_outgoing_restart(); test_outgoing_rollback_failure();
test_pump();
assert(!fault && !fail_rollback && u_get_allocated_count() == baseline);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment requests: reader before ACK/events, stable duplicate date, neighboring key and allocations preserved");
return 0;
}
+781 -14
View File
@@ -3,21 +3,40 @@
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <openssl/sha.h>
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_attachment.h"
#include "../src/chat/chat_core_priv.h"
#include "../src/chat/chat_event.h"
#include "../src/chat/chat_feedback.h"
#include "../src/media_async/media_async.h"
#include "../src/media_delivery/media_download.h"
#include "../src/media_delivery/media_index.h"
#include "../lib/platform_compat.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
enum fault { NONE, BEGIN, PREPARE, BIND_1, BIND_2, BIND_3, BIND_4, BIND_5, BIND_6, BIND_7, BIND_8,
STEP, FINALIZE, COMMIT, FAULT_COUNT };
STEP, FINALIZE, COMMIT, FAULT_COUNT, CHANGES };
static enum fault fault;
static unsigned hits[FAULT_COUNT], rollback_fault;
static int fail_rollback, fail_submit;
static const char* insert_prefix = "INSERT INTO attachment_keys(channel,crypto_id,author,key,";
static const char* fault_prefix;
static int target_written;
static timeout_callback_t attachment_tick;
static timeout_callback_t channel_backfill_tick, full_backfill_tick;
static const char* fault_transaction;
static int fault_transaction_open, block_index;
static const struct media_index_result* expected_download;
static unsigned download_starts;
static unsigned completion_calls;
static int block_presence;
static unsigned blocked_completions;
static int completion_transaction;
static int channel_transaction, fail_channel_rollback;
static const char content[] = "attachment source must survive SQL failure";
struct fixture {
@@ -26,14 +45,14 @@ struct fixture {
sqlite3* reader;
char directory[256], source[512], channel[32];
uint8_t id[16], key[32];
unsigned rows, events, submissions;
int success, inspect_submit;
unsigned rows, events, submissions, decrypt_submissions, key_events, deleted_events, received_events, attachment_events;
int success, inspect_submit, decrypt_mode, allow_worker, result_error, delete_mode, local_mode;
};
static struct fixture* active;
static int take(enum fault target) {
if (fault != target) return 0;
fault = NONE; hits[target]++;
fault = NONE; if (target < FAULT_COUNT) hits[target]++;
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected stage=%d", target); return 1;
}
@@ -41,20 +60,55 @@ static int is_preparation(sqlite3_stmt* st) {
return st && !strncmp(sqlite3_sql(st), insert_prefix, strlen(insert_prefix));
}
static int is_target(sqlite3_stmt* st) {
const char* prefix = fault_prefix ? fault_prefix : insert_prefix;
return st && !strncmp(sqlite3_sql(st), prefix, strlen(prefix));
}
extern void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*);
void* __wrap_uasync_set_timeout(struct UASYNC* ua, int delay, void* arg, timeout_callback_t callback, const char* name) {
if (name && !strcmp(name, "attachment keys")) attachment_tick = callback;
if (name && !strcmp(name, "chat_md_chbl")) channel_backfill_tick = callback;
if (name && !strcmp(name, "chat_media_bl")) full_backfill_tick = callback;
return __real_uasync_set_timeout(ua, delay, arg, callback, name);
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char**, char**), void* arg, char** error) {
if (!strcmp(sql, "BEGIN IMMEDIATE") && take(BEGIN)) return SQLITE_BUSY;
if (!strcmp(sql, "COMMIT") && take(COMMIT)) return SQLITE_BUSY;
if (!strcmp(sql, "ROLLBACK") && fail_rollback) {
if (!strcmp(sql, "SAVEPOINT delete_topo_channel")) channel_transaction = 1;
if (fail_channel_rollback && !strcmp(sql, "ROLLBACK TO delete_topo_channel; RELEASE delete_topo_channel")) {
fail_channel_rollback = 0; DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "delete test: injected rollback failure"); return SQLITE_IOERR;
}
int binding_scope = fault_prefix && !strcmp(fault_prefix, "UPDATE attachment_keys SET message_id=");
int completion_scope = fault_prefix && !binding_scope && strcmp(fault_prefix, "UPDATE attachment_keys SET decrypting=1 WHERE");
int targeted = fault_transaction ? strstr(sql, fault_transaction) != NULL : binding_scope ? strstr(sql, "/* attachment binding */") != NULL :
!completion_scope || strstr(sql, "/* attachment completion */");
if (targeted && !strncmp(sql, "BEGIN IMMEDIATE", strlen("BEGIN IMMEDIATE")) && take(BEGIN)) return SQLITE_BUSY;
if (targeted && !strncmp(sql, "COMMIT", strlen("COMMIT")) && take(COMMIT)) return SQLITE_BUSY;
if (!strcmp(sql, "ROLLBACK") && fail_rollback && (!completion_scope || completion_transaction || fault_transaction_open)) {
fail_rollback = 0; rollback_fault++;
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected rollback failure"); return SQLITE_IOERR;
}
return __real_sqlite3_exec(db, sql, cb, arg, error);
int rc = __real_sqlite3_exec(db, sql, cb, arg, error);
if (fault_transaction && strstr(sql, fault_transaction) && !strncmp(sql, "BEGIN", 5) && rc == SQLITE_OK) fault_transaction_open = 1;
if (!strncmp(sql, "COMMIT", strlen("COMMIT")) || !strcmp(sql, "ROLLBACK")) fault_transaction_open = 0;
if (strstr(sql, "RELEASE delete_topo_channel")) channel_transaction = 0;
if (!strncmp(sql, "COMMIT", strlen("COMMIT")) || !strcmp(sql, "ROLLBACK")) completion_transaction = 0;
return rc;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int length, sqlite3_stmt** st, const char** tail) {
if (!strncmp(sql, insert_prefix, strlen(insert_prefix)) && take(PREPARE)) { *st = NULL; return SQLITE_NOMEM; }
const char* observation_prefix = "INSERT INTO attachment_keys(channel,crypto_id,message_id,author";
if (block_index && !strncmp(sql, observation_prefix, strlen(observation_prefix))) {
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "index test: blocked observation prepare"); *st = NULL; return SQLITE_BUSY;
}
if (!strncmp(sql, "SELECT 1 FROM attachment_keys WHERE", strlen("SELECT 1 FROM attachment_keys WHERE"))) {
completion_calls++; completion_transaction = 1;
if (block_presence) { blocked_completions++; *st = NULL; return SQLITE_BUSY; }
}
const char* prefix = fault_prefix ? fault_prefix : insert_prefix;
if (!strncmp(sql, prefix, strlen(prefix)) && take(PREPARE)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, length, st, tail);
}
@@ -63,35 +117,48 @@ int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* data, int
if (is_preparation(st)) {
if (index == 2) { assert(length == 16); memcpy(active->id, data, 16); }
if (index == 4) { assert(length == 32); memcpy(active->key, data, 32); }
if (take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
}
if (is_target(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, data, length, destroy);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* data, int length, void (*destroy)(void*)) {
if (is_preparation(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
if (is_target(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, data, length, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (is_preparation(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
if (is_target(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (is_target(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
if (is_preparation(st) && take(STEP)) return SQLITE_IOERR;
target_written = is_target(st);
if (target_written && take(STEP)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int failed = is_preparation(st) && take(FINALIZE);
int failed = is_target(st) && take(FINALIZE);
int rc = __real_sqlite3_finalize(st); return failed ? SQLITE_IOERR : rc;
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (target_written && take(CHANGES)) return 0;
return __real_sqlite3_changes(db);
}
static void sql(sqlite3* db, const char* text) {
int rc = sqlite3_exec(db, text, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment test SQL rc=%d: %s", rc, sqlite3_errmsg(db));
@@ -130,6 +197,8 @@ static void committed(struct fixture* f, int published) {
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
static void decryption_state(struct fixture* f, int ready, int decrypting, int error);
extern void __real_media_async_submit(struct media_async*, struct UASYNC*, ma_work_fn, void*, ma_done_fn, void*);
void __wrap_media_async_submit(struct media_async* ma, struct UASYNC* ua, ma_work_fn work, void* data, ma_done_fn done, void* arg) {
if (active && active->inspect_submit) {
@@ -137,10 +206,32 @@ void __wrap_media_async_submit(struct media_async* ma, struct UASYNC* ua, ma_wor
committed(active, 0);
if (fail_submit) { fail_submit = 0; done(arg, -1); return; }
}
if (active && active->decrypt_mode) {
if (!active->allow_worker || !sqlite3_get_autocommit(active->inst->topo_sqlite_db))
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "decryption test: worker started allowed=%d autocommit=%d previous_workers=%u",
active->allow_worker, sqlite3_get_autocommit(active->inst->topo_sqlite_db), active->decrypt_submissions);
assert(active->allow_worker && sqlite3_get_autocommit(active->inst->topo_sqlite_db));
decryption_state(active, 0, 1, 0); active->decrypt_submissions++;
}
__real_media_async_submit(ma, ua, work, data, done, arg);
}
static void channel_state(struct fixture* f, int deleted);
static void local_event(struct fixture* f);
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int length) {
if (type == CHAT_EVT_MSG_RECEIVED && active) { active->received_events++; return; }
if (type == CHAT_EVT_ATTACHMENT_KEYS_CHANGED && active) {
active->attachment_events++;
if (active->local_mode) { assert(inst == active->inst && sqlite3_get_autocommit(inst->topo_sqlite_db)); local_event(active); return; }
}
if (type == CHAT_EVT_CHANNEL_DELETED && active && active->delete_mode) {
assert(sqlite3_get_autocommit(inst->topo_sqlite_db)); channel_state(active, 1); active->deleted_events++; return;
}
if (type == CHAT_EVT_ATTACHMENT_KEYS_CHANGED && active && active->decrypt_mode) {
assert(inst == active->inst && sqlite3_get_autocommit(inst->topo_sqlite_db));
decryption_state(active, !active->result_error, 0, active->result_error); active->key_events++; return;
}
if (type != CHAT_EVT_MSG_SUBMITTED) return;
assert(active && active->inst == inst && length == 9);
active->events++; active->success = data[8];
@@ -275,12 +366,688 @@ static void test_rollback_failure(void) {
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: failed rollback requests owner stop, no worker/source transfer, close rolls back uncommitted row");
}
static void decryption_state(struct fixture* f, int ready, int decrypting, int error) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT key,info,content_type,ready,decrypting,error FROM attachment_keys"
" WHERE channel=? AND crypto_id=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), f->key, 32));
struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "source.bin"};
uint8_t wire[ATTACHMENT_WIRE_MAX]; int bytes = attachment_encode(&info, wire, sizeof(wire));
assert(bytes > 0 && sqlite3_column_bytes(st, 1) == bytes && !memcmp(sqlite3_column_blob(st, 1), wire, (size_t)bytes));
assert(!strcmp((const char*)sqlite3_column_text(st, 2), "application/octet-stream"));
int actual_ready = sqlite3_column_int(st, 3), actual_decrypting = sqlite3_column_int(st, 4), actual_error = sqlite3_column_int(st, 5);
if (actual_ready != ready || actual_decrypting != decrypting || actual_error != error)
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "decryption test: reader ready/decrypting/error=%d/%d/%d expected=%d/%d/%d",
actual_ready, actual_decrypting, actual_error, ready, decrypting, error);
assert(actual_ready == ready && actual_decrypting == decrypting && actual_error == error);
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
static void plaintext_path(struct fixture* f, char path[512]) {
char hex[33];
for (unsigned i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", f->id[i]);
snprintf(path, 512, "%s/decrypted/%s/%s.file", f->directory, f->channel, hex);
}
static void decryption_reset(struct fixture* f) {
assert(!CC(f->inst)->attachment_file_count && !CC(f->inst)->attachment_files);
char path[512], query[256]; plaintext_path(f, path);
assert(!remove(path) || errno == ENOENT);
snprintf(query, sizeof(query), "UPDATE attachment_keys SET ready=0,decrypting=0,error=0,wanted=1,downloaded=1 WHERE channel='%s'", f->channel);
sql(f->inst->topo_sqlite_db, query);
f->decrypt_mode = 1; f->allow_worker = 0; f->key_events = f->decrypt_submissions = 0; f->result_error = 0;
decryption_state(f, 0, 0, 0);
}
static void tick(struct fixture* f) {
assert(attachment_tick && CC(f->inst)->attachment_timer);
uasync_cancel_timeout(f->ua, CC(f->inst)->attachment_timer); attachment_tick(f->inst);
}
static void decryption_done(struct fixture* f) {
for (unsigned poll = 0; poll < 2000 && !f->key_events; poll++) uasync_poll(f->ua, 10);
if (f->key_events != 1 || f->decrypt_submissions != 1 || CC(f->inst)->attachment_file_count || CC(f->inst)->attachment_files)
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "decryption test: completion events=%u workers=%u retained=%u list=%d",
f->key_events, f->decrypt_submissions, CC(f->inst)->attachment_file_count, CC(f->inst)->attachment_files != NULL);
assert(f->key_events == 1 && f->decrypt_submissions == 1 && !CC(f->inst)->attachment_file_count && !CC(f->inst)->attachment_files);
decryption_state(f, !f->result_error, 0, f->result_error);
if (!f->result_error) {
char path[512], buffer[sizeof(content)]; plaintext_path(f, path); FILE* file = fopen(path, "rb"); assert(file);
assert(fread(buffer, 1, sizeof(buffer), file) == sizeof(buffer) && !memcmp(buffer, content, sizeof(buffer)));
assert(fgetc(file) == EOF && !fclose(file));
}
}
static void wait_completion(struct fixture* f, unsigned previous) {
for (unsigned poll = 0; poll < 2000 && completion_calls == previous; poll++) uasync_poll(f->ua, 10);
assert(completion_calls > previous);
}
static void test_decryption_claim(void) {
struct fixture f; setup(&f); source(&f); publish(&f);
const enum fault points[] = {BEGIN, PREPARE, BIND_1, BIND_2, BIND_3, STEP, FINALIZE, CHANGES, COMMIT};
fault_prefix = "UPDATE attachment_keys SET decrypting=1 WHERE";
for (unsigned i = 0; i < sizeof(points) / sizeof(points[0]); i++) {
decryption_reset(&f); fault = points[i]; tick(&f);
assert(fault == NONE && !f.decrypt_submissions && !f.key_events && !CC(f.inst)->attachment_file_count);
decryption_state(&f, 0, 0, 0); f.allow_worker = 1; tick(&f); decryption_done(&f);
}
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_claim BEFORE UPDATE ON attachment_keys WHEN NEW.decrypting=1 BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_claim BEFORE UPDATE ON attachment_keys WHEN NEW.decrypting=1 BEGIN SELECT RAISE(ABORT,'claim'); END"
};
for (unsigned i = 0; i < 2; i++) {
decryption_reset(&f); sql(f.inst->topo_sqlite_db, triggers[i]); tick(&f);
assert(!f.decrypt_submissions && !f.key_events); decryption_state(&f, 0, 0, 0);
sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_claim"); f.allow_worker = 1; tick(&f); decryption_done(&f);
}
fault_prefix = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS decryption claim:9 SQL faults, ABORT/IGNORE, no worker before committed single-row claim; real decrypt on retry");
}
static void test_decryption_completion(void) {
struct fixture f; setup(&f); source(&f); publish(&f); unsigned cases = 0;
const char* prefixes[] = {"SELECT 1 FROM attachment_keys WHERE", "UPDATE attachment_keys SET info="};
for (unsigned op = 0; op < 2; op++) {
int binds = op ? 4 : 2;
for (int i = 0; i < 3 + binds + (op ? 3 : 0); i++) {
decryption_reset(&f); f.allow_worker = 1; tick(&f); assert(f.decrypt_submissions == 1);
fault_prefix = prefixes[op];
fault = i == 0 ? PREPARE : i <= binds ? (enum fault)(BIND_1 + i - 1) : i == binds + 1 ? STEP :
i == binds + 2 ? FINALIZE : i == binds + 3 ? CHANGES : i == binds + 4 ? BEGIN : COMMIT;
for (unsigned poll = 0; poll < 2000 && fault != NONE; poll++) uasync_poll(f.ua, 10);
assert(fault == NONE && !f.key_events && CC(f.inst)->attachment_file_count == 1 && CC(f.inst)->attachment_files);
decryption_state(&f, 0, 1, 0); tick(&f); decryption_done(&f); cases++;
}
}
decryption_reset(&f); f.allow_worker = 1; tick(&f);
sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'");
for (unsigned poll = 0; poll < 50; poll++) uasync_poll(f.ua, 10);
assert(!f.key_events && !sqlite3_get_autocommit(f.inst->topo_sqlite_db) && CC(f.inst)->attachment_file_count == 1);
sql(f.inst->topo_sqlite_db, "ROLLBACK"); tick(&f); decryption_done(&f);
fault_prefix = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS decryption completion:%u SQL faults retain result/key, retry commits without second worker; foreign transaction untouched", cases);
}
/* Hash-обслуживание истории может завершить свою транзакцию в том же poll; veto относится только к completion. */
static int veto_completion(void* arg) {
unsigned* count = arg;
if (!completion_transaction || *count) return 0;
*count = 1; return 1;
}
static void test_completion_controls(void) {
struct fixture f; setup(&f); source(&f); publish(&f);
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_result BEFORE UPDATE ON attachment_keys WHEN NEW.ready=1 AND OLD.ready=0 BEGIN SELECT RAISE(IGNORE); END",
"CREATE TEMP TRIGGER reject_result BEFORE UPDATE ON attachment_keys WHEN NEW.ready=1 AND OLD.ready=0 BEGIN SELECT RAISE(ABORT,'result'); END"
};
for (unsigned i = 0; i < 2; i++) {
decryption_reset(&f); sql(f.inst->topo_sqlite_db, triggers[i]); f.allow_worker = 1;
unsigned previous = completion_calls; tick(&f); wait_completion(&f, previous);
assert(!f.key_events && CC(f.inst)->attachment_file_count == 1); decryption_state(&f, 0, 1, 0);
sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_result"); tick(&f); decryption_done(&f);
}
decryption_reset(&f); f.allow_worker = 1; tick(&f);
unsigned vetoes = 0, previous = completion_calls; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto_completion, &vetoes);
wait_completion(&f, previous); assert(vetoes == 1 && !f.key_events && sqlite3_get_autocommit(f.inst->topo_sqlite_db));
decryption_state(&f, 0, 1, 0); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); tick(&f); decryption_done(&f);
/* Файл уже проверен и опубликован; удаление канала отменяет только принадлежащий задаче результат. */
decryption_reset(&f); f.allow_worker = 1; tick(&f); previous = completion_calls;
fault_prefix = "UPDATE attachment_keys SET info="; fault = STEP; wait_completion(&f, previous);
assert(fault == NONE && !f.key_events && CC(f.inst)->attachment_file_count == 1);
char path[512]; plaintext_path(&f, path); assert(ma_file_size(path) == sizeof(content));
chat_core_delete_channel(f.inst, f.channel); tick(&f);
assert(!CC(f.inst)->attachment_files && !CC(f.inst)->attachment_file_count && !f.key_events && ma_file_size(path) < 0);
fault_prefix = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS completion controls: ABORT/IGNORE, real commit veto and deletion while result waits for commit");
}
static void test_completion_stop(void) {
struct fixture f; setup(&f); source(&f); publish(&f); decryption_reset(&f); f.allow_worker = 1;
unsigned previous = completion_calls; tick(&f); fault_prefix = "UPDATE attachment_keys SET info="; fault = STEP;
wait_completion(&f, previous); assert(fault == NONE && !f.key_events && CC(f.inst)->attachment_file_count == 1);
char path[512]; plaintext_path(&f, path); assert(ma_file_size(path) == sizeof(content));
chat_service_stop(f.inst); assert(!f.inst->chat_core && ma_file_size(path) < 0 && !f.key_events);
assert(!chat_service_start(f.inst)); chat_event_set_handler(f.inst, event);
f.decrypt_submissions = 0; // Новый lifecycle запускает новый worker после удаления незавершённого plaintext.
decryption_state(&f, 0, 0, 0); tick(&f); decryption_done(&f);
decryption_reset(&f); f.allow_worker = 1; previous = completion_calls; tick(&f); fault = COMMIT; fail_rollback = 1;
wait_completion(&f, previous);
assert(fault == NONE && !fail_rollback && !f.inst->running && !f.key_events && CC(f.inst)->attachment_file_count == 1);
assert(!CC(f.inst)->attachment_timer && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
chat_event_set_handler(f.inst, NULL); utun_instance_destroy(f.inst); f.inst = NULL;
assert(ma_file_size(path) < 0); decryption_state(&f, 0, 1, 0);
assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0); active = NULL; fault_prefix = NULL;
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS completion stop: deferred result freed after workers, service restart retries; rollback fault stops without event or timer");
}
static void test_worker_error_completion(void) {
struct fixture f; setup(&f); source(&f); publish(&f);
char hex[33], cipher[512], backup[540];
for (unsigned i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", f.id[i]);
snprintf(cipher, sizeof(cipher), "%s/media/%s/%s.uenc", f.directory, f.channel, hex);
snprintf(backup, sizeof(backup), "%s.test-backup", cipher);
const enum fault points[] = {BEGIN, PREPARE, BIND_1, BIND_2, STEP, FINALIZE, CHANGES, COMMIT};
for (unsigned i = 0; i < sizeof(points) / sizeof(points[0]); i++) {
decryption_reset(&f); assert(!rename(cipher, backup)); f.result_error = f.allow_worker = 1;
tick(&f); fault_prefix = "UPDATE attachment_keys SET decrypting=0,error=1 WHERE"; fault = points[i];
for (unsigned poll = 0; poll < 2000 && fault != NONE; poll++) uasync_poll(f.ua, 10);
assert(fault == NONE && !f.key_events && CC(f.inst)->attachment_file_count == 1); decryption_state(&f, 0, 1, 0);
tick(&f); decryption_done(&f); assert(!rename(backup, cipher));
}
fault_prefix = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS failed-worker completion:8 SQL faults retain key/result, error persisted on retry without new worker");
}
static void test_completion_limit(void) {
struct fixture f; setup(&f); block_presence = 1; blocked_completions = 0;
for (unsigned i = 0; i < MEDIA_ASYNC_MAX_TASKS; i++) {
snprintf(f.source, sizeof(f.source), "%s/prepared-%u.bin", f.directory, i); source(&f);
assert(!send_attachment(&f)); f.rows++;
}
for (unsigned poll = 0; poll < 5000 && blocked_completions < MEDIA_ASYNC_MAX_TASKS; poll++) uasync_poll(f.ua, 10);
assert(blocked_completions == MEDIA_ASYNC_MAX_TASKS && CC(f.inst)->attachment_file_count == MEDIA_ASYNC_MAX_TASKS && !f.events);
snprintf(f.source, sizeof(f.source), "%s/rejected.bin", f.directory); source(&f);
assert(send_attachment(&f) == -1); unchanged(&f); f.inspect_submit = 0;
block_presence = 0; tick(&f); assert(!CC(f.inst)->attachment_file_count && !CC(f.inst)->attachment_files);
for (unsigned poll = 0; poll < 5000 && f.events < MEDIA_ASYNC_MAX_TASKS; poll++) uasync_poll(f.ua, 10);
assert(f.events == MEDIA_ASYNC_MAX_TASKS && f.success == 1); source_preserved(&f); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS completion limit:16 retained results, seventeenth source preserved, retry publishes all exactly once");
}
static void channel_state(struct fixture* f, int deleted) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT EXISTS(SELECT 1 FROM channels WHERE channel_id=?1),"
"EXISTS(SELECT 1 FROM attachment_keys WHERE channel=?1),EXISTS(SELECT 1 FROM attachment_key_requests WHERE channel=?1),"
"EXISTS(SELECT 1 FROM attachment_key_outbox WHERE channel=?1),EXISTS(SELECT 1 FROM sqlite_master WHERE name='msg_'||?1),"
"EXISTS(SELECT 1 FROM merkle_tree_hash WHERE namespace='audit:'||?1)",
-1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
for (int i = 0; i < 6; i++) {
if (sqlite3_column_int(st, i) != !deleted)
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "delete test: reader column=%d exists=%d expected=%d", i, sqlite3_column_int(st, i), !deleted);
assert(sqlite3_column_int(st, i) == !deleted);
}
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
if (!deleted) {
assert(sqlite3_prepare_v2(f->reader, "SELECT r.incoming,r.state,r.created_at,o.command,o.retry_at FROM attachment_key_requests r"
" JOIN attachment_key_outbox o USING(channel,message_id,peer) WHERE r.channel=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
const int expected[] = {1, 2, 123, 3, 17};
for (int i = 0; i < 5; i++) assert(sqlite3_column_int(st, i) == expected[i]);
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
}
static void channel_seed(struct fixture* f) {
source(f); publish(f); f->delete_mode = 1;
char query[768];
snprintf(query, sizeof(query), "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)"
" SELECT channel,message_id,99,1,2,123 FROM attachment_keys WHERE channel='%s';"
" INSERT INTO attachment_key_outbox(channel,message_id,peer,command,retry_at)"
" SELECT channel,message_id,99,3,17 FROM attachment_keys WHERE channel='%s';"
" INSERT INTO merkle_tree_hash(namespace,level,prefix64,hash) VALUES('audit:%s',0,0,zeroblob(32))",
f->channel, f->channel, f->channel);
sql(f->inst->topo_sqlite_db, query); channel_state(f, 0);
}
static int veto_channel(void* arg) {
unsigned* count = arg;
if (!channel_transaction || *count) return 0;
*count = 1; return 1;
}
static void test_channel_delete_rollback(void) {
const char* tables[] = {"channels", "attachment_keys", "attachment_key_requests", "attachment_key_outbox", "merkle_tree_hash"};
for (unsigned table = 0; table < 5; table++) for (int ignored = 0; ignored < 2; ignored++) {
struct fixture f; setup(&f); channel_seed(&f); char path[512], trigger[384]; plaintext_path(&f, path);
snprintf(trigger, sizeof(trigger), "CREATE TEMP TRIGGER reject_channel BEFORE DELETE ON %s%s BEGIN SELECT RAISE(%s); END",
tables[table], table == 4 ? " WHEN OLD.namespace LIKE 'audit:%'" : "", ignored ? "IGNORE" : "ABORT,'channel'");
sql(f.inst->topo_sqlite_db, trigger); chat_core_delete_channel(f.inst, f.channel);
assert(!f.deleted_events && rows(&f) == f.rows && ma_file_size(path) == sizeof(content)); committed(&f, 1); channel_state(&f, 0);
sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_channel"); chat_core_delete_channel(f.inst, f.channel);
assert(f.deleted_events == 1 && rows(&f) == f.rows - 1 && ma_file_size(path) < 0); cleanup(&f);
}
const enum fault points[] = {PREPARE, BIND_1, STEP, FINALIZE};
for (unsigned i = 0; i < 4; i++) {
struct fixture f; setup(&f); channel_seed(&f); char path[512]; plaintext_path(&f, path);
fault_prefix = "DELETE FROM channels WHERE"; fault = points[i]; chat_core_delete_channel(f.inst, f.channel);
assert(fault == NONE && !f.deleted_events && ma_file_size(path) == sizeof(content)); committed(&f, 1); channel_state(&f, 0);
fault_prefix = NULL; chat_core_delete_channel(f.inst, f.channel); assert(f.deleted_events == 1); cleanup(&f);
}
struct fixture f; setup(&f); channel_seed(&f); char path[512]; plaintext_path(&f, path);
sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'");
chat_core_delete_channel(f.inst, f.channel); assert(!f.deleted_events && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
channel_state(&f, 0); assert(ma_file_size(path) == sizeof(content)); sql(f.inst->topo_sqlite_db, "ROLLBACK");
unsigned vetoes = 0; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto_channel, &vetoes); chat_core_delete_channel(f.inst, f.channel);
assert(vetoes == 1 && !f.deleted_events && ma_file_size(path) == sizeof(content)); channel_state(&f, 0); committed(&f, 1);
sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); chat_core_delete_channel(f.inst, f.channel);
assert(f.deleted_events == 1 && ma_file_size(path) < 0); cleanup(&f);
setup(&f); channel_seed(&f); plaintext_path(&f, path);
sql(f.inst->topo_sqlite_db, "CREATE TEMP TRIGGER reject_channel BEFORE DELETE ON channels BEGIN SELECT RAISE(ABORT,'channel'); END");
fail_channel_rollback = 1; chat_core_delete_channel(f.inst, f.channel);
assert(!fail_channel_rollback && !f.inst->running && !f.deleted_events && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
channel_state(&f, 0); assert(ma_file_size(path) == sizeof(content));
chat_event_set_handler(f.inst, NULL); utun_instance_destroy(f.inst); f.inst = NULL;
channel_state(&f, 0); decryption_state(&f, 1, 0, 0); assert(ma_file_size(path) == sizeof(content));
assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0); active = NULL; channel_transaction = 0;
chat_core_delete_channel(NULL, "1");
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS channel delete:14 SQL faults, cascade/Merkle ABORT/IGNORE, veto/foreign transaction/rollback fault; key/files preserved until committed event");
}
static void binding_state(struct fixture* f, const uint8_t message[32], const uint8_t hash[32], int bound) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT message_id,cipher_hash FROM attachment_keys WHERE channel=? AND crypto_id=?",
-1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_bytes(st, 0) == 32 * bound && sqlite3_column_bytes(st, 1) == 32 * bound);
if (bound) assert(!memcmp(sqlite3_column_blob(st, 0), message, 32) && !memcmp(sqlite3_column_blob(st, 1), hash, 32));
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
static void test_attachment_binding(void) {
struct fixture f; setup(&f); source(&f); publish(&f);
uint8_t message[32], hash[32]; sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f.reader, "SELECT message_id,cipher_hash FROM attachment_keys WHERE channel=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f.channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_bytes(st, 1) == 32);
memcpy(message, sqlite3_column_blob(st, 0), 32); memcpy(hash, sqlite3_column_blob(st, 1), 32); assert(sqlite3_finalize(st) == SQLITE_OK);
char query[256]; snprintf(query, sizeof(query), "UPDATE attachment_keys SET message_id=NULL,cipher_hash=NULL WHERE channel='%s'", f.channel);
const enum fault points[] = {BIND_1, BEGIN, PREPARE, BIND_2, BIND_3, BIND_4, BIND_5, BIND_6, BIND_7, STEP, FINALIZE, CHANGES, COMMIT};
fault_prefix = "UPDATE attachment_keys SET message_id=";
for (unsigned i = 0; i < sizeof(points) / sizeof(points[0]); i++) {
sql(f.inst->topo_sqlite_db, query); fault = points[i];
int rc = chat_attachment_bind(f.inst, f.channel, f.id, message, hash);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "binding test: fault=%d rc=%d consumed=%d events=%u", points[i], rc, fault == NONE, f.events);
assert(rc < 0 && fault == NONE && f.events == 1 && sqlite3_get_autocommit(f.inst->topo_sqlite_db));
binding_state(&f, message, hash, 0); committed(&f, 0);
assert(!chat_attachment_bind(f.inst, f.channel, f.id, message, hash)); binding_state(&f, message, hash, 1);
assert(!chat_attachment_bind(f.inst, f.channel, f.id, message, hash)); binding_state(&f, message, hash, 1);
}
uint8_t conflicting[32]; memcpy(conflicting, hash, 32); conflicting[0] ^= 1;
assert(chat_attachment_bind(f.inst, f.channel, f.id, message, conflicting) < 0); binding_state(&f, message, hash, 1);
sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'");
assert(chat_attachment_bind(f.inst, f.channel, f.id, message, hash) < 0 && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
binding_state(&f, message, hash, 1); sql(f.inst->topo_sqlite_db, "ROLLBACK"); committed(&f, 1);
fault_prefix = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment binding:13 SQL faults/retries, exact committed identity, idempotent repeat, conflicting hash and foreign transaction rejected");
}
static void test_attachment_identity(void) {
const char* name = "00112233445566778899aabbccddeeff.uenc";
char body[55] = "*"; uint8_t id[16], unchanged[16]; memset(unchanged, 0xa5, sizeof(unchanged));
assert(b64_encode((const uint8_t*)name, strlen(name), body + 1, 52) == 52); body[53] = '|'; body[54] = 0;
assert(!chat_attachment_identity(body, 54, id));
for (unsigned i = 0; i < 16; i++) assert(id[i] == i * 17);
const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
for (int i = 49; i < 64; i++) {
body[50] = alphabet[i]; memcpy(id, unchanged, 16);
assert(chat_attachment_identity(body, 54, id) < 0 && !memcmp(id, unchanged, 16));
}
body[50] = 'w'; body[51] = '!'; memcpy(id, unchanged, 16);
assert(chat_attachment_identity(body, 54, id) < 0 && !memcmp(id, unchanged, 16));
body[51] = '='; body[52] = '!'; assert(chat_attachment_identity(body, 54, id) < 0);
body[52] = '='; assert(chat_attachment_identity(body, 54, NULL) < 0);
assert(chat_attachment_identity(NULL, 54, id) < 0);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment identity: canonical base64 only, 15 trailing-bit aliases and invalid padding rejected, output preserved on error");
}
struct local_snapshot {
int exists, key_size, info_size, ready, downloaded, wanted, decrypting, error;
uint64_t author;
uint8_t id[16], message[32], key[32], hash[32], info[ATTACHMENT_WIRE_MAX];
char type[32], cipher[1024], plain[1024];
};
static struct local_snapshot local_expected;
static struct local_snapshot local_read(struct fixture* f) {
struct local_snapshot result; memset(&result, 0, sizeof(result)); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT crypto_id,message_id,author,key,cipher_hash,info,content_type,cipher_path,plain_path,"
"ready,downloaded,wanted,decrypting,error FROM attachment_keys WHERE channel=? AND crypto_id=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK);
int rc = sqlite3_step(st); assert(rc == SQLITE_ROW || rc == SQLITE_DONE);
if (rc == SQLITE_ROW) {
result.exists = 1; assert(sqlite3_column_bytes(st, 0) == 16 && sqlite3_column_bytes(st, 1) == 32 && sqlite3_column_bytes(st, 4) == 32);
memcpy(result.id, sqlite3_column_blob(st, 0), 16); memcpy(result.message, sqlite3_column_blob(st, 1), 32);
result.author = (uint64_t)sqlite3_column_int64(st, 2); memcpy(result.hash, sqlite3_column_blob(st, 4), 32);
result.key_size = sqlite3_column_bytes(st, 3); result.info_size = sqlite3_column_bytes(st, 5);
assert((result.key_size == 0 || result.key_size == 32) && result.info_size >= 0 && result.info_size <= ATTACHMENT_WIRE_MAX);
if (result.key_size) memcpy(result.key, sqlite3_column_blob(st, 3), 32);
if (result.info_size) memcpy(result.info, sqlite3_column_blob(st, 5), (size_t)result.info_size);
const char* type = (const char*)sqlite3_column_text(st, 6);
if (type) { assert(strlen(type) < sizeof(result.type)); strcpy(result.type, type); }
const char* cipher = (const char*)sqlite3_column_text(st, 7), *plain = (const char*)sqlite3_column_text(st, 8);
assert(cipher && plain && strlen(cipher) < sizeof(result.cipher) && strlen(plain) < sizeof(result.plain));
strcpy(result.cipher, cipher); strcpy(result.plain, plain);
result.ready = sqlite3_column_int(st, 9); result.downloaded = sqlite3_column_int(st, 10); result.wanted = sqlite3_column_int(st, 11);
result.decrypting = sqlite3_column_int(st, 12); result.error = sqlite3_column_int(st, 13);
assert(sqlite3_step(st) == SQLITE_DONE);
}
assert(sqlite3_finalize(st) == SQLITE_OK); return result;
}
static void local_equal(struct fixture* f, const struct local_snapshot* expected) {
struct local_snapshot seen = local_read(f);
int matches = seen.exists == expected->exists && seen.author == expected->author && seen.key_size == expected->key_size &&
seen.info_size == expected->info_size && seen.ready == expected->ready && seen.downloaded == expected->downloaded &&
seen.wanted == expected->wanted && seen.decrypting == expected->decrypting && seen.error == expected->error &&
!memcmp(seen.id, expected->id, sizeof(seen.id)) && !memcmp(seen.message, expected->message, sizeof(seen.message)) &&
!memcmp(seen.hash, expected->hash, sizeof(seen.hash)) && !memcmp(seen.key, expected->key, sizeof(seen.key)) &&
!memcmp(seen.info, expected->info, sizeof(seen.info)) && !strcmp(seen.type, expected->type) &&
!strcmp(seen.cipher, expected->cipher) && !strcmp(seen.plain, expected->plain);
if (!matches) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA,
"local state test: exists/key/info/ready/downloaded/wanted/decrypting/error=%d/%d/%d/%d/%d/%d/%d/%d expected=%d/%d/%d/%d/%d/%d/%d/%d",
seen.exists, seen.key_size, seen.info_size, seen.ready, seen.downloaded, seen.wanted, seen.decrypting, seen.error,
expected->exists, expected->key_size, expected->info_size, expected->ready, expected->downloaded,
expected->wanted, expected->decrypting, expected->error);
assert(matches);
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->reader, "SELECT key,ready FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_int(st, 1) == 0);
uint8_t key[32] = {85}; assert(!memcmp(sqlite3_column_blob(st, 0), key, 32));
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
static void local_event(struct fixture* f) { local_equal(f, &local_expected); f->key_events++; }
static char* manifest_copy(struct fixture* f, struct local_snapshot* owner) {
*owner = local_read(f); char query[160]; snprintf(query, sizeof(query), "SELECT data FROM msg_%s WHERE message_id=?", f->channel);
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(f->reader, query, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 1, owner->message, 32, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int length = sqlite3_column_bytes(st, 0); assert(length > 0 && length <= 65536);
char* copy = u_malloc((size_t)length + 1); assert(copy); memcpy(copy, sqlite3_column_text(st, 0), (size_t)length); copy[length] = 0;
assert(sqlite3_finalize(st) == SQLITE_OK); return copy;
}
static struct local_snapshot derived_snapshot(const struct local_snapshot* owner) {
struct local_snapshot result = *owner;
memset(result.key, 0, sizeof(result.key)); memset(result.info, 0, sizeof(result.info)); memset(result.type, 0, sizeof(result.type));
result.key_size = result.info_size = result.ready = result.downloaded = result.wanted = result.decrypting = result.error = 0;
return result;
}
/* Shutdown после SQL-отказа не должен принимать незавершённое удаление истории за отсутствие публикации. */
static void test_recovery_transaction(void) {
struct fixture f; setup(&f); source(&f); publish(&f);
struct local_snapshot owner = local_read(&f); char query[160];
assert(!chat_attachment_downloaded(f.inst, f.channel, owner.message, 0)); owner = local_read(&f);
assert(ma_file_size(owner.cipher) > 0 && ma_file_size(owner.plain) == sizeof(content));
snprintf(query, sizeof(query), "BEGIN IMMEDIATE; DELETE FROM msg_%s", f.channel); sql(f.inst->topo_sqlite_db, query);
void* timer = CC(f.inst)->attachment_timer;
assert(chat_attachment_init(f.inst) == -1 && CC(f.inst)->attachment_timer == timer && !CC(f.inst)->attachment_closing);
chat_attachment_destroy(f.inst);
assert(!sqlite3_get_autocommit(f.inst->topo_sqlite_db)); local_equal(&f, &owner);
assert(ma_file_size(owner.cipher) > 0 && ma_file_size(owner.plain) == sizeof(content));
sql(f.inst->topo_sqlite_db, "ROLLBACK");
chat_service_stop(f.inst); assert(!chat_service_start(f.inst)); local_equal(&f, &owner);
assert(ma_file_size(owner.cipher) > 0 && ma_file_size(owner.plain) == sizeof(content)); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS recovery: uncommitted history deletion at shutdown preserves published files and foreign transaction; rollback/restart restores exact record");
}
static void erase_index(struct fixture* f) {
char query[160]; snprintf(query, sizeof(query), "DELETE FROM attachment_keys WHERE channel='%s'", f->channel); sql(f->inst->topo_sqlite_db, query);
}
static int observe(struct fixture* f, const struct local_snapshot* owner, const char* manifest) {
return chat_attachment_observe(f->inst, f->channel, owner->message, owner->author, manifest, strlen(manifest));
}
static void restore_private_metadata(struct fixture* f, const struct local_snapshot* owner) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f->inst->topo_sqlite_db, "UPDATE attachment_keys SET key=?,info=?,content_type=?,ready=1 WHERE channel=?",
-1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 1, owner->key, 32, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 2, owner->info, owner->info_size, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_text(st, 3, owner->type, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_text(st, 4, f->channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_DONE);
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void observation_fault(struct fixture* f, const struct local_snapshot* owner, const char* manifest,
int existing, const char* prefix, enum fault point) {
erase_index(f);
if (existing) { assert(!observe(f, owner, manifest)); restore_private_metadata(f, owner); }
struct local_snapshot before = local_read(f), expected = existing ? *owner : derived_snapshot(owner);
fault_prefix = prefix; fault = point;
assert(observe(f, owner, manifest) == -1 && fault == NONE && sqlite3_get_autocommit(f->inst->topo_sqlite_db)); local_equal(f, &before);
assert(!observe(f, owner, manifest)); local_equal(f, &expected);
assert(!observe(f, owner, manifest)); local_equal(f, &expected); assert(f->events == 1 && f->received_events == 1);
}
static int veto_target(void* arg) {
unsigned* count = arg;
if (!fault_transaction_open || *count) return 0;
*count = 1; return 1;
}
static void test_observation(void) {
struct fixture f; setup(&f); source(&f); publish(&f); struct local_snapshot owner;
char* manifest = manifest_copy(&f, &owner); unsigned cases = 0;
fault_transaction = "/* attachment observation */";
const char* read_prefix = "SELECT message_id,author,cipher_hash FROM attachment_keys";
const char* write_prefix = "INSERT INTO attachment_keys(channel,crypto_id,message_id,author";
for (int existing = 0; existing < 2; existing++) {
observation_fault(&f, &owner, manifest, existing, read_prefix, BEGIN); cases++;
for (int write = 0; write <= !existing; write++) {
int binds = write ? 7 : 2;
for (int i = 0; i < 3 + binds + write; i++) {
enum fault point = i == 0 ? PREPARE : i <= binds ? (enum fault)(BIND_1 + i - 1) :
i == binds + 1 ? STEP : i == binds + 2 ? FINALIZE : CHANGES;
observation_fault(&f, &owner, manifest, existing, write ? write_prefix : read_prefix, point); cases++;
}
}
observation_fault(&f, &owner, manifest, existing, read_prefix, COMMIT); cases++;
}
erase_index(&f); unsigned vetoes = 0; sqlite3_commit_hook(f.inst->topo_sqlite_db, veto_target, &vetoes);
assert(observe(&f, &owner, manifest) == -1 && vetoes == 1); struct local_snapshot empty; memset(&empty, 0, sizeof(empty));
local_equal(&f, &empty); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); assert(!observe(&f, &owner, manifest));
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_index BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(ABORT,'index'); END",
"CREATE TEMP TRIGGER reject_index BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(IGNORE); END"
};
for (unsigned i = 0; i < 2; i++) {
erase_index(&f); sql(f.inst->topo_sqlite_db, triggers[i]); assert(observe(&f, &owner, manifest) == -1); local_equal(&f, &empty);
sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_index"); assert(!observe(&f, &owner, manifest));
}
restore_private_metadata(&f, &owner); local_equal(&f, &owner);
uint8_t different[32]; memcpy(different, owner.message, 32); different[0] ^= 1;
assert(chat_attachment_observe(f.inst, f.channel, different, owner.author, manifest, strlen(manifest)) == -2); local_equal(&f, &owner);
sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'");
assert(observe(&f, &owner, manifest) == -1 && !sqlite3_get_autocommit(f.inst->topo_sqlite_db)); local_equal(&f, &owner);
sql(f.inst->topo_sqlite_db, "ROLLBACK");
const char* ordinary = "{\"ct\":\"text\",\"d\":\"x\"}";
fault = BEGIN; assert(!chat_attachment_observe(f.inst, f.channel, owner.message, owner.author, ordinary, strlen(ordinary)));
assert(fault == BEGIN); fault = NONE;
fault_prefix = fault_transaction = NULL; u_free(manifest); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS observation:%u SQL faults/retries, new/duplicate records, key/metadata preserved, veto/ABORT/IGNORE/collision/foreign transaction", cases);
}
static void set_storage(struct fixture* f, int enabled) {
struct chat_member_tags* tags = chat_member_tags_load(f->inst, f->channel, f->inst->node_id); assert(tags);
assert(!chat_member_tags_set(tags, "storage", enabled ? "yes" : "no") && !chat_member_tags_commit(f->inst, tags)); chat_member_tags_free(tags);
}
static void flag_fault(struct fixture* f, const struct local_snapshot* owner, int downloaded, int variant,
const char* prefix, enum fault point) {
char query[256];
snprintf(query, sizeof(query), "UPDATE attachment_keys SET downloaded=0,wanted=0,ready=1,decrypting=0,error=1 WHERE channel='%s'", f->channel);
sql(f->inst->topo_sqlite_db, query); f->local_mode = 1; f->key_events = 0;
struct local_snapshot before = local_read(f); local_expected = before;
fault_prefix = prefix; fault = point;
int rc = downloaded ? chat_attachment_downloaded(f->inst, f->channel, owner->message, 1) : chat_attachment_want(f->inst, f->channel, owner->message);
assert(rc == -1 && fault == NONE && !f->key_events && sqlite3_get_autocommit(f->inst->topo_sqlite_db)); local_equal(f, &before);
local_expected.error = 0;
if (downloaded) { local_expected.downloaded = 1; local_expected.wanted = !variant; }
else { local_expected.wanted = 1; local_expected.ready = !variant; }
rc = downloaded ? chat_attachment_downloaded(f->inst, f->channel, owner->message, 1) : chat_attachment_want(f->inst, f->channel, owner->message);
assert(!rc && f->key_events == (unsigned)downloaded); local_equal(f, &local_expected);
rc = downloaded ? chat_attachment_downloaded(f->inst, f->channel, owner->message, 1) : chat_attachment_want(f->inst, f->channel, owner->message);
assert(!rc && f->key_events == (unsigned)downloaded); local_equal(f, &local_expected);
}
static void test_local_flags(void) {
struct fixture f; setup(&f); source(&f); publish(&f); struct local_snapshot owner = local_read(&f); unsigned cases = 0;
for (int downloaded = 0; downloaded < 2; downloaded++) for (int variant = 0; variant < 2; variant++) {
if (downloaded) set_storage(&f, variant);
else if (variant) { char path[512]; plaintext_path(&f, path); assert(!remove(path)); }
fault_transaction = downloaded ? "/* attachment downloaded */" : "/* attachment wanted */";
const char* read_prefix = downloaded ? "SELECT downloaded,wanted,error,NOT EXISTS(" : "SELECT ready,decrypting,plain_path FROM attachment_keys";
const char* write_prefix = downloaded ? "UPDATE attachment_keys SET downloaded=1,wanted=" : "UPDATE attachment_keys SET wanted=1,error=0,ready=";
flag_fault(&f, &owner, downloaded, variant, read_prefix, BEGIN); cases++;
for (int write = 0; write < 2; write++) {
int binds = (downloaded ? 3 : 2) + write;
for (int i = 0; i < 3 + binds + write; i++) {
enum fault point = i == 0 ? PREPARE : i <= binds ? (enum fault)(BIND_1 + i - 1) :
i == binds + 1 ? STEP : i == binds + 2 ? FINALIZE : CHANGES;
flag_fault(&f, &owner, downloaded, variant, write ? write_prefix : read_prefix, point); cases++;
}
}
flag_fault(&f, &owner, downloaded, variant, read_prefix, COMMIT); cases++;
}
/* Наличие старого cache не сбрасывает ошибку; новая проверенная передача позволяет повторить worker. */
char query[256]; snprintf(query, sizeof(query), "UPDATE attachment_keys SET downloaded=1,wanted=0,ready=0,error=1 WHERE channel='%s'", f.channel);
sql(f.inst->topo_sqlite_db, query); f.key_events = 0; local_expected = local_read(&f);
fault = NONE; fault_prefix = NULL; fault_transaction = "/* attachment downloaded */";
assert(!chat_attachment_downloaded(f.inst, f.channel, owner.message, 0) && !f.key_events); local_equal(&f, &local_expected);
local_expected.error = 0; assert(!chat_attachment_downloaded(f.inst, f.channel, owner.message, 1) && f.key_events == 1);
local_equal(&f, &local_expected);
sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'");
assert(chat_attachment_want(f.inst, f.channel, owner.message) == -1 && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
assert(chat_attachment_downloaded(f.inst, f.channel, owner.message, 1) == -1 && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
local_equal(&f, &local_expected); sql(f.inst->topo_sqlite_db, "ROLLBACK");
fault_prefix = fault_transaction = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS local flags:%u SQL faults/retries, cache present/missing, signed storage/ordinary policy, reader in events, cached error retained until fresh download", cases);
}
static void drive_backfill(struct fixture* f, int full) {
void* handle = full ? CC(f->inst)->media_backfill_timer : CC(f->inst)->media_channel_backfill_timer;
timeout_callback_t callback = full ? full_backfill_tick : channel_backfill_tick;
assert(handle && callback); uasync_cancel_timeout(f->ua, handle); callback(f->inst);
}
static void test_index_recovery(void) {
struct fixture f; setup(&f); source(&f); publish(&f); set_storage(&f, 1);
struct local_snapshot owner; char* manifest = manifest_copy(&f, &owner); erase_index(&f);
unsigned received = f.received_events;
fault_transaction = "/* attachment observation */"; fault_prefix = "INSERT INTO attachment_keys(channel,crypto_id,message_id,author"; fault = BIND_5;
struct DB_SYNC_INSTANCE* si = si_find(f.inst, f.channel); assert(si);
on_msg_inserted(si, owner.message, 1, manifest, strlen(manifest), owner.author, 1, 0, f.inst);
assert(fault == NONE && f.received_events == received + 1 && CC(f.inst)->media_channel_backfill_pending == 1);
struct local_snapshot empty; memset(&empty, 0, sizeof(empty)); local_equal(&f, &empty);
fault = BIND_3; drive_backfill(&f, 0);
assert(fault == NONE && CC(f.inst)->media_channel_backfill_pending == 1 && CC(f.inst)->media_channel_backfill_timer);
local_equal(&f, &empty); drive_backfill(&f, 0);
struct local_snapshot expected = derived_snapshot(&owner); expected.downloaded = 1; local_equal(&f, &expected);
assert(!CC(f.inst)->media_channel_backfill_pending && !CC(f.inst)->media_channel_backfill_timer && f.received_events == received + 1);
/* Startup также восстанавливает производную запись без сети и без повторного MSG_RECEIVED. */
erase_index(&f); fault_prefix = fault_transaction = NULL; f.local_mode = 0;
chat_service_stop(f.inst); assert(!chat_service_start(f.inst)); chat_event_set_handler(f.inst, event); local_equal(&f, &expected);
assert(f.received_events == received + 1); u_free(manifest); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS index recovery: failed observation still notifies committed message; detached backfill retries another failure; startup restores cached record without network");
}
static void test_backfill_capacity(void) {
struct fixture f; setup(&f); block_index = 1; unsigned channels = CHAT_MEDIA_BACKFILL_CHANNELS + 1;
for (unsigned i = 0; i < channels; i++) {
if (i) {
char name[64], query[256]; snprintf(name, sizeof(name), "backfill capacity %u", i);
chat_core_create_channel_auto(f.inst, name);
snprintf(query, sizeof(query), "SELECT channel_id FROM channels WHERE name='%s'", name); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f.reader, query, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
snprintf(f.channel, sizeof(f.channel), "%s", sqlite3_column_text(st, 0)); assert(sqlite3_finalize(st) == SQLITE_OK);
}
source(&f); publish(&f); erase_index(&f); f.rows--;
}
chat_media_autodownload_backfill(f.inst);
assert(CC(f.inst)->media_channel_backfill_pending == CHAT_MEDIA_BACKFILL_CHANNELS);
assert(CC(f.inst)->media_channel_backfill_timer && CC(f.inst)->media_backfill_timer);
unsigned received = f.received_events, changed = f.attachment_events;
block_index = 0; drive_backfill(&f, 0); drive_backfill(&f, 1);
assert(rows(&f) == f.rows + channels && !CC(f.inst)->media_channel_backfill_pending);
assert(!CC(f.inst)->media_backfill_timer && !CC(f.inst)->media_channel_backfill_timer);
assert(f.received_events == received && f.attachment_events == changed + channels); cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS backfill capacity:17 channels, bounded16 queue, full-scan fallback restores all without duplicate history/events");
}
/* Только порт запуска загрузки; проверяем полный разбор подписанного manifest без сетевых/дисковых гигабайтов. */
extern int __real_media_download_start(struct UTUN_INSTANCE*, uint64_t, const struct media_index_result*, const char*, const char*,
uint64_t, void (*)(void*, int), void*, void (*)(void*, int, int), void*);
int __wrap_media_download_start(struct UTUN_INSTANCE* inst, uint64_t group, const struct media_index_result* result,
const char* path, const char* base, uint64_t author, void (*done)(void*, int), void* arg,
void (*progress)(void*, int, int), void* progress_arg) {
if (!expected_download) return __real_media_download_start(inst, group, result, path, base, author, done, arg, progress, progress_arg);
assert(inst == active->inst && group == strtoull(active->channel, NULL, 10) && author == inst->node_id);
assert(done && arg && progress && progress_arg && !strncmp(path, active->directory, strlen(active->directory)) && base);
assert(result->file_size == expected_download->file_size && result->block_size == expected_download->block_size &&
result->num_blocks == expected_download->num_blocks && !memcmp(result->media_id, expected_download->media_id, 16) &&
!memcmp(result->content_hash, expected_download->content_hash, 32));
assert(!memcmp(result->block_ids, expected_download->block_ids, (size_t)result->num_blocks * 16));
assert(!memcmp(result->block_sigs, expected_download->block_sigs, (size_t)result->num_blocks * 64));
download_starts++; return -1; // Refusal без done; вызывающий обязан освободить собственный контекст.
}
static void test_long_manual_manifest(void) {
struct fixture f; setup(&f); enum { BLOCKS = 23 };
uint8_t content[BLOCKS], ids[BLOCKS * 16], signatures[BLOCKS * 64];
struct media_index_result result = {.file_size = BLOCKS, .block_size = 1, .num_blocks = BLOCKS, .block_ids = ids, .block_sigs = signatures};
result.media_id[0] = 99; memset(ids, 0, sizeof(ids));
for (unsigned i = 0; i < BLOCKS; i++) {
content[i] = (uint8_t)('a' + i); ids[i * 16] = (uint8_t)(i + 1);
assert(!ma_sign_block(f.inst->my_ed25519_privkey, content + i, 1, signatures + i * 64));
}
assert(SHA256(content, sizeof(content), result.content_hash));
char body[6000] = "*"; const char* filename = "long.bin";
size_t offset = 1 + b64_encode((const uint8_t*)filename, strlen(filename), body + 1, sizeof(body) - 1);
offset += (size_t)snprintf(body + offset, sizeof(body) - offset, "|%d|1|%d|", BLOCKS, BLOCKS);
for (unsigned i = 0; i < 16; i++) offset += (size_t)snprintf(body + offset, sizeof(body) - offset, "%02x", result.media_id[i]);
body[offset++] = '|';
for (unsigned i = 0; i < 32; i++) offset += (size_t)snprintf(body + offset, sizeof(body) - offset, "%02x", result.content_hash[i]);
body[offset++] = '|';
for (unsigned block = 0; block < BLOCKS; block++) {
if (block) body[offset++] = ',';
for (unsigned i = 0; i < 16; i++) offset += (size_t)snprintf(body + offset, sizeof(body) - offset, "%02x", ids[block * 16 + i]);
body[offset++] = ',';
for (unsigned i = 0; i < 64; i++) offset += (size_t)snprintf(body + offset, sizeof(body) - offset, "%02x", signatures[block * 64 + i]);
assert(offset < sizeof(body));
}
body[offset] = 0; assert(offset > 3000);
char json[6500]; int length = snprintf(json, sizeof(json), "{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"application/octet-stream\",\"d\":\"%s\"}",
(unsigned long long)f.inst->node_id, f.channel, body);
assert(length > 0 && length <= DB_SYNC_MESSAGE_MAX && (size_t)length < sizeof(json));
struct DB_SYNC_INSTANCE* si = si_find(f.inst, f.channel); assert(si);
uint64_t timestamp = db_sync_next_timestamp(si); struct db_message_revision revision = {.ver = 1}; revision.nonce[0] = 33;
uint8_t signature[64], message[32];
assert(!db_sync_sign_message(strtoull(f.channel, NULL, 10), timestamp, f.inst->node_id, &revision, json, (size_t)length,
f.inst->my_ed25519_privkey, signature));
assert(!chat_message_id(strtoull(f.channel, NULL, 10), timestamp, f.inst->node_id, revision.nonce, message));
assert(!db_sync_insert_signed(si, json, (size_t)length, signature, 64, timestamp, NULL, &revision));
char query[160]; snprintf(query, sizeof(query), "SELECT id FROM msg_%s WHERE message_id=?", f.channel); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(f.reader, query, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_blob(st, 1, message, 32, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int64_t id = sqlite3_column_int64(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK);
expected_download = &result; download_starts = 0; chat_core_attachment_download(f.inst, f.channel, id);
assert(download_starts == 1); expected_download = NULL; cleanup(&f);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS manual manifest:%zu bytes, all23 signed blocks near protocol limit reach download port; rejected start frees context", offset);
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_INFO);
utun_instance_set_tun_init_enabled(0); size_t baseline = u_get_allocated_count();
test_sql_faults(); test_sql_controls(); test_inline_failure(); test_rollback_failure();
assert(fault == NONE && !fail_rollback && !fail_submit && rollback_fault == 1);
for (unsigned i = BEGIN; i < FAULT_COUNT; i++) assert(hits[i] == (i == COMMIT ? 2 : 1));
test_decryption_claim(); test_decryption_completion(); test_completion_controls(); test_completion_stop();
test_worker_error_completion(); test_completion_limit();
test_channel_delete_rollback();
test_attachment_binding(); test_attachment_identity();
test_recovery_transaction();
test_observation(); test_local_flags(); test_index_recovery();
test_backfill_capacity();
test_long_manual_manifest();
assert(u_get_allocated_count() == baseline);
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: independent committed key/metadata before worker, allocations restored");
return 0;
+442
View File
@@ -0,0 +1,442 @@
/* Создание канала: настоящие ключи/подписи/SQLite; отказ не публикует незавершённый канал. */
#define _GNU_SOURCE
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/rand.h>
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_core_priv.h"
#include "../src/chat/chat_event.h"
#include "../src/chat/chat_headless_control.h"
#include "../src/chat/member_sync.h"
#include "../src/routing_layer/topo_node_sqlite.h"
#include "../src/routing_layer/topo_group.h"
#include "../lib/platform_compat.h"
#include "../lib/socket_compat.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
static unsigned fail_sign_number, channel_events;
static struct UTUN_INSTANCE* active;
static sqlite3* reader;
static int fail_profile_binding;
enum sql_fault { Q_NONE, Q_PREPARE, Q_BIND, Q_STEP, Q_CHANGES, Q_FINALIZE, Q_EXEC, Q_ALLOCATE };
static enum sql_fault sql_fault;
static const char *sql_prefix, *sql_suffix;
static int sql_bind, allocations_until_failure, failed_rollback, last_target;
enum crypto_fault { K_NONE, K_CONTEXT, K_INITIALIZE, K_GENERATE, K_PUBLIC, K_PUBLIC_SIZE, K_PRIVATE, K_PRIVATE_SIZE,
K_RANDOM, K_DESCRIPTION, K_INVITATION, K_PROFILE };
static enum crypto_fault crypto_fault;
static int crypto_type;
static EVP_PKEY_CTX* crypto_context;
static EVP_PKEY* crypto_key;
static int take_crypto(enum crypto_fault point) {
if (crypto_fault != point) return 0;
crypto_fault = K_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: crypto fault type=%d stage=%d", crypto_type, point); return 1;
}
extern EVP_PKEY_CTX* __real_EVP_PKEY_CTX_new_id(int, ENGINE*);
EVP_PKEY_CTX* __wrap_EVP_PKEY_CTX_new_id(int type, ENGINE* engine) {
if (type == crypto_type && take_crypto(K_CONTEXT)) return NULL;
EVP_PKEY_CTX* ctx = __real_EVP_PKEY_CTX_new_id(type, engine);
if (type == crypto_type && crypto_fault != K_NONE) crypto_context = ctx;
return ctx;
}
extern int __real_EVP_PKEY_keygen_init(EVP_PKEY_CTX*);
int __wrap_EVP_PKEY_keygen_init(EVP_PKEY_CTX* ctx) {
if (ctx == crypto_context && take_crypto(K_INITIALIZE)) return 0;
return __real_EVP_PKEY_keygen_init(ctx);
}
extern int __real_EVP_PKEY_keygen(EVP_PKEY_CTX*, EVP_PKEY**);
int __wrap_EVP_PKEY_keygen(EVP_PKEY_CTX* ctx, EVP_PKEY** key) {
if (ctx == crypto_context && take_crypto(K_GENERATE)) return 0;
int rc = __real_EVP_PKEY_keygen(ctx, key);
if (ctx == crypto_context && rc == 1 && crypto_fault != K_NONE) crypto_key = *key;
return rc;
}
extern int __real_EVP_PKEY_get_raw_public_key(const EVP_PKEY*, unsigned char*, size_t*);
int __wrap_EVP_PKEY_get_raw_public_key(const EVP_PKEY* key, unsigned char* data, size_t* length) {
if (key == crypto_key && take_crypto(K_PUBLIC)) return 0;
int rc = __real_EVP_PKEY_get_raw_public_key(key, data, length);
if (key == crypto_key && take_crypto(K_PUBLIC_SIZE)) *length = 31;
return rc;
}
extern int __real_EVP_PKEY_get_raw_private_key(const EVP_PKEY*, unsigned char*, size_t*);
int __wrap_EVP_PKEY_get_raw_private_key(const EVP_PKEY* key, unsigned char* data, size_t* length) {
if (key == crypto_key && take_crypto(K_PRIVATE)) return 0;
int rc = __real_EVP_PKEY_get_raw_private_key(key, data, length);
if (key == crypto_key && take_crypto(K_PRIVATE_SIZE)) *length = 31;
return rc;
}
extern int __real_RAND_bytes(unsigned char*, int);
int __wrap_RAND_bytes(unsigned char* data, int length) {
if (length == 8 && take_crypto(K_RANDOM)) return 0;
return __real_RAND_bytes(data, length);
}
static int sql_target(const char* sql) {
return sql_prefix && !strncmp(sql, sql_prefix, strlen(sql_prefix)) && (!sql_suffix || strstr(sql, sql_suffix));
}
static int take_sql(enum sql_fault point, const char* sql, int index) {
if (sql_fault != point || (sql && !sql_target(sql)) || (point == Q_BIND && index != sql_bind)) return 0;
sql_fault = Q_NONE;
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: SQL fault stage=%d bind=%d", point, index); return 1;
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char**, char**), void* arg, char** error) {
if (failed_rollback && !strcmp(sql, "ROLLBACK")) {
failed_rollback = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: rollback failed"); return SQLITE_IOERR;
}
if (take_sql(Q_EXEC, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_exec(db, sql, cb, arg, error);
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int length, sqlite3_stmt** st, const char** tail) {
if (take_sql(Q_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, length, st, tail);
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* value, int length, void (*destroy)(void*)) {
const char* prefix = "INSERT INTO \"peers_";
if (fail_profile_binding && index == 6 && !strncmp(sqlite3_sql(st), prefix, strlen(prefix))) {
fail_profile_binding = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: injected root profile signature bind failure");
return SQLITE_NOMEM;
}
if (take_sql(Q_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, value, length, destroy);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* value, int length, void (*destroy)(void*)) {
if (take_sql(Q_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, value, length, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take_sql(Q_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_null(sqlite3_stmt*, int);
int __wrap_sqlite3_bind_null(sqlite3_stmt* st, int index) {
if (take_sql(Q_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_null(st, index);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
const char* sql = sqlite3_sql(st); last_target = sql_target(sql);
if (take_sql(Q_STEP, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_target && take_sql(Q_CHANGES, NULL, 0)) return 0;
return __real_sqlite3_changes(db);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int fail = st && take_sql(Q_FINALIZE, sqlite3_sql(st), 0);
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
extern void* __real_u_malloc_impl(uint32_t, const char*);
void* __wrap_u_malloc_impl(uint32_t size, const char* location) {
if (sql_fault == Q_ALLOCATE && strstr(location, "topo_node_sqlite.c") && !--allocations_until_failure) {
sql_fault = Q_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: schema allocation failed bytes=%u", size); return NULL;
}
return __real_u_malloc_impl(size, location);
}
/* Проверяем логические bytes описаний,всей схемы и Merkle через независимое соединение. */
static void snapshot(sqlite3* db, uint8_t out[32]) {
const char* queries[] = {
"SELECT channel_id,name,owner_node_id,x25519_pubkey,x25519_privkey,ed25519_pubkey,ed25519_privkey,signature FROM channels ORDER BY channel_id",
"SELECT namespace,level,prefix64,hash FROM merkle_tree_hash ORDER BY namespace,level,prefix64",
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name",
"SELECT type,name,tbl_name,sql FROM sqlite_temp_master ORDER BY type,name"
};
EVP_MD_CTX* hash = EVP_MD_CTX_new(); assert(hash && EVP_DigestInit_ex(hash, EVP_sha256(), NULL) == 1);
for (unsigned query = 0; query < sizeof(queries) / sizeof(queries[0]); query++) {
assert(EVP_DigestUpdate(hash, &query, sizeof(query)) == 1); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, queries[query], -1, &st, NULL) == SQLITE_OK); int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
for (int column = 0; column < sqlite3_column_count(st); column++) {
int type = sqlite3_column_type(st, column), bytes = sqlite3_column_bytes(st, column);
const void* value = sqlite3_column_blob(st, column); assert(!bytes || value);
assert(EVP_DigestUpdate(hash, &type, sizeof(type)) == 1 && EVP_DigestUpdate(hash, &bytes, sizeof(bytes)) == 1);
if (bytes) assert(EVP_DigestUpdate(hash, value, (size_t)bytes) == 1);
}
}
assert(rc == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
unsigned length; assert(EVP_DigestFinal_ex(hash, out, &length) == 1 && length == 32); EVP_MD_CTX_free(hash);
}
extern int __real_EVP_DigestSign(EVP_MD_CTX*, unsigned char*, size_t*, const unsigned char*, size_t);
int __wrap_EVP_DigestSign(EVP_MD_CTX* ctx, unsigned char* signature, size_t* length, const unsigned char* data, size_t size) {
if (data && fail_sign_number && active && size == 112 && !memcmp(data + 72, active->my_keys.public_key, 32) && !--fail_sign_number) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "channel test: injected signature failure bytes=%zu", size); return 0;
}
if (data && active && size == MEMBER_SIGN_MSG_SIZE && !memcmp(data, active->my_keys.public_key, 32) &&
!memcmp(data + 32, MEMBER_SIGN_SUFFIX, MEMBER_SIGN_SUFFIX_LEN) && take_crypto(K_INVITATION)) return 0;
if (data && size >= 80 && !memcmp(data + 72, "{\"name\":", 8) && take_crypto(K_PROFILE)) return 0;
const uint8_t* zero = data ? memchr(data, 0, size) : NULL;
if (zero && zero - data < 20 && (size_t)(zero - data + 1) + sizeof("crypto refusal") <= size &&
!memcmp(zero + 1, "crypto refusal", sizeof("crypto refusal")) && take_crypto(K_DESCRIPTION)) return 0;
return __real_EVP_DigestSign(ctx, signature, length, data, size);
}
static int channels(void) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(reader, "SELECT count(*) FROM channels", -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int count = sqlite3_column_int(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK); return count;
}
/* Обработчик видит канал и проверенного участника уже после commit. */
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int size) {
if (type != CHAT_EVT_CHANNEL_UPDATED) return;
assert(sqlite3_get_autocommit(inst->topo_sqlite_db) && size == data[0] + 1 && data[0] < 20);
char channel[20]; memcpy(channel, data + 1, data[0]); channel[data[0]] = 0;
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(reader, "SELECT owner_node_id,length(x25519_pubkey),length(x25519_privkey),"
"length(ed25519_pubkey),length(ed25519_privkey),length(signature) FROM channels WHERE channel_id=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert((uint64_t)sqlite3_column_int64(st, 0) == inst->node_id);
for (int i = 1; i <= 5; i++) assert(sqlite3_column_int(st, i) == (i == 5 ? 64 : 32));
assert(sqlite3_finalize(st) == SQLITE_OK);
uint8_t key[32]; assert(member_sync_author_key(inst, channel, inst->node_id, key) == MS_AUTHOR_ACTIVE);
assert(!memcmp(key, inst->my_ed25519_pubkey, 32));
char query[256]; snprintf(query, sizeof(query), "SELECT join_sig,update_sig,update_ts,userinfo,signature,source FROM peers_%s WHERE node_id=?", channel);
assert(sqlite3_prepare_v2(reader, query, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_bytes(st, 0) == 64 && sqlite3_column_bytes(st, 1) == 64 && sqlite3_column_bytes(st, 4) == 64);
assert(sqlite3_column_int64(st, 2) > 0 && !sqlite3_column_int(st, 5));
uint8_t profile[MS_USERINFO_MAX + 73], signature[64];
int bytes = member_sync_build_update_msg(sqlite3_column_blob(st, 0), (uint64_t)sqlite3_column_int64(st, 2),
(const char*)sqlite3_column_text(st, 3), profile, sizeof(profile));
assert(bytes > 0); memcpy(signature, sqlite3_column_blob(st, 1), 64); assert(sqlite3_finalize(st) == SQLITE_OK);
assert(sc_ed25519_verify(inst->my_ed25519_pubkey, profile, (size_t)bytes, signature) == SC_OK);
uint8_t writer[32], committed[32]; snapshot(inst->topo_sqlite_db, writer); snapshot(reader, committed);
assert(!memcmp(writer, committed, 32)); channel_events++;
}
static void refused(struct UTUN_INSTANCE* inst, const char* name) {
uint8_t before[32], after[32]; snapshot(reader, before);
unsigned events = channel_events; int count = channels(), groups = queue_entry_count(inst->topo_groups->group_list);
uint64_t created = chat_core_create_channel_auto(inst, name);
if (created || sql_fault != Q_NONE) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC,
"channel test: expected refusal name=%s ID=%llu pending_fault=%d", name, (unsigned long long)created, sql_fault);
assert(!created && sql_fault == Q_NONE && crypto_fault == K_NONE && !fail_sign_number && sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && channels() == count && channel_events == events);
assert(queue_entry_count(inst->topo_groups->group_list) == groups);
}
static void retry(struct UTUN_INSTANCE* inst) {
unsigned events = channel_events; int count = channels();
assert(chat_core_create_channel_auto(inst, "successful SQL retry") && channels() == count + 1 && channel_events == events + 1);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void descriptor_regressions(void) {
uint8_t x[32] = {1}, ed[32] = {2}, out[CHAT_CHANNEL_DESCRIPTION_MAX], expected[CHAT_CHANNEL_DESCRIPTION_MAX];
const char* id = "9223372036854775807"; char name[257]; memset(name, 'n', 255); name[255] = 0;
uint64_t owner = UINT64_C(0x0123456789abcdef); size_t offset = 0;
memcpy(expected, id, 20); offset += 20; memcpy(expected + offset, name, 256); offset += 256;
memcpy(expected + offset, &owner, 8); offset += 8;
memcpy(expected + offset, x, 32); offset += 32; memcpy(expected + offset, ed, 32); offset += 32;
assert(offset == sizeof(out) && chat_channel_description_data(id, name, owner, x, ed, out, sizeof(out)) == (int)offset);
assert(!memcmp(out, expected, sizeof(out))); memset(out, 0xa5, sizeof(out)); memset(expected, 0xa5, sizeof(expected));
assert(chat_channel_description_data(id, name, owner, x, ed, out, sizeof(out) - 1) < 0 && !memcmp(out, expected, sizeof(out)));
name[255] = 'n'; name[256] = 0;
assert(chat_channel_description_data(id, name, owner, x, ed, out, sizeof(out)) < 0 && !memcmp(out, expected, sizeof(out)));
const char* invalid[] = {"", "0", "01", "-1", "1x", " 1", "9223372036854775808", "18446744073709551615", "12345678901234567890"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
assert(chat_channel_description_data(invalid[i], "", owner, x, ed, out, sizeof(out)) < 0);
assert(!memcmp(out, expected, sizeof(out)));
}
assert(chat_channel_description_data("1", "", owner, x, ed, out, 75) == 75);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel description: canonical ID range,255-byte name,capacity,unchanged error output,exact shared wire");
}
static void direct_refused(struct UTUN_INSTANCE* inst, const struct chat_channel_create* req) {
uint8_t before[32], after[32]; snapshot(reader, before);
unsigned events = channel_events; int groups = queue_entry_count(inst->topo_groups->group_list);
assert(chat_core_create_channel(inst, req) < 0 && sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == channel_events);
assert(groups == queue_entry_count(inst->topo_groups->group_list));
}
static void direct_regressions(struct UTUN_INSTANCE* inst) {
struct chat_channel_create original = {.inst = inst, .owner_node_id = inst->node_id}, request;
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(reader, "SELECT channel_id,name,x25519_pubkey,x25519_privkey,ed25519_pubkey,ed25519_privkey,signature "
"FROM channels ORDER BY channel_id LIMIT 1", -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
snprintf(original.channel_id, sizeof(original.channel_id), "%s", sqlite3_column_text(st, 0));
snprintf(original.name, sizeof(original.name), "%s", sqlite3_column_text(st, 1));
void* keys[] = {original.x25519_pubkey, original.x25519_privkey, original.ed25519_pubkey, original.ed25519_privkey, original.signature};
for (unsigned i = 0; i < 5; i++) {
int bytes = i == 4 ? 64 : 32; assert(sqlite3_column_bytes(st, (int)i + 2) == bytes);
memcpy(keys[i], sqlite3_column_blob(st, (int)i + 2), (size_t)bytes);
}
assert(sqlite3_finalize(st) == SQLITE_OK); direct_refused(inst, &original); direct_refused(inst, NULL);
request = original; request.owner_node_id ^= 1; direct_refused(inst, &request);
request = original; request.x25519_privkey[8] ^= 1; direct_refused(inst, &request);
request = original; request.ed25519_privkey[8] ^= 1; direct_refused(inst, &request);
request = original; request.signature[0] ^= 1; direct_refused(inst, &request);
request = original; memset(request.name, 'n', sizeof(request.name)); direct_refused(inst, &request);
request = original; memset(request.channel_id, '1', sizeof(request.channel_id)); direct_refused(inst, &request);
const char* invalid[] = {"0", "01", "9223372036854775808", "1x"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
request = original; snprintf(request.channel_id, sizeof(request.channel_id), "%s", invalid[i]); direct_refused(inst, &request);
}
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN", NULL, NULL, NULL) == SQLITE_OK);
assert(chat_core_create_channel(inst, &original) < 0 && !sqlite3_get_autocommit(inst->topo_sqlite_db));
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
char name[129]; memset(name, 'n', 128); name[128] = 0; refused(inst, name);
request = original; snprintf(request.channel_id, sizeof(request.channel_id), "9223372036854775807");
memset(request.name, 'n', 127); request.name[127] = 0; uint8_t description[CHAT_CHANNEL_DESCRIPTION_MAX];
int bytes = chat_channel_description_data(request.channel_id, request.name, request.owner_node_id,
request.x25519_pubkey, request.ed25519_pubkey, description, sizeof(description));
assert(bytes > 0 && sc_ed25519_sign(request.ed25519_privkey, description, (size_t)bytes, request.signature) == SC_OK);
int count = channels(); unsigned events = channel_events;
assert(!chat_core_create_channel(inst, &request) && channels() == count + 1 && channel_events == events + 1);
direct_refused(inst, &request); OPENSSL_cleanse(&original, sizeof(original)); OPENSSL_cleanse(&request, sizeof(request));
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel API: malformed/occupied IDs,owner/key/signature/name checks,foreign transaction;127-byte name/max-ID commit");
}
static void rpc_request(struct UASYNC* ua, socket_t fd, const char* request, int id, int success) {
assert(send(fd, request, (int)strlen(request), 0) == (ssize_t)strlen(request));
char response[1024] = {0}; size_t length = 0;
for (int attempt = 0; attempt < 2500 && !strchr(response, '\n'); attempt++) {
uasync_poll(ua, 20); ssize_t received = recv(fd, response + length, sizeof(response) - length - 1, 0);
if (received < 0) { int error = socket_get_error(); assert(error == ERR_AGAIN || error == ERR_WOULDBLOCK || error == ERR_INTR); continue; }
assert(received > 0 && length + (size_t)received < sizeof(response) - 1);
length += (size_t)received; response[length] = 0;
}
assert(strchr(response, '\n')); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(reader, "SELECT json_extract(?1,'$.id'),json_extract(?1,'$.ok'),"
"json_extract(?1,'$.data.created'),json_extract(?1,'$.data.channel_id'),json_extract(?1,'$.error')", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, response, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_int(st, 0) == id && sqlite3_column_int(st, 1) == success);
if (success) {
assert(sqlite3_column_int(st, 2) == 1 && sqlite3_column_type(st, 3) == SQLITE_TEXT);
assert(strtoull((const char*)sqlite3_column_text(st, 3), NULL, 10) > 0);
} else assert(sqlite3_column_type(st, 4) == SQLITE_TEXT);
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void headless_regressions(struct UTUN_INSTANCE* inst, struct UASYNC* ua) {
assert(!chat_headless_control_init(ua, inst, "127.0.0.1", 0));
socket_t fd = socket(AF_INET, SOCK_STREAM, 0); assert(fd != SOCKET_INVALID);
struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons(chat_headless_control_get_port(inst))};
assert(inet_pton(AF_INET, "127.0.0.1", &address.sin_addr) == 1 && !connect(fd, (struct sockaddr*)&address, sizeof(address)));
assert(!socket_set_nonblocking(fd)); int count = channels();
sql_prefix = "BEGIN IMMEDIATE /* channel creation */"; sql_suffix = NULL; sql_fault = Q_EXEC;
rpc_request(ua, fd, "{\"id\":17,\"cmd\":\"create_channel\",\"name\":\"RPC failed\"}\n", 17, 0);
assert(sql_fault == Q_NONE && channels() == count);
rpc_request(ua, fd, "{\"id\":18,\"cmd\":\"create_channel\",\"name\":\"RPC created\"}\n", 18, 1);
assert(channels() == count + 1); socket_close_wrapper(fd); chat_headless_control_destroy(inst); chat_event_set_handler(inst, event);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel TCP API: failed commit path returns ok:false;retry returns committed channel_id");
}
static void crypto_regressions(struct UTUN_INSTANCE* inst) {
const int types[] = {EVP_PKEY_X25519, EVP_PKEY_ED25519}; unsigned cases = 0;
for (unsigned type = 0; type < 2; type++) {
for (enum crypto_fault point = K_CONTEXT; point <= K_PRIVATE_SIZE; point++) {
crypto_type = types[type]; crypto_context = NULL; crypto_key = NULL; crypto_fault = point;
refused(inst, "crypto refusal"); retry(inst); cases++;
}
}
crypto_type = 0; crypto_context = NULL; crypto_key = NULL;
for (enum crypto_fault point = K_RANDOM; point <= K_PROFILE; point++) {
crypto_fault = point; refused(inst, "crypto refusal"); retry(inst); cases++;
}
fail_sign_number = 1; refused(inst, "crypto refusal"); retry(inst); cases++;
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel crypto:%u faults/retries; key generation/extraction sizes,RNG,description,JOIN,invitation,profile", cases);
}
static void sql_regressions(struct UTUN_INSTANCE* inst) {
const char* queries[] = {"SELECT 1 FROM channels WHERE channel_id=?", "INSERT INTO channels(channel_id, name, owner_node_id,",
"INSERT INTO \"peers_"};
unsigned cases = 0;
for (unsigned query = 0; query < 3; query++) {
sql_prefix = queries[query]; sql_suffix = NULL;
for (enum sql_fault point = Q_PREPARE; point <= Q_FINALIZE; point++) {
if (point == Q_CHANGES && !query) continue;
int bindings = point == Q_BIND ? (query == 0 ? 1 : query == 1 ? 8 : 11) : 1;
for (int bind = 1; bind <= bindings; bind++) {
sql_fault = point; sql_bind = bind; refused(inst, "SQL statement refusal"); retry(inst); cases++;
}
}
}
const char* exec[] = {"BEGIN IMMEDIATE /* channel creation */", "SAVEPOINT channel_put", "CREATE TABLE IF NOT EXISTS \"peers_",
"CREATE INDEX IF NOT EXISTS \"idx_peers_", "CREATE INDEX IF NOT EXISTS \"idx_peers_",
"CREATE TABLE IF NOT EXISTS feedback_", "RELEASE channel_put", "COMMIT /* channel creation */"};
for (unsigned i = 0; i < sizeof(exec) / sizeof(exec[0]); i++) {
sql_prefix = exec[i]; sql_suffix = i == 3 ? "_type_rtt\"" : i == 4 ? "_storage\"" : NULL;
sql_fault = Q_EXEC; refused(inst, "SQL transaction/schema refusal"); retry(inst); cases++;
}
sql_prefix = sql_suffix = NULL;
for (int allocation = 1; allocation <= 3; allocation++) {
allocations_until_failure = allocation; sql_fault = Q_ALLOCATE;
refused(inst, "SQL schema allocation refusal"); retry(inst); cases++;
}
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_create BEFORE INSERT ON channels BEGIN SELECT RAISE(ABORT,'channel create'); END",
"CREATE TEMP TRIGGER reject_create BEFORE INSERT ON channels BEGIN SELECT RAISE(IGNORE); END"
};
for (unsigned i = 0; i < 2; i++) {
assert(sqlite3_exec(inst->topo_sqlite_db, triggers[i], NULL, NULL, NULL) == SQLITE_OK);
refused(inst, "channel trigger refusal");
assert(sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER reject_create", NULL, NULL, NULL) == SQLITE_OK); retry(inst);
}
unsigned commits = 0; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits);
refused(inst, "channel commit veto"); assert(commits == 1); sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL); retry(inst);
uint8_t before[32], after[32]; snapshot(reader, before); unsigned events = channel_events;
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN;INSERT INTO ui_state(key,value) VALUES('foreign-create','1')", NULL, NULL, NULL) == SQLITE_OK);
assert(!chat_core_create_channel_auto(inst, "foreign transaction") && !sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == channel_events);
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); retry(inst);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel SQL:%u faults/retries,ABORT/IGNORE/veto,foreign transaction; exact committed descriptions/schema/roots and runtime count preserved", cases);
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_INFO);
size_t baseline = u_get_allocated_count();
utun_instance_set_tun_init_enabled(0);
char directory[] = "/tmp/utun-channel-create-XXXXXX"; assert(mkdtemp(directory));
char config[512], database[256];
snprintf(config, sizeof(config), "[global]\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", directory);
struct UASYNC* ua = uasync_create(); assert(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
snprintf(database, sizeof(database), "%s/chats.db", directory);
assert(sqlite3_open_v2(database, &reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
active = inst; chat_event_set_handler(inst, event); assert(!channels());
fail_sign_number = 1; chat_core_create_channel_auto(inst, "root signature failure");
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "channel test: after signature fault channels=%d events=%u", channels(), channel_events);
assert(!fail_sign_number && !channels() && !channel_events);
chat_core_create_channel_auto(inst, "successful retry"); assert(channels() == 1 && channel_events == 1);
fail_profile_binding = 1;
uint64_t created = chat_core_create_channel_auto(inst, "root profile SQL failure");
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "channel test: profile bind failure ID=%llu channels=%d events=%u",
(unsigned long long)created, channels(), channel_events);
assert(!fail_profile_binding && !created && channels() == 1 && channel_events == 1);
descriptor_regressions();
direct_regressions(inst);
crypto_regressions(inst);
sql_regressions(inst);
headless_regressions(inst, ua);
int committed = channels();
uint8_t before[32], after[32]; snapshot(reader, before); unsigned events = channel_events;
sql_prefix = "COMMIT /* channel creation */"; sql_suffix = NULL; sql_fault = Q_EXEC; failed_rollback = 1; inst->running = 1;
assert(!chat_core_create_channel_auto(inst, "failed rollback") && sql_fault == Q_NONE && !failed_rollback);
assert(!inst->running && !sqlite3_get_autocommit(inst->topo_sqlite_db) && channel_events == events);
snapshot(reader, after); assert(!memcmp(before, after, 32));
utun_instance_destroy(inst); active = NULL; assert(channels() == committed && sqlite3_close(reader) == SQLITE_OK); reader = NULL;
uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS channel creation: failed root signature leaves no channel/event; retry publishes valid membership after commit");
return 0;
}
+193 -4
View File
@@ -5,6 +5,80 @@
static const uint8_t owner_private[32] = {9};
static int events;
enum sql_fault { F_NONE, F_BEGIN, F_PREPARE, F_BIND, F_STEP, F_CHANGES, F_FINALIZE, F_COMMIT };
enum sql_operation { F_GET, F_PUT, F_PAGE, F_HASH };
static enum sql_fault injected;
static enum sql_operation injected_operation;
static int injected_bind, failed_rollback, last_put;
static int fault_statement(const char* sql) {
if (injected_operation == F_PAGE || injected_operation == F_HASH) {
const char* prefix = "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket FROM feedback_1001 WHERE bucket=?";
return sql && !strncmp(sql, prefix, strlen(prefix)) &&
((strstr(sql, " AND (?=0 OR record_key>?)") != NULL) == (injected_operation == F_PAGE));
}
const char* prefix = injected_operation == F_GET ? "SELECT message_id,node_id,kind,ver,data,signature FROM feedback_1001 " :
"INSERT INTO feedback_1001(record_key,bucket,message_id,node_id,kind,ver,data,signature)";
return sql && !strncmp(sql, prefix, strlen(prefix));
}
static int take_fault(enum sql_fault point, const char* sql, int index) {
if (injected != point || (sql && !fault_statement(sql)) || (point == F_BIND && index != injected_bind)) return 0;
injected = F_NONE;
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback test: injected stage=%d operation=%d bind=%d", point, injected_operation, index); return 1;
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char**, char**), void* arg, char** error) {
if ((!strcmp(sql, "SAVEPOINT feedback_update") || strstr(sql, "BEGIN IMMEDIATE /* feedback")) && take_fault(F_BEGIN, NULL, 0))
return SQLITE_BUSY;
if ((!strcmp(sql, "RELEASE feedback_update") || !strncmp(sql, "COMMIT /* feedback", 18)) && take_fault(F_COMMIT, NULL, 0))
return SQLITE_BUSY;
if (failed_rollback && !strncmp(sql, "ROLLBACK", 8)) {
failed_rollback = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback test: injected rollback failure"); return SQLITE_IOERR;
}
return __real_sqlite3_exec(db, sql, cb, arg, error);
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int length, sqlite3_stmt** st, const char** tail) {
if (take_fault(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, length, st, tail);
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* bytes, int length, void (*destroy)(void*)) {
if (take_fault(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, bytes, length, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take_fault(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (take_fault(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
const char* sql = sqlite3_sql(st); const char* put = "INSERT INTO feedback_1001(";
last_put = !strncmp(sql, put, strlen(put));
if (take_fault(F_STEP, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_put && take_fault(F_CHANGES, NULL, 0)) return 0;
return __real_sqlite3_changes(db);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int fail = st && take_fault(F_FINALIZE, sqlite3_sql(st), 0);
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
/* Событие должно видеть опубликованную транзакцию. */
static void on_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
(void)data; (void)len;
@@ -17,13 +91,13 @@ static void public_key(const uint8_t priv[32], uint8_t pub[32]) {
}
/* Экземпляры используют один uasync данного потока, но разные БД и идентичности. */
static void setup(struct UTUN_INSTANCE* inst, struct UASYNC* ua) {
static void setup(struct UTUN_INSTANCE* inst, struct UASYNC* ua, const char* path) {
memset(inst, 0, sizeof(*inst)); inst->ua = ua;
assert(sc_generate_keypair(&inst->my_keys) == SC_OK);
inst->node_id = sc_derive_node_id_from_pubkey(inst->my_keys.public_key);
memcpy(inst->my_ed25519_privkey, inst->my_keys.private_key, 32);
public_key(inst->my_ed25519_privkey, inst->my_ed25519_pubkey);
assert(sqlite3_open(":memory:", &inst->topo_sqlite_db) == SQLITE_OK);
assert(sqlite3_open(path ? path : ":memory:", &inst->topo_sqlite_db) == SQLITE_OK);
assert(topo_node_sqlite_init(inst->topo_sqlite_db) == 0);
uint8_t x[32] = {3}, ed[32], zero[64] = {0}; public_key(owner_private, ed);
assert(topo_node_sqlite_channel_put(inst->topo_sqlite_db, "1001", "one", 1, x, NULL, ed, NULL, zero) == 0);
@@ -94,7 +168,7 @@ static int veto_commit(void* arg) { int* remaining = arg; return (*remaining)--
/* Исключение, purge и ожидание signer не уничтожают последние состояния и отмены. */
static void membership_regressions(struct UASYNC* ua) {
struct UTUN_INSTANCE author, target, inviter; setup(&author, ua); setup(&target, ua); setup(&inviter, ua);
struct UTUN_INSTANCE author, target, inviter; setup(&author, ua, NULL); setup(&target, ua, NULL); setup(&inviter, ua, NULL);
for (unsigned ch = 0; ch < 2; ch++) {
const char* channel = ch ? "1002" : "1001";
add_member(&author, &author, channel); add_member(&target, &author, channel); add_member(&target, &target, channel);
@@ -245,6 +319,120 @@ static void membership_regressions(struct UASYNC* ua) {
assert(sqlite3_close(inviter.topo_sqlite_db) == SQLITE_OK);
}
/* Реальный file DB позволяет проверить commit другим соединением, в том числе из события. */
static struct UTUN_INSTANCE* feedback_reader;
static struct chat_feedback_record event_record;
static void fault_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
if (type != CHAT_EVT_FEEDBACK_CHANGED) return;
assert(sqlite3_get_autocommit(inst->topo_sqlite_db) && len == 38 && data[0] == 4);
assert_stored(feedback_reader, &event_record);
uint8_t writer[32], reader[32]; root(inst, writer);
assert(!merkle_tree_get(feedback_reader->topo_sqlite_db, "1:1001", 0, 0, reader) && !memcmp(reader, writer, 32)); events++;
}
/* Те же операции, которыми Merkle получает лист и пересчитывает его хеш. */
static int feedback_read(struct UTUN_INSTANCE* inst, const struct chat_feedback_record* record, enum sql_operation operation) {
merkle_key_t key, after = {{0}}, next; assert(!fb_key(1001, record, &key));
uint64_t prefix = merkle_sync_level_prefix(fb_read64(key.bytes), MT_MAX_LEVEL);
if (operation == F_PAGE) {
uint8_t bytes[FB_WIRE_MAX + 2]; size_t length = sizeof(bytes); int more;
return fb_page(inst, "1001", prefix, 0, &after, bytes, &length, &next, &more);
}
EVP_MD_CTX* hash = EVP_MD_CTX_new(); assert(hash && EVP_DigestInit_ex(hash, EVP_sha256(), NULL) == 1);
int rc = fb_hash(inst, "1001", MT_MAX_LEVEL, prefix, hash); EVP_MD_CTX_free(hash); return rc;
}
static void sql_fault_regressions(struct UASYNC* ua) {
char path[] = "/tmp/utun-feedback-fault-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd);
struct UTUN_INSTANCE author, target, reader; memset(&reader, 0, sizeof(reader));
setup(&author, ua, NULL); setup(&target, ua, path);
add_member(&author, &author, "1001"); add_member(&target, &author, "1001"); add_member(&target, &target, "1001");
assert(sqlite3_open_v2(path, &reader.topo_sqlite_db, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
feedback_reader = &reader; chat_event_set_handler(&target, fault_event);
uint8_t message[32] = {61}, reaction[] = {1, 0}, before[32], after[32]; uint64_t version;
struct chat_feedback_record old, current, candidate;
assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), NULL) == 1);
assert(chat_feedback_get(&author, "1001", message, author.node_id, CHAT_FEEDBACK_REACTION, &old) == 1);
assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, NULL) == 1);
assert(chat_feedback_get(&author, "1001", message, author.node_id, CHAT_FEEDBACK_REACTION, &current) == 1);
event_record = current; assert(chat_feedback_apply(&target, "1001", &current) == 1);
int saved = events; root(&target, before);
/* Ошибка поиска не должна воскрешать старую реакцию поверх более новой отмены. */
injected_operation = F_GET; injected_bind = 1; injected = F_BIND;
event_record = old;
assert(chat_feedback_apply(&target, "1001", &old) < 0 && injected == F_NONE && events == saved);
assert_stored(&reader, &current); root(&target, after); assert(!memcmp(before, after, 32));
unsigned cases = 0;
for (enum sql_fault point = F_BEGIN; point <= F_COMMIT; point++) {
for (enum sql_operation operation = F_GET; operation <= F_PUT; operation++) {
if (operation == F_GET && (point == F_BEGIN || point == F_CHANGES || point == F_COMMIT)) continue;
int bindings = point == F_BIND ? (operation == F_GET ? 3 : 8) : 1;
for (int bind = 1; bind <= bindings; bind++) {
candidate = old; candidate.ver = current.ver + 1; sign_record(&author, 1001, &candidate);
saved = events; root(&target, before); event_record = candidate;
injected_operation = operation; injected_bind = bind; injected = point;
assert(chat_feedback_apply(&target, "1001", &candidate) < 0 && injected == F_NONE && events == saved);
assert(sqlite3_get_autocommit(target.topo_sqlite_db)); assert_stored(&reader, &current);
root(&target, after); assert(!memcmp(before, after, 32));
assert(chat_feedback_apply(&target, "1001", &candidate) == 1 && events == saved + 1);
current = candidate; cases++;
}
}
}
unsigned reads = 0;
for (enum sql_operation operation = F_PAGE; operation <= F_HASH; operation++) {
for (enum sql_fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
int bindings = point == F_BIND ? (operation == F_PAGE ? 3 : 1) : 1;
for (int bind = 1; bind <= bindings; bind++) {
root(&target, before); saved = events;
injected_operation = operation; injected_bind = bind; injected = point;
assert(feedback_read(&target, &current, operation) < 0 && injected == F_NONE && events == saved);
assert_stored(&reader, &current); root(&target, after); assert(!memcmp(before, after, 32));
assert(feedback_read(&target, &current, operation) >= 0); reads++;
}
}
}
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_feedback BEFORE INSERT ON feedback_1001 BEGIN SELECT RAISE(ABORT,'feedback'); END",
"CREATE TEMP TRIGGER reject_feedback BEFORE INSERT ON feedback_1001 BEGIN SELECT RAISE(IGNORE); END"
};
for (unsigned i = 0; i < 2; i++) {
candidate = current; candidate.ver++; sign_record(&author, 1001, &candidate); event_record = candidate;
assert(sqlite3_exec(target.topo_sqlite_db, triggers[i], NULL, NULL, NULL) == SQLITE_OK); saved = events;
assert(chat_feedback_apply(&target, "1001", &candidate) < 0 && events == saved); assert_stored(&reader, &current);
assert(sqlite3_exec(target.topo_sqlite_db, "DROP TRIGGER reject_feedback", NULL, NULL, NULL) == SQLITE_OK);
assert(chat_feedback_apply(&target, "1001", &candidate) == 1); current = candidate;
}
/* Настоящий veto откатывает запись и индекс, повтор успешно фиксирует оба. */
candidate = current; candidate.ver++; sign_record(&author, 1001, &candidate); event_record = candidate;
int vetoes = 1; sqlite3_commit_hook(target.topo_sqlite_db, veto_commit, &vetoes); saved = events;
assert(chat_feedback_apply(&target, "1001", &candidate) < 0 && events == saved && vetoes == 0);
sqlite3_commit_hook(target.topo_sqlite_db, NULL, NULL); assert_stored(&reader, &current);
assert(chat_feedback_apply(&target, "1001", &candidate) == 1); current = candidate;
/* Даже локальный no-op не должен принимать внешнюю транзакцию. */
chat_event_set_handler(&target, on_event);
assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), &version) == 1);
saved = events; assert(sqlite3_exec(target.topo_sqlite_db, "BEGIN;UPDATE channels SET name='pending' WHERE channel_id='1002'",
NULL, NULL, NULL) == SQLITE_OK);
version = UINT64_MAX;
assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), &version) < 0);
uint8_t page[2 + FB_WIRE_MAX]; page[0] = 0; page[1] = 1;
candidate = current; candidate.ver++; sign_record(&author, 1001, &candidate);
int bytes = fb_encode(&candidate, page + 2); assert(bytes > 0);
assert(fb_apply_page(&target, "1001", author.node_id, page, 2 + bytes) < 0);
assert(!sqlite3_get_autocommit(target.topo_sqlite_db) && version == UINT64_MAX && events == saved);
assert_stored(&reader, &current); assert(sqlite3_exec(target.topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
/* После неудачного rollback нельзя RELEASE и commit частичной страницы. */
target.running = 1; injected_operation = F_PUT; injected = F_FINALIZE; failed_rollback = 1;
assert(chat_feedback_apply(&target, "1001", &candidate) < 0 && injected == F_NONE && !failed_rollback);
assert(!target.running && !sqlite3_get_autocommit(target.topo_sqlite_db) && events == saved); assert_stored(&reader, &current);
member_sync_destroy(&target); assert(sqlite3_close(target.topo_sqlite_db) == SQLITE_OK); assert_stored(&reader, &current);
member_sync_destroy(&author); assert(sqlite3_close(author.topo_sqlite_db) == SQLITE_OK);
assert(sqlite3_close(reader.topo_sqlite_db) == SQLITE_OK); assert(!remove(path)); feedback_reader = NULL;
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS feedback:%u write/%u read faults and retries, stale lookup, ABORT/IGNORE/veto, foreign no-op/page, failed rollback; reader checks records/roots before events", cases, reads);
}
/* Диагностика копии профиля: без изменения записей или доступа к приватным ключам. */
static int check_profile_authors(const char* path) {
struct UTUN_INSTANCE inst = {0};
@@ -271,7 +459,7 @@ int main(int argc, char** argv) {
if (argc == 2) return check_profile_authors(argv[1]);
size_t baseline = u_get_allocated_count();
struct UASYNC* ua = uasync_create(); assert(ua);
struct UTUN_INSTANCE source, target; setup(&source, ua); setup(&target, ua);
struct UTUN_INSTANCE source, target; setup(&source, ua, NULL); setup(&target, ua, NULL);
for (int ch = 0; ch < 2; ch++) {
const char* channel = ch ? "1002" : "1001";
add_member(&source, &source, channel); add_member(&target, &source, channel);
@@ -377,6 +565,7 @@ int main(int argc, char** argv) {
uint8_t empty[32] = {0}; assert(!memcmp(received, empty, 32));
puts("PASS: deleting a channel removes its feedback table and derived root");
membership_regressions(ua);
sql_fault_regressions(ua);
member_sync_destroy(&source); member_sync_destroy(&target);
assert(sqlite3_close(source.topo_sqlite_db) == SQLITE_OK && sqlite3_close(target.topo_sqlite_db) == SQLITE_OK);
uasync_poll(ua, 0); uasync_destroy(ua, 0);
+233
View File
@@ -0,0 +1,233 @@
/* JSON introspection: допустимое имя канала возвращается без потери байтов и с полным escaping. */
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_core_priv.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include <assert.h>
#include <string.h>
enum fault { F_NONE, F_PREPARE, F_BIND, F_STEP, F_FINALIZE, F_TEXT, F_ALLOCATE };
static enum fault fault;
static const char* target;
static int bind_index, text_column, step_number, fail_sync;
static int take(enum fault point, const char* sql, int index) {
if (fault != point || !target || strncmp(sql, target, strlen(target)) ||
(point == F_BIND && index != bind_index) || (point == F_TEXT && index != text_column)) return 0;
if (point == F_STEP && --step_number) return 0;
fault = F_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "JSON test: fault stage=%d index=%d target=%s", point, index, target); return 1;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int bytes, sqlite3_stmt** st, const char** tail) {
if (take(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, bytes, st, tail);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* value, int bytes, void (*destroy)(void*)) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, value, bytes, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
if (take(F_STEP, sqlite3_sql(st), 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int fail = st && take(F_FINALIZE, sqlite3_sql(st), 0);
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
extern const unsigned char* __real_sqlite3_column_text(sqlite3_stmt*, int);
const unsigned char* __wrap_sqlite3_column_text(sqlite3_stmt* st, int column) {
if (take(F_TEXT, sqlite3_sql(st), column)) return NULL;
return __real_sqlite3_column_text(st, column);
}
extern void* __real_u_realloc_impl(void*, uint32_t, const char*);
void* __wrap_u_realloc_impl(void* ptr, uint32_t bytes, const char* location) {
if (fault == F_ALLOCATE && strstr(location, "strbuf.c")) { fault = F_NONE; return NULL; }
return __real_u_realloc_impl(ptr, bytes, location);
}
extern void* __real_u_calloc_impl(uint32_t, uint32_t, const char*);
void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* location) {
if (fail_sync && strstr(location, "db_sync.c")) { fail_sync = 0; return NULL; }
return __real_u_calloc_impl(count, bytes, location);
}
static int listing(struct UTUN_INSTANCE* inst, const char* channel, unsigned kind, char* out, size_t capacity, size_t* size) {
if (!kind) return chat_core_get_channels_json(inst, out, capacity, size);
if (kind == 1) return chat_core_get_members_json(inst, channel, out, capacity, size);
return chat_core_get_messages_json(inst, channel, 100, 0, out, capacity, size);
}
static void valid_json(sqlite3* db, const char* json, size_t size) {
sqlite3_stmt* st = NULL; assert(strlen(json) == size);
assert(sqlite3_prepare_v2(db, "SELECT json_valid(?),json_type(?)", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, json, (int)size, SQLITE_STATIC) == SQLITE_OK &&
sqlite3_bind_text(st, 2, json, (int)size, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_int(st, 0) && !strcmp((const char*)sqlite3_column_text(st, 1), "array"));
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void text_value(sqlite3* db, const char* json, const char* path, const char* expected) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_type(st, 0) == SQLITE_TEXT);
assert(sqlite3_column_bytes(st, 0) == (int)strlen(expected) && !memcmp(sqlite3_column_text(st, 0), expected, strlen(expected)));
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void execute(sqlite3* db, const char* sql) {
int rc = sqlite3_exec(db, sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "JSON fixture: rc=%d: %s", rc, sqlite3_errmsg(db));
assert(rc == SQLITE_OK);
}
static void set_text(sqlite3* db, const char* sql, const char* value, int bytes) {
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, value, bytes, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_DONE);
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void rejected(struct UTUN_INSTANCE* inst, const char* channel, unsigned kind) {
char out[65536]; size_t size = 999; int changes = sqlite3_total_changes(inst->topo_sqlite_db); out[0] = 'x';
assert(listing(inst, channel, kind, out, sizeof(out), &size) < 0 && fault == F_NONE && !size && !out[0]);
assert(changes == sqlite3_total_changes(inst->topo_sqlite_db));
}
static void retry(struct UTUN_INSTANCE* inst, const char* channel, unsigned kind) {
char out[65536]; size_t size = 0; int changes = sqlite3_total_changes(inst->topo_sqlite_db);
assert(!listing(inst, channel, kind, out, sizeof(out), &size)); valid_json(inst->topo_sqlite_db, out, size);
assert(changes == sqlite3_total_changes(inst->topo_sqlite_db));
}
static void boundaries(struct UTUN_INSTANCE* inst, const char* channel) {
uint8_t guarded[65540]; char full[65536];
for (unsigned kind = 0; kind < 3; kind++) {
size_t bytes = 0; assert(!listing(inst, channel, kind, full, sizeof(full), &bytes));
size_t capacities[] = {0, 1, 2, 3, bytes - 1, bytes, bytes + 1};
for (unsigned c = 0; c < sizeof(capacities) / sizeof(capacities[0]); c++) {
memset(guarded, 0xa5, sizeof(guarded)); size_t capacity = capacities[c], size = 999;
int rc = listing(inst, channel, kind, (char*)guarded + 2, capacity, &size);
assert(guarded[0] == 0xa5 && guarded[1] == 0xa5);
for (size_t i = capacity + 2; i < sizeof(guarded); i++) assert(guarded[i] == 0xa5);
if (capacity <= bytes) assert(rc < 0 && !size && (!capacity || !guarded[2]));
else assert(!rc && size == bytes && !memcmp(guarded + 2, full, bytes + 1));
}
size_t size = 99; full[0] = 'x'; assert(listing(NULL, channel, kind, full, sizeof(full), &size) < 0 && !size && !full[0]);
full[0] = 'x'; assert(listing(inst, channel, kind, full, sizeof(full), NULL) < 0 && !full[0]);
}
puts("PASS JSON: zero/tiny/exact capacities,canaries,NULL guards,no truncated success");
}
static void faults(struct UTUN_INSTANCE* inst, const char* channel) {
const char* queries[] = {"SELECT c.channel_id", "SELECT 1 FROM sqlite_master WHERE type='table' AND name=", "SELECT count(*) FROM \"msg_",
"SELECT node_id FROM \"peers_", "SELECT p.node_id,n.name", "SELECT family,protocol,address", "SELECT json_object('id',m.id"};
const unsigned kinds[] = {0, 0, 0, 0, 1, 1, 2}; const int bindings[] = {0, 1, 0, 0, 0, 1, 2}; unsigned cases = 0;
for (unsigned q = 0; q < sizeof(kinds) / sizeof(kinds[0]); q++) {
target = queries[q];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_BIND && !bindings[q]) continue;
for (int index = 1; index <= (point == F_BIND ? bindings[q] : 1); index++) {
bind_index = index; step_number = 1; fault = point; rejected(inst, channel, kinds[q]); retry(inst, channel, kinds[q]); cases++;
}
}
}
const unsigned outer[] = {0, 4, 6};
for (unsigned i = 0; i < 3; i++) {
target = queries[outer[i]]; step_number = 2; fault = F_STEP; rejected(inst, channel, i); retry(inst, channel, i); cases++;
int columns[] = {i == 0 ? 0 : i == 1 ? 1 : 0, i == 0 ? 1 : 4, 3};
unsigned count = i == 0 ? 3 : i == 1 ? 2 : 1;
for (unsigned column = 0; column < count; column++) {
text_column = columns[column]; fault = F_TEXT; rejected(inst, channel, i); retry(inst, channel, i); cases++;
}
if (i < 2) { fault = F_ALLOCATE; rejected(inst, channel, i); retry(inst, channel, i); cases++; }
}
printf("PASS JSON:%u prepare/bind/step/finalize/text/allocation faults and retries,including failure after a completed row\n", cases);
}
static void corruption(struct UTUN_INSTANCE* inst, const char* channel) {
sqlite3* db = inst->topo_sqlite_db; const char hidden[] = {'a', 0, 'b'}; char sql[256];
execute(db, "BEGIN"); set_text(db, "UPDATE channels SET name=?", hidden, sizeof(hidden)); rejected(inst, channel, 0); execute(db, "ROLLBACK");
execute(db, "BEGIN"); set_text(db, "UPDATE nodes SET name=?", hidden, sizeof(hidden));
rejected(inst, channel, 0); rejected(inst, channel, 1); execute(db, "ROLLBACK");
execute(db, "BEGIN"); snprintf(sql, sizeof(sql), "UPDATE peers_%s SET adm_tags=?", channel); set_text(db, sql, hidden, sizeof(hidden));
rejected(inst, channel, 1); execute(db, "ROLLBACK");
const char* malformed[] = {"UPDATE nodes SET x25519_pubkey=zeroblob(31)", "UPDATE nodes SET ed25519_pubkey=zeroblob(33)",
"UPDATE nodes SET x25519_pubkey=printf('%032d',1)", "UPDATE node_addresses SET protocol=9", "UPDATE node_addresses SET address='abcd'"};
for (unsigned i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
execute(db, "BEGIN"); execute(db, malformed[i]); rejected(inst, channel, 1); execute(db, "ROLLBACK");
}
execute(db, "BEGIN;UPDATE channels SET channel_id='01'"); rejected(inst, channel, 0); execute(db, "ROLLBACK");
execute(db, "BEGIN;INSERT INTO ui_state(key,value) VALUES('JSON caller transaction','retained')");
for (unsigned kind = 0; kind < 3; kind++) { retry(inst, channel, kind); assert(!sqlite3_get_autocommit(db)); }
target = "SELECT json_object('id',m.id"; fault = F_FINALIZE; rejected(inst, channel, 2); assert(!sqlite3_get_autocommit(db));
execute(db, "ROLLBACK");
for (unsigned kind = 0; kind < 3; kind++) retry(inst, channel, kind);
puts("PASS JSON: hidden NUL,invalid key/address/ID rows rejected;caller transaction preserved on success/failure");
}
static void deferred_channel(struct UTUN_INSTANCE* inst) {
fail_sync = 1; uint64_t created = chat_core_create_channel_auto(inst, "committed, activation pending"); assert(created && !fail_sync);
char channel[32]; snprintf(channel, sizeof(channel), "%llu", (unsigned long long)created); assert(!si_find(inst, channel));
char out[65536]; size_t size = 0; assert(!chat_core_get_channels_json(inst, out, sizeof(out), &size)); valid_json(inst->topo_sqlite_db, out, size);
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_extract(value,'$.msgs') FROM json_each(?1) "
"WHERE json_extract(value,'$.id')=?2", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, out, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_type(st, 0) == SQLITE_INTEGER && !sqlite3_column_int(st, 0));
assert(sqlite3_finalize(st) == SQLITE_OK && !si_find(inst, channel));
assert(!chat_core_ensure_channel_ready(inst, channel) && si_find(inst, channel));
assert(!chat_core_get_channels_json(inst, out, sizeof(out), &size)); valid_json(inst->topo_sqlite_db, out, size);
puts("PASS JSON: committed channel without history remains visible with zero messages;runtime retry succeeds");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); size_t baseline = u_get_allocated_count();
utun_instance_set_tun_init_enabled(0); struct UASYNC* ua = uasync_create(); assert(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua,
"[ntp]\nenabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n");
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
char name[64] = "Канал "; size_t name_bytes = strlen(name);
for (int c = 1; c < 32; c++) name[name_bytes++] = (char)c;
memcpy(name + name_bytes, " \"\\", 4);
uint64_t id = chat_core_create_channel_auto(inst, name); assert(id); char channel[32];
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)id);
char json[4096]; size_t size = 0; assert(!chat_core_get_channels_json(inst, json, sizeof(json), &size));
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_valid(?)", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, json, (int)size, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int valid = sqlite3_column_int(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK);
if (!valid) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "JSON test: channel name with ASCII01 valid=%d bytes=%zu", valid, size);
assert(valid); text_value(inst->topo_sqlite_db, json, "$[0].name", name);
char node_name[256]; memset(node_name, '"', sizeof(node_name) - 1); node_name[sizeof(node_name) - 1] = 0;
set_text(inst->topo_sqlite_db, "UPDATE nodes SET name=?", node_name, -1);
char tags[8193]; memset(tags, 'a', sizeof(tags) - 1); memcpy(tags, "{\"pad\":\"", 8);
memcpy(tags + sizeof(tags) - 3, "\"}", 3); char sql[512]; snprintf(sql, sizeof(sql), "UPDATE peers_%s SET adm_tags=?", channel);
set_text(inst->topo_sqlite_db, sql, tags, -1);
snprintf(sql, sizeof(sql), "DELETE FROM node_addresses;INSERT INTO node_addresses(node_id,family,protocol,address,port,rtt) VALUES"
"(%lld,4,1,x'7f000001',4000,7),(%lld,4,6,x'7f000001',443,8),(%lld,6,2,x'00000000000000000000000000000001',4001,9)",
(long long)inst->node_id, (long long)inst->node_id, (long long)inst->node_id); execute(inst->topo_sqlite_db, sql);
const char message[] = "Текст \x01 \"\\\n";
struct chat_msg_submit req = {.inst = inst, .data = (uint8_t*)message, .data_len = sizeof(message) - 1};
strcpy(req.channel_id, channel); strcpy(req.content_type, "text/plain"); assert(!chat_core_submit_message(inst, &req));
char full[65536]; assert(!chat_core_get_members_json(inst, channel, full, sizeof(full), &size)); valid_json(inst->topo_sqlite_db, full, size);
text_value(inst->topo_sqlite_db, full, "$[0].name", node_name); text_value(inst->topo_sqlite_db, full, "$[0].adm_tags", tags);
text_value(inst->topo_sqlite_db, full, "$[0].addrs[0].ip", "127.0.0.1");
text_value(inst->topo_sqlite_db, full, "$[0].addrs[1].proto", "TCP+REALITY"); text_value(inst->topo_sqlite_db, full, "$[0].addrs[2].ip", "::1");
assert(!chat_core_get_channels_json(inst, full, sizeof(full), &size)); text_value(inst->topo_sqlite_db, full, "$[0].owner_name", node_name);
boundaries(inst, channel); faults(inst, channel); corruption(inst, channel); deferred_channel(inst);
utun_instance_destroy(inst); uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
puts("PASS JSON full escaping,complete outputs,SQL faults,deferred channel;allocations restored"); return 0;
}
+308
View File
@@ -0,0 +1,308 @@
/* Полный снимок участников не выдаёт успех после отказа SQL. */
#define _GNU_SOURCE
#include "../src/chat/chat_member.c"
#include "../src/routing_layer/topo_node_sqlite.h"
#include "../src/transport_layer/etcp.h"
#include "../src/transport_layer/etcp_connections.h"
#include "../lib/strbuf.h"
#include <assert.h>
static int fail_query;
enum fault { F_NONE, F_PREPARE, F_BIND, F_STEP, F_CHANGES, F_FINALIZE, F_EXEC, F_ALLOCATE, F_REALLOCATE };
static enum fault fault;
static const char* target;
static int bind_index, last_target, fail_rollback, arm_after_commit;
static sqlite3* reader;
static uint64_t group_id;
static char channel[32];
static unsigned member_events, mute_events, list_events;
static uint8_t detail[CHAT_MEMBER_DETAIL_MAX_SIZE];
static size_t detail_size;
static unsigned text_events;
static int expect_large_text;
static int matches(const char* sql) { return target && !strncmp(sql, target, strlen(target)); }
static int take(enum fault point, const char* sql, int index) {
if (fault != point || (sql && !matches(sql)) || (point == F_BIND && index != bind_index)) return 0;
fault = F_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member test: fault stage=%d index=%d target=%s", point, index, target); return 1;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int size, sqlite3_stmt** st, const char** tail) {
if (take(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, size, st, tail);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
last_target = matches(sqlite3_sql(st)); if (take(F_STEP, sqlite3_sql(st), 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_target && take(F_CHANGES, NULL, 0)) return 0;
return __real_sqlite3_changes(db);
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char**, char**), void* arg, char** error) {
if (fail_rollback && !strcmp(sql, "ROLLBACK")) { fail_rollback = 0; return SQLITE_IOERR; }
if (take(F_EXEC, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_exec(db, sql, cb, arg, error);
}
extern void* __real_u_malloc_impl(uint32_t, const char*);
void* __wrap_u_malloc_impl(uint32_t size, const char* location) {
if (fault == F_ALLOCATE && strstr(location, target)) { fault = F_NONE; return NULL; }
return __real_u_malloc_impl(size, location);
}
extern void* __real_u_realloc_impl(void*, uint32_t, const char*);
void* __wrap_u_realloc_impl(void* ptr, uint32_t size, const char* location) {
if (fault == F_REALLOCATE && strstr(location, target)) { fault = F_NONE; return NULL; }
return __real_u_realloc_impl(ptr, size, location);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
const char* snapshot = "SELECT p.node_id";
int fail = fail_query && st && !strncmp(sqlite3_sql(st), snapshot, strlen(snapshot));
if (fail) { fail_query = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member test: injected snapshot finalize failure"); }
if (st && take(F_FINALIZE, sqlite3_sql(st), 0)) fail = 1;
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
static int stored_mute(void) {
char sql[160]; snprintf(sql, sizeof(sql), "SELECT radio_muted FROM peers_%s WHERE node_id=7", channel); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(reader, sql, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int value = sqlite3_column_int(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK); return value;
}
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int size) {
if (type == CHAT_EVT_STATUS_REFRESH || type == CHAT_EVT_CONN_METRICS) {
assert(size > 0);
if (expect_large_text) assert(size > 8192 && memmem(data, (size_t)size, "LINK#299", 8));
text_events++; return;
}
if (type == CHAT_EVT_MEMBER_DETAIL) {
assert(size >= 0 && (size_t)size <= sizeof(detail)); memcpy(detail, data, (size_t)size); detail_size = (size_t)size; return;
}
if (type == CHAT_EVT_MEMBER_LIST) {
assert(size >= data[0] + 3); uint16_t count; memcpy(&count, data + data[0] + 1, 2);
assert((size_t)size == data[0] + 3 + (size_t)count * CHAT_MEMBER_DISPLAY_SIZE); list_events++; return;
}
if (type != CHAT_EVT_MEMBER_UPDATED) return;
assert(sqlite3_get_autocommit(inst->topo_sqlite_db) && size == data[0] + 2 + CHAT_MEMBER_DISPLAY_SIZE);
const uint8_t* wire = data + data[0] + 2;
if (mf_get64(wire) == 7) assert(!!(wire[10] & CHAT_MEMBER_FLAG_RADIO_MUTED) == stored_mute());
member_events++;
}
static void mute(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, int muted, void* arg) {
(void)arg; assert(group == group_id && node == 7 && sqlite3_get_autocommit(inst->topo_sqlite_db));
assert(stored_mute() == muted && radio_member_muted(inst, group, node) == muted); mute_events++;
if (arm_after_commit) { arm_after_commit = 0; target = "SELECT p.node_id"; fault = F_FINALIZE; }
}
static void refused_mute(struct UTUN_INSTANCE* inst) {
int before = stored_mute(); unsigned display = member_events, audio = mute_events;
assert(chat_core_toggle_member_radio_mute(inst, channel, 7) < 0 && fault == F_NONE);
assert(sqlite3_get_autocommit(inst->topo_sqlite_db) && stored_mute() == before && radio_member_muted(inst, group_id, 7) == before);
assert(display == member_events && audio == mute_events);
}
static void retry_mute(struct UTUN_INSTANCE* inst) {
int before = stored_mute(); unsigned display = member_events, audio = mute_events;
assert(!chat_core_toggle_member_radio_mute(inst, channel, 7));
assert(stored_mute() != before && member_events == display + 1 && mute_events == audio + 1);
}
static void snapshots(struct UTUN_INSTANCE* inst) {
unsigned cases = 0; target = "SELECT p.node_id"; uint8_t previous[CHAT_MEMBER_DISPLAY_SIZE], output[CHAT_MEMBER_DISPLAY_SIZE];
memset(previous, 0xa5, sizeof(previous));
for (int single = 0; single < 2; single++) for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES || (point == F_BIND && !single)) continue;
bind_index = 1; fault = point;
if (single) {
memcpy(output, previous, sizeof(output));
assert(chat_core_get_single_member(inst, channel, 7, output) < 0 && !memcmp(output, previous, sizeof(output)));
assert(fault == F_NONE && !chat_core_get_single_member(inst, channel, 7, output));
} else {
uint8_t* wire = NULL; int count = 0;
assert(chat_core_get_member_list(inst, channel, &wire, &count) < 0 && !wire && !count);
assert(fault == F_NONE && !chat_core_get_member_list(inst, channel, &wire, &count) && count == 4); u_free(wire);
}
cases++;
}
for (enum fault point = F_ALLOCATE; point <= F_REALLOCATE; point++) {
uint8_t* wire = NULL; int count = 0; target = "chat_member.c"; fault = point;
assert(chat_core_get_member_list(inst, channel, &wire, &count) < 0 && !wire && !count && fault == F_NONE);
assert(!chat_core_get_member_list(inst, channel, &wire, &count) && count == 4); u_free(wire); cases++;
}
const char* invalid[] = {NULL, "", "0", "01", "1x", "9223372036854775808", "12345678901234567890"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
uint8_t* wire = NULL; int count = 0;
assert(chat_core_get_member_list(inst, invalid[i], &wire, &count) < 0 && !wire && !count);
}
uint8_t* wire = NULL; int count = 0; assert(chat_core_get_member_list(NULL, channel, &wire, &count) < 0 && !wire && !count);
assert(!chat_core_member_online(NULL, channel, 7));
for (int rtt = 0; rtt < 2; rtt++) {
struct chat_member_list_req* req = u_calloc(1, sizeof(*req)); assert(req); strcpy(req->ch_id, channel);
if (rtt) chat_core_request_member_rtt_trampoline(req); else chat_core_request_member_list_trampoline(req);
req = u_malloc(sizeof(*req)); assert(req); req->inst = inst; memset(req->ch_id, '1', sizeof(req->ch_id));
if (rtt) chat_core_request_member_rtt_trampoline(req); else chat_core_request_member_list_trampoline(req);
}
struct chat_member_radio_mute_req* mute_req = u_malloc(sizeof(*mute_req)); assert(mute_req);
mute_req->inst = inst; mute_req->node_id = 7; memset(mute_req->ch_id, '1', sizeof(mute_req->ch_id));
chat_core_toggle_member_radio_mute_trampoline(mute_req);
on_member_props_changed(7, NULL, channel, inst); unsigned before = member_events;
target = "SELECT p.node_id"; fault = F_FINALIZE; on_member_props_changed(7, NULL, channel, inst);
assert(fault == F_NONE && before == member_events); on_member_props_changed(7, NULL, channel, inst); assert(member_events == before + 1);
struct chat_member_display source; memset(&source, 0xa5, sizeof(source)); strcpy(source.name, "N"); strcpy(source.avatar_file, "F");
chat_core_serialize_member(&source, output);
for (unsigned i = 16; i <= 78; i++) assert(!output[i]);
for (unsigned i = 88; i <= 156; i++) assert(!output[i]);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS member snapshots:%u SQL/allocation faults,retries,NULL/canonical guards,property event suppression,zero-padded strings", cases);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void mutes(struct UTUN_INSTANCE* inst) {
char update[96]; snprintf(update, sizeof(update), "UPDATE \"peers_%s\" SET radio_muted=", channel); target = update; unsigned cases = 0;
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
int bindings = point == F_BIND ? 2 : 1;
for (int index = 1; index <= bindings; index++) {
bind_index = index; fault = point; refused_mute(inst); retry_mute(inst); cases++;
}
}
const char* exec[] = {"BEGIN IMMEDIATE /* radio mute */", "COMMIT /* radio mute */"};
for (unsigned i = 0; i < 2; i++) { target = exec[i]; fault = F_EXEC; refused_mute(inst); retry_mute(inst); cases++; }
assert(!radio_set_member_muted(inst, group_id, 7, 0));
target = "radio.c"; fault = F_REALLOCATE; refused_mute(inst); retry_mute(inst); cases++;
const char* actions[] = {"ABORT,'mute update'", "IGNORE"};
for (unsigned i = 0; i < 2; i++) {
char sql[256]; snprintf(sql, sizeof(sql), "CREATE TEMP TRIGGER refuse_mute BEFORE UPDATE OF radio_muted ON peers_%s "
"BEGIN SELECT RAISE(%s); END", channel, actions[i]);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK); refused_mute(inst);
assert(sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER refuse_mute", NULL, NULL, NULL) == SQLITE_OK); retry_mute(inst);
}
unsigned commits = 0; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits); refused_mute(inst); assert(commits == 1);
sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL); retry_mute(inst);
int saved = stored_mute(); unsigned display = member_events, audio = mute_events;
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN;INSERT INTO ui_state(key,value) VALUES('foreign-mute','1')", NULL, NULL, NULL) == SQLITE_OK);
assert(radio_set_member_muted(inst, group_id, 7, !saved) < 0 && chat_core_toggle_member_radio_mute(inst, channel, 7) < 0);
assert(!sqlite3_get_autocommit(inst->topo_sqlite_db) && stored_mute() == saved && radio_member_muted(inst, group_id, 7) == saved);
assert(display == member_events && audio == mute_events);
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
arm_after_commit = 1; retry_mute(inst); assert(!arm_after_commit && fault == F_FINALIZE);
uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE]; assert(chat_core_get_single_member(inst, channel, 7, wire) < 0 && fault == F_NONE);
assert(!chat_core_get_single_member(inst, channel, 7, wire) && !!(wire[10] & CHAT_MEMBER_FLAG_RADIO_MUTED) == stored_mute());
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS member mute:%u SQL/allocation faults,retries,ABORT/IGNORE/veto,foreign transaction,read failure after commit;DB/cache/audio/events agree", cases);
}
static void details(struct UTUN_INSTANCE* inst) {
char query[96]; snprintf(query, sizeof(query), "SELECT 1 FROM \"peers_%s\" WHERE node_id=", channel); target = query;
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
struct chat_member_detail_req* req = u_calloc(1, sizeof(*req)); assert(req);
*req = (struct chat_member_detail_req){inst, group_id, 7, 123}; bind_index = 1; fault = point; detail_size = 0;
chat_core_collect_member_detail_trampoline(req); struct chat_member_detail_view view;
assert(fault == F_NONE && !chat_core_parse_member_detail(detail, detail_size, &view));
assert(view.result == CHAT_MEMBER_DETAIL_ERROR && view.request_id == 123 && view.node_id == 7 && view.group_id == group_id);
}
struct chat_member_detail_req* req = u_calloc(1, sizeof(*req)); assert(req); req->inst = inst; req->request_id = 124;
chat_core_collect_member_detail_trampoline(req); struct chat_member_detail_view view;
assert(!chat_core_parse_member_detail(detail, detail_size, &view) && view.result == CHAT_MEMBER_DETAIL_INVALID && view.request_id == 124);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS member detail:4 SQL faults,complete addressed error replies and invalid request codec");
}
static void node_names(struct UTUN_INSTANCE* inst) {
target = "SELECT name FROM nodes"; char out[128];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
strcpy(out, "previous"); bind_index = 1; fault = point;
assert(chat_core_get_node_name(inst, inst->node_id, out, sizeof(out)) < 0 && !out[0] && fault == F_NONE);
assert(!chat_core_get_node_name(inst, inst->node_id, out, sizeof(out)) && !strcmp(out, inst->name));
}
out[0] = 'x'; assert(chat_core_get_node_name(inst, inst->node_id, out, 0) < 0 && out[0] == 'x');
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS node names:4 SQL faults/retries,cleared failure output and zero-size guard");
}
static void diagnostics(struct UTUN_INSTANCE* inst) {
struct ETCP_CONN connection = {.instance = inst, .peer_node_id = 9, .unacked_bytes = UINT32_MAX};
struct ETCP_LINK* links = u_calloc(300, sizeof(*links)); assert(links); connection.links = links;
struct ll_queue* acks = queue_new(inst->ua, 0, 0, 0, "member_test_acks"); assert(acks);
for (int i = 0; i < 300; i++) {
links[i].etcp = &connection; links[i].ack_q = acks; links[i].next = i < 299 ? links + i + 1 : NULL;
}
struct ll_entry* entry = queue_entry_new(sizeof(struct conn_queue_entry)); assert(entry);
struct conn_queue_entry* item = (struct conn_queue_entry*)entry->data; item->peer_node_id = 9; item->conn = &connection;
assert(!queue_data_put_with_index(inst->connections, entry)); expect_large_text = 1; unsigned before = text_events;
chat_core_collect_status_trampoline(inst);
struct chat_node_arg* req = u_malloc(sizeof(*req)); assert(req); *req = (struct chat_node_arg){inst, 9}; chat_core_collect_conn_metrics_trampoline(req);
assert(text_events == before + 2);
target = "strbuf.c"; fault = F_REALLOCATE; chat_core_collect_status_trampoline(inst); assert(fault == F_NONE && text_events == before + 2);
chat_core_collect_status_trampoline(inst); assert(text_events == before + 3); expect_large_text = 0;
queue_remove_data(inst->connections, entry); queue_entry_free(entry); u_free(links); queue_free(acks);
if (SIZE_MAX > UINT32_MAX) {
size_t baseline = u_get_allocated_count(); struct strbuf text; strbuf_init(&text, (size_t)UINT32_MAX + 1);
assert(!text.buf && !text.cap && !text.len && u_get_allocated_count() == baseline);
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS diagnostics:300 links,full status/metrics beyond8KiB,OOM suppresses partial event,retry,allocator capacity boundary");
}
static void member_limit(struct UTUN_INSTANCE* inst) {
char sql[1024]; snprintf(sql, sizeof(sql), "WITH RECURSIVE n(value) AS(SELECT 100000 UNION ALL SELECT value+1 FROM n WHERE value<200000) "
"INSERT INTO peers_%s(node_id,x25519_pubkey,ed25519_pubkey,join_sig,join_ts,source) "
"SELECT value,zeroblob(32),zeroblob(32),zeroblob(64),0,1 FROM n WHERE value<>%llu LIMIT(65535-(SELECT count(*) FROM peers_%s))",
channel, (unsigned long long)inst->node_id, channel);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK);
uint8_t* wire = NULL; int count = 0; assert(!chat_core_get_member_list(inst, channel, &wire, &count) && count == UINT16_MAX);
assert(wire[8] && mf_get64(wire) == inst->node_id);
for (int i = 2; i < count; i++) assert(mf_get64(wire + (i - 1) * CHAT_MEMBER_DISPLAY_SIZE) < mf_get64(wire + i * CHAT_MEMBER_DISPLAY_SIZE));
u_free(wire); struct chat_member_list_req* req = u_malloc(sizeof(*req)); assert(req);
req->inst = inst; strcpy(req->ch_id, channel); unsigned before = list_events; chat_core_request_member_list_trampoline(req);
assert(list_events == before + 1);
uint64_t extra = inst->node_id == 9 ? 10 : 9;
assert(!topo_node_sqlite_member_placeholder_put(inst->topo_sqlite_db, channel, extra, inst->my_keys.public_key, inst->my_ed25519_pubkey));
wire = NULL; count = 0; assert(chat_core_get_member_list(inst, channel, &wire, &count) < 0 && !wire && !count);
req = u_malloc(sizeof(*req)); assert(req); req->inst = inst; strcpy(req->ch_id, channel); chat_core_request_member_list_trampoline(req);
assert(list_events == before + 1 && !topo_node_sqlite_member_del(inst->topo_sqlite_db, channel, extra));
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS member limit:65535 exact sorted members and complete event;65536 refused without wrapped count/truncated event");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_INFO);
size_t baseline = u_get_allocated_count(); utun_instance_set_tun_init_enabled(0);
char directory[] = "/tmp/utun-member-sql-XXXXXX"; assert(mkdtemp(directory)); char config[512], database[512];
snprintf(config, sizeof(config), "[global]\nmy_node_name=member test\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", directory);
struct UASYNC* ua = uasync_create(); assert(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
group_id = chat_core_create_channel_auto(inst, "member SQL"); assert(group_id);
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group_id);
uint8_t* wire = NULL; int count = 0; fail_query = 1;
int rc = chat_core_get_member_list(inst, channel, &wire, &count);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "member test: finalize fault result=%d count=%d fault_pending=%d", rc, count, fail_query);
assert(rc < 0 && !wire && !count && !fail_query);
assert(!chat_core_get_member_list(inst, channel, &wire, &count) && count == 1); u_free(wire);
const uint64_t peers[] = {7, 8, UINT64_MAX};
for (unsigned i = 0; i < 3; i++) assert(!topo_node_sqlite_member_placeholder_put(inst->topo_sqlite_db, channel, peers[i],
inst->my_keys.public_key, inst->my_ed25519_pubkey));
snprintf(database, sizeof(database), "%s/chats.db", directory); assert(sqlite3_open_v2(database, &reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
chat_event_set_handler(inst, event); radio_set_mute_cb(inst, mute, NULL);
snapshots(inst); mutes(inst); details(inst); node_names(inst); diagnostics(inst); member_limit(inst);
assert(!radio_set_member_muted(inst, group_id, 7, 0)); unsigned display = member_events, audio = mute_events;
target = "COMMIT /* radio mute */"; fault = F_EXEC; fail_rollback = 1; inst->running = 1;
assert(chat_core_toggle_member_radio_mute(inst, channel, 7) < 0 && fault == F_NONE && !fail_rollback && !inst->running);
assert(!sqlite3_get_autocommit(inst->topo_sqlite_db) && !stored_mute() && !radio_member_muted(inst, group_id, 7));
assert(display == member_events && audio == mute_events);
utun_instance_destroy(inst); assert(!stored_mute() && sqlite3_close(reader) == SQLITE_OK);
uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS member snapshot SQL refusal and retry"); return 0;
}
+147
View File
@@ -5,6 +5,75 @@
#include "../src/config_parser.h"
#include <assert.h>
enum poll_fault { P_NONE, P_PREPARE, P_BIND, P_STEP, P_FINALIZE };
enum poll_query { P_VALID, P_FIELD, P_TARGET, P_SNAPSHOT, P_CHOICES, P_OPTIONS };
static enum poll_fault fault;
static enum poll_query query;
static unsigned query_number, matching_queries, fault_bind, changed_events;
static sqlite3_stmt* fault_statement;
static int fail_ack_allocation;
extern void* __real_u_malloc_impl(uint32_t, const char*);
void* __wrap_u_malloc_impl(uint32_t size, const char* location) {
if (fail_ack_allocation && strstr(location, "chat_poll.c")) {
fail_ack_allocation = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll test: committed ack allocation failed bytes=%u", size); return NULL;
}
return __real_u_malloc_impl(size, location);
}
static int query_matches(const char* sql) {
const char* prefix[] = {"SELECT json_valid(?1)", "SELECT json_type(?1,?2),json_extract(?1,?2)",
"SELECT CASE WHEN json_valid", "SELECT node_id,kind,ver,data FROM feedback_",
"SELECT type,value FROM json_each(?1,?2)", "SELECT type,value FROM json_each(?1,'$.options')"};
return !strncmp(sql, prefix[query], strlen(prefix[query]));
}
static int take_fault(enum poll_fault point, sqlite3_stmt* st, unsigned index) {
if (fault != point || st != fault_statement || (point == P_BIND && index != fault_bind)) return 0;
fault = P_NONE;
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll test: injected query=%d occurrence=%u stage=%d bind=%u", query, query_number, point, index);
return 1;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int length, sqlite3_stmt** st, const char** tail) {
int target = fault != P_NONE && query_matches(sql) && ++matching_queries == query_number;
if (target && fault == P_PREPARE) {
fault = P_NONE; *st = NULL; DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll test: prepare query=%d occurrence=%u", query, query_number);
return SQLITE_NOMEM;
}
int rc = __real_sqlite3_prepare_v2(db, sql, length, st, tail);
if (target && rc == SQLITE_OK) fault_statement = *st;
return rc;
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* bytes, int length, void (*destroy)(void*)) {
if (take_fault(P_BIND, st, index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, bytes, length, destroy);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* bytes, int length, void (*destroy)(void*)) {
if (take_fault(P_BIND, st, index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, bytes, length, destroy);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
if (take_fault(P_STEP, st, 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int fail = st && take_fault(P_FINALIZE, st, 0);
int rc = __real_sqlite3_finalize(st); if (st == fault_statement) fault_statement = NULL;
return fail ? SQLITE_IOERR : rc;
}
static void poll_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
(void)data; (void)len;
assert(sqlite3_get_autocommit(inst->topo_sqlite_db));
if (type == CHAT_EVT_FEEDBACK_CHANGED || type == CHAT_EVT_MSG_RECEIVED) changed_events++;
}
static sqlite3_int64 json_number(sqlite3* db, const char* json, const char* path) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK);
@@ -26,6 +95,83 @@ static char* command(struct UTUN_INSTANCE* inst, const char* text, int expected)
assert(json_number(inst->topo_sqlite_db, "{\"valid\":1}", "$.valid") == 1); return result;
}
static int message_count(struct UTUN_INSTANCE* inst, const char* channel) {
char sql[128]; snprintf(sql, sizeof(sql), "SELECT count(*) FROM msg_%s", channel); sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
int count = sqlite3_column_int(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK); return count;
}
/* Ошибка SQL не становится отсутствующим полем/пустой сводкой и не публикует команду. */
static void sql_faults(struct UTUN_INSTANCE* inst, const char* channel, const char* hex, const uint8_t message[32]) {
char react[512], vote[512], create[512]; uint8_t before[32], after[32]; unsigned cases = 0;
snprintf(react, sizeof(react), "{\"cmd\":\"react\",\"ch\":\"%s\",\"message_id\":\"%s\",\"emoji\":0}", channel, hex);
snprintf(vote, sizeof(vote), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[0]}", channel, hex);
snprintf(create, sizeof(create), "{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"SQL retry\","
"\"mode\":\"single\",\"options\":[\"one\",\"two\"]}", channel);
chat_event_set_handler(inst, poll_event);
for (enum poll_query operation = P_VALID; operation <= P_OPTIONS; operation++) {
unsigned occurrences = operation == P_FIELD ? 6 : 1;
for (unsigned occurrence = 1; occurrence <= occurrences; occurrence++) {
for (enum poll_fault point = P_PREPARE; point <= P_FINALIZE; point++) {
unsigned bindings = point == P_BIND && (operation == P_FIELD || operation == P_CHOICES) ? 2 : 1;
for (unsigned bind = 1; bind <= bindings; bind++) {
struct chat_feedback_record saved, actual;
assert(chat_feedback_get(inst, channel, message, inst->node_id, CHAT_FEEDBACK_REACTION, &saved) == 1);
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, before));
unsigned events = changed_events; int messages = message_count(inst, channel);
query = operation; query_number = occurrence; matching_queries = 0; fault_statement = NULL;
fault_bind = bind; fault = point; char* result = NULL;
const char* request = operation == P_CHOICES ? vote : operation == P_OPTIONS ? create : react;
if (operation == P_SNAPSHOT) {
assert(chat_feedback_snapshot(inst, channel, message, &result) < 0 && !result);
} else { result = command(inst, request, -1); u_free(result); }
assert(fault == P_NONE && !fault_statement && changed_events == events && message_count(inst, channel) == messages);
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, after) && !memcmp(before, after, 32));
assert(chat_feedback_get(inst, channel, message, inst->node_id, CHAT_FEEDBACK_REACTION, &actual) == 1);
assert(actual.ver == saved.ver && actual.data_len == saved.data_len && !memcmp(actual.data, saved.data, saved.data_len));
assert(!memcmp(actual.signature, saved.signature, 64));
if (operation == P_SNAPSHOT) assert(!chat_feedback_snapshot(inst, channel, message, &result) && result);
else result = command(inst, request, 0);
u_free(result); cases++;
}
}
}
}
struct chat_feedback_record saved, actual;
assert(chat_feedback_get(inst, channel, message, inst->node_id, CHAT_FEEDBACK_REACTION, &saved) == 1);
snprintf(react, sizeof(react), "{\"cmd\":\"react\",\"ch\":\"%s\",\"message_id\":\"%s\",\"emoji\":\"👍\\u0000tail\"}", channel, hex);
char* result = command(inst, react, -1); u_free(result);
assert(chat_feedback_get(inst, channel, message, inst->node_id, CHAT_FEEDBACK_REACTION, &actual) == 1 && actual.ver == saved.ver);
assert(actual.data_len == saved.data_len && !memcmp(actual.data, saved.data, saved.data_len));
/* Snapshot после commit может отказать: ответ остаётся успехом, повторяем только чтение. */
snprintf(react, sizeof(react), "{\"cmd\":\"react\",\"ch\":\"%s\",\"message_id\":\"%s\",\"emoji\":0}", channel, hex);
for (unsigned publish = 0; publish < 2; publish++) {
const char* request = publish ? create : react;
for (enum poll_fault point = P_PREPARE; point <= P_FINALIZE; point++) {
int messages = message_count(inst, channel);
query = P_SNAPSHOT; query_number = 1; matching_queries = 0; fault_statement = NULL; fault_bind = 1; fault = point;
result = command(inst, request, 0); assert(fault == P_NONE && !fault_statement);
assert(json_number(inst->topo_sqlite_db, result, "$.committed") == 1 &&
json_number(inst->topo_sqlite_db, result, "$.snapshot_pending") == 1);
char acknowledged[65], read[256]; json_string(inst->topo_sqlite_db, result, "$.message_id", acknowledged, sizeof(acknowledged));
u_free(result);
assert(message_count(inst, channel) == messages + (int)publish);
snprintf(read, sizeof(read), "{\"cmd\":\"feedback\",\"ch\":\"%s\",\"message_id\":\"%s\"}", channel, acknowledged);
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, before)); unsigned events = changed_events;
result = command(inst, read, 0); u_free(result);
assert(message_count(inst, channel) == messages + (int)publish && changed_events == events);
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, after) && !memcmp(before, after, 32));
}
int messages = message_count(inst, channel); unsigned events = changed_events;
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, before));
fail_ack_allocation = 1; result = command(inst, request, -1); u_free(result);
assert(!fail_ack_allocation && changed_events == events && message_count(inst, channel) == messages);
assert(!merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, channel, 0, 0, after) && !memcmp(before, after, 32));
}
chat_event_set_handler(inst, NULL);
printf("PASS: %u poll SQL faults,8 post-commit read failures,2 pre-commit allocation failures; state/events preserved, read-only retry, complete emoji text\n", cases);
}
/* Проверяем границы, каноничность и невозможность интерпретации битого UTF-8. */
static void codecs(void) {
struct chat_poll poll = { .mode = CHAT_POLL_SINGLE, .allow_custom_answer = 1, .option_count = 2 }, decoded;
@@ -112,6 +258,7 @@ int main(void) {
result = command(inst, request, 0); assert(json_number(db, result, "$.reactions[0].count") == 0);
assert(json_number(db, result, "$.reaction_ver") == 5); u_free(result);
puts("PASS: selecting another emoji replaces the previous one; unselected withdrawal is inert; repeat click cancels");
sql_faults(inst, channel, id, message_id);
snprintf(request, sizeof(request),
"{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"Выбор\",\"mode\":\"multiple\","
"\"options\":[\"Первый\",\"Второй\"],\"allow_custom_answer\":true}", channel);
+196
View File
@@ -0,0 +1,196 @@
/* UI-state:ошибка завершения SQL не должна фиксировать значение. */
#define _GNU_SOURCE
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_setting.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include <assert.h>
#include <stdlib.h>
#include <string.h>
static int fail_finalize;
enum fault { F_NONE, F_PREPARE, F_BIND, F_STEP, F_CHANGES, F_FINALIZE, F_EXEC, F_TEXT };
static enum fault fault;
static const char* target;
static int argument, last_target, fail_rollback, observe_sound;
static unsigned broadcasts, autoconnect_calls;
static sqlite3* reader;
static char channel[32];
static int matches(const char* sql) { return sql && target && !strncmp(sql, target, strlen(target)); }
static int take(enum fault point, const char* sql, int index) {
if (fault != point || (sql && !matches(sql)) || ((point == F_BIND || point == F_TEXT) && index != argument)) return 0;
fault = F_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "preferences test: SQL fault point=%d index=%d target=%s", point, index, target); return 1;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int bytes, sqlite3_stmt** st, const char** tail) {
if (take(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, bytes, st, tail);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* text, int bytes, void (*destroy)(void*)) {
return take(F_BIND, sqlite3_sql(st), index) ? SQLITE_NOMEM : __real_sqlite3_bind_text(st, index, text, bytes, destroy);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
last_target = matches(sqlite3_sql(st));
return take(F_STEP, sqlite3_sql(st), 0) ? SQLITE_IOERR : __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) { return last_target && take(F_CHANGES, NULL, 0) ? 0 : __real_sqlite3_changes(db); }
extern const unsigned char* __real_sqlite3_column_text(sqlite3_stmt*, int);
const unsigned char* __wrap_sqlite3_column_text(sqlite3_stmt* st, int index) {
return take(F_TEXT, sqlite3_sql(st), index) ? NULL : __real_sqlite3_column_text(st, index);
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*fn)(void*, int, char**, char**), void* arg, char** error) {
if (fail_rollback && !strcmp(sql, "ROLLBACK")) { fail_rollback = 0; return SQLITE_IOERR; }
return take(F_EXEC, sql, 0) ? SQLITE_IOERR : __real_sqlite3_exec(db, sql, fn, arg, error);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
const char* sql = st ? sqlite3_sql(st) : NULL;
int fail = fail_finalize && sql && strstr(sql, "INTO ui_state");
if (fail) fail_finalize = 0;
fail |= sql && take(F_FINALIZE, sql, 0);
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
static void value_is(sqlite3* db, const char* expected) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, "SELECT value FROM ui_state WHERE key='preferences-test'", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW && !strcmp((const char*)sqlite3_column_text(st, 0), expected));
assert(sqlite3_finalize(st) == SQLITE_OK);
}
extern void __real_chat_sync_broadcast_sound_setting(struct UTUN_INSTANCE*, const char*, int);
void __wrap_chat_sync_broadcast_sound_setting(struct UTUN_INSTANCE* inst, const char* ch, int on) {
if (observe_sound) {
assert(sqlite3_get_autocommit(inst->topo_sqlite_db) && !strcmp(ch, channel));
char key[128]; snprintf(key, sizeof(key), "ch_%s_sound_on_message", ch);
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(reader, "SELECT value FROM ui_state WHERE key=?", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
const char* text = (const char*)sqlite3_column_text(st, 0); assert(text && text[0] == '0' + on && !text[1]);
assert(sqlite3_finalize(st) == SQLITE_OK); broadcasts++;
}
__real_chat_sync_broadcast_sound_setting(inst, ch, on);
}
extern void __real_chat_core_apply_group_autoconnect(struct UTUN_INSTANCE*);
void __wrap_chat_core_apply_group_autoconnect(struct UTUN_INSTANCE* inst) {
autoconnect_calls++; __real_chat_core_apply_group_autoconnect(inst);
}
static void write_faults(struct UTUN_INSTANCE* inst, int sound) {
const char* queries[] = {"SELECT value FROM ui_state", "INSERT INTO ui_state"}; unsigned cases = 0;
for (size_t q = 0; q < 2; q++) for (enum fault point = F_PREPARE; point <= F_TEXT; point++) {
if (point == F_EXEC || (point == F_CHANGES && !q) || (point == F_TEXT && q)) continue;
for (int index = 1; index <= (q && point == F_BIND ? 2 : 1); index++) {
assert(sound ? !chat_core_set_sound_on_message(inst, channel, 1) : !chat_core_save_ui_state(inst, "preferences-test", "old"));
unsigned count = broadcasts; target = queries[q]; fault = point; argument = point == F_TEXT ? 0 : index;
int rc = sound ? chat_core_set_sound_on_message(inst, channel, 0) : chat_core_save_ui_state(inst, "preferences-test", "new");
assert(rc < 0 && fault == F_NONE && sqlite3_get_autocommit(inst->topo_sqlite_db) && broadcasts == count);
if (sound) assert(chat_core_get_sound_on_message(inst, channel) == 1);
else value_is(reader, "old");
assert(sound ? !chat_core_set_sound_on_message(inst, channel, 0) : !chat_core_save_ui_state(inst, "preferences-test", "new"));
assert(broadcasts == count + (unsigned)sound); cases++;
}
}
const char* commands[] = {"BEGIN IMMEDIATE /* ui_state */", "COMMIT /* ui_state */"};
for (size_t i = 0; i < 2; i++) {
assert(sound ? !chat_core_set_sound_on_message(inst, channel, 1) : !chat_core_save_ui_state(inst, "preferences-test", "old"));
unsigned count = broadcasts; target = commands[i]; fault = F_EXEC;
int rc = sound ? chat_core_set_sound_on_message(inst, channel, 0) : chat_core_save_ui_state(inst, "preferences-test", "new");
assert(rc < 0 && fault == F_NONE && broadcasts == count && sqlite3_get_autocommit(inst->topo_sqlite_db));
if (sound) assert(chat_core_get_sound_on_message(inst, channel) == 1);
else value_is(reader, "old");
assert(sound ? !chat_core_set_sound_on_message(inst, channel, 0) : !chat_core_save_ui_state(inst, "preferences-test", "new")); cases++;
}
printf("PASS preferences writer sound=%d:%u SQL faults with unchanged reader/no broadcast and successful retries\n", sound, cases);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void read_faults(struct UTUN_INSTANCE* inst) {
assert(!chat_core_set_sound_on_message(inst, channel, 0)); unsigned count = broadcasts, cases = 0;
target = "SELECT value FROM ui_state";
for (enum fault point = F_PREPARE; point <= F_TEXT; point++) {
if (point == F_CHANGES || point == F_EXEC) continue;
fault = point; argument = point == F_TEXT ? 0 : 1;
assert(chat_core_get_sound_on_message(inst, channel) < 0 && fault == F_NONE);
assert(chat_core_get_sound_on_message(inst, channel) == 0 && broadcasts == count); cases++;
}
const char* bad[] = {"'yes'", "'2'", "NULL", "X'31'", "CAST(X'3100' AS TEXT)"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
char sql[256]; snprintf(sql, sizeof(sql), "UPDATE ui_state SET value=%s WHERE key='ch_%s_sound_on_message'", bad[i], channel);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK && chat_core_get_sound_on_message(inst, channel) < 0);
}
char sql[256]; snprintf(sql, sizeof(sql), "UPDATE ui_state SET value='0' WHERE key='ch_%s_sound_on_message'", channel);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK && chat_core_get_sound_on_message(inst, channel) == 0);
assert(chat_core_get_sound_on_message(NULL, channel) < 0 && chat_core_set_sound_on_message(inst, "01", 1) < 0);
assert(broadcasts == count);
printf("PASS preferences reader:%u SQL faults/retries;invalid booleans,types,NUL,and channel rejected\n", cases);
}
static void semantics(struct UTUN_INSTANCE* inst) {
assert(!chat_core_save_ui_state(inst, "preferences-test", "old") && !chat_core_set_sound_on_message(inst, channel, 1));
unsigned commits = 0, count = broadcasts; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits);
assert(!chat_core_save_ui_state(inst, "preferences-test", "old") && !chat_core_set_sound_on_message(inst, channel, 1));
assert(!commits && broadcasts == count + 1);
assert(chat_core_set_sound_on_message(inst, channel, 0) < 0 && commits == 1 && broadcasts == count + 1);
sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL); assert(chat_core_get_sound_on_message(inst, channel) == 1);
assert(!chat_core_set_sound_on_message(inst, channel, 0));
const char* modes[] = {"ABORT", "IGNORE"};
for (size_t i = 0; i < 2; i++) {
char sql[256]; snprintf(sql, sizeof(sql), "CREATE TRIGGER reject_pref BEFORE UPDATE ON ui_state BEGIN SELECT RAISE(%s%s);END",
modes[i], i ? "" : ",'injected preference error'");
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK); count = broadcasts;
assert(chat_core_set_sound_on_message(inst, channel, 1) < 0 && broadcasts == count && chat_core_get_sound_on_message(inst, channel) == 0);
assert(sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER reject_pref", NULL, NULL, NULL) == SQLITE_OK);
}
assert(!chat_core_set_sound_on_message(inst, channel, 1));
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN;INSERT INTO ui_state VALUES('pref-foreign','1')", NULL, NULL, NULL) == SQLITE_OK);
count = broadcasts; assert(chat_core_set_sound_on_message(inst, channel, 1) < 0 && chat_core_save_ui_state(inst, "preferences-test", "old") < 0);
assert(!sqlite3_get_autocommit(inst->topo_sqlite_db) && broadcasts == count);
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
unsigned applied = autoconnect_calls; struct chat_setting_state before = inst->chat_settings;
assert(chat_core_set_setting(inst, "group_autoconnect", "bogus") == -2 && autoconnect_calls == applied);
assert(!memcmp(&before, &inst->chat_settings, sizeof(before)) && chat_core_set_setting(inst, "unknown-pref", "1") == -1);
assert(!chat_core_set_setting(inst, "group_autoconnect", "0") && autoconnect_calls == applied + 1);
assert(chat_core_set_setting(NULL, "group_autoconnect", "0") == -2 && autoconnect_calls == applied + 1);
chat_core_save_ui_state_trampoline(NULL); chat_core_set_setting_trampoline(NULL); chat_core_set_sound_on_message_trampoline(NULL);
struct save_ui_state_arg* a = u_malloc(sizeof(*a)); assert(a); a->inst = inst; memset(a->data, 'x', sizeof(a->data));
chat_core_save_ui_state_trampoline(a);
a = u_malloc(sizeof(*a)); assert(a); a->inst = inst; memset(a->data, 'x', sizeof(a->data)); a->data[1] = 0;
chat_core_save_ui_state_trampoline(a);
struct chat_setting_arg* b = u_malloc(sizeof(*b)); assert(b); b->inst = inst; memset(b->data, 'x', sizeof(b->data));
chat_core_set_setting_trampoline(b);
struct chat_sound_arg* c = u_malloc(sizeof(*c)); assert(c); c->inst = inst; memset(c->ch_id, 'x', sizeof(c->ch_id));
c->on = 0; count = broadcasts; chat_core_set_sound_on_message_trampoline(c); assert(broadcasts == count);
puts("PASS preferences:no-op retries delivery without commit,veto/ABORT/IGNORE/foreign txn,validation and malformed requests");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); size_t baseline = u_get_allocated_count();
utun_instance_set_tun_init_enabled(0); struct UASYNC* ua = uasync_create(); assert(ua);
char directory[] = "/tmp/utun-preferences-XXXXXX"; assert(mkdtemp(directory)); char config[512], database[512];
snprintf(config, sizeof(config), "[global]\nmy_node_name=preferences test\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", directory);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
snprintf(database, sizeof(database), "%s/chats.db", directory);
assert(sqlite3_open_v2(database, &reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
assert(!chat_core_save_ui_state(inst, "preferences-test", "old")); value_is(reader, "old");
fail_finalize = 1; assert(chat_core_save_ui_state(inst, "preferences-test", "new") < 0 && !fail_finalize);
value_is(reader, "old");
uint64_t group = chat_core_create_channel_auto(inst, "preferences"); assert(group);
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group); observe_sound = 1;
assert(chat_core_get_sound_on_message(inst, channel) == 1); write_faults(inst, 0); write_faults(inst, 1); read_faults(inst); semantics(inst);
assert(!chat_core_save_ui_state(inst, "preferences-test", "old")); target = "COMMIT /* ui_state */";
fault = F_EXEC; fail_rollback = 1; inst->running = 1;
assert(chat_core_save_ui_state(inst, "preferences-test", "uncommitted") < 0 && !fail_rollback && !inst->running);
assert(!sqlite3_get_autocommit(inst->topo_sqlite_db)); value_is(reader, "old");
utun_instance_destroy(inst); assert(sqlite3_close(reader) == SQLITE_OK);
uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
puts("PASS preferences:failed finalize preserves committed UI state"); return 0;
}
+580
View File
@@ -0,0 +1,580 @@
/* Подписанный профиль: ошибка чтения не даёт успешный/частичный снимок. */
#define _GNU_SOURCE
#include "../src/utun_instance.h"
#include "../src/chat/chat_core.h"
#include "../src/chat/chat_member.h"
#include "../src/chat/member_sync.h"
#include "../src/chat/chat_event.h"
#include "../src/chat/chat_core_priv.h"
#include "../src/routing_layer/topo_node_sqlite.h"
#include "../src/routing_layer/topo_group.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include "../lib/json_flat.h"
#include <assert.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
#include <openssl/crypto.h>
static int fail_read;
enum fault { F_NONE, F_PREPARE, F_BIND, F_STEP, F_CHANGES, F_FINALIZE, F_EXEC, F_TEXT };
static enum fault fault;
static const char* target;
static int index_number, last_target, fail_rollback, fail_member_rollback, member_rollback_failed, releases_after_failure;
static int fail_sign, fail_verify, fail_member_malloc, fail_apply_registration, delete_on_event;
static unsigned tag_alloc_fail, tag_alloc_seen;
static int tag_alloc_record;
static unsigned events, sequence;
static sqlite3* reader;
static char channel[32];
static struct UTUN_INSTANCE* active;
static uint8_t original_join[64];
static int matches(const char* sql) { return target && !strncmp(sql, target, strlen(target)); }
static int take(enum fault point, const char* sql, int index) {
if (fault != point || (sql && !matches(sql)) || ((point == F_BIND || point == F_TEXT) && index != index_number)) return 0;
fault = F_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile test: SQL fault point=%d index=%d target=%s", point, index, target); return 1;
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int bytes, sqlite3_stmt** st, const char** tail) {
if (take(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, bytes, st, tail);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* value, int bytes, void (*destroy)(void*)) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, value, bytes, destroy);
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* value, int bytes, void (*destroy)(void*)) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, value, bytes, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_int(sqlite3_stmt*, int, int);
int __wrap_sqlite3_bind_int(sqlite3_stmt* st, int index, int value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int(st, index, value);
}
extern int __real_sqlite3_bind_null(sqlite3_stmt*, int);
int __wrap_sqlite3_bind_null(sqlite3_stmt* st, int index) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_null(st, index);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
last_target = matches(sqlite3_sql(st));
if (take(F_STEP, sqlite3_sql(st), 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_target && take(F_CHANGES, NULL, 0)) return 0;
return __real_sqlite3_changes(db);
}
extern const unsigned char* __real_sqlite3_column_text(sqlite3_stmt*, int);
const unsigned char* __wrap_sqlite3_column_text(sqlite3_stmt* st, int column) {
if (take(F_TEXT, sqlite3_sql(st), column)) return NULL;
return __real_sqlite3_column_text(st, column);
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*callback)(void*, int, char**, char**), void* arg, char** error) {
if (fail_rollback && !strcmp(sql, "ROLLBACK")) { fail_rollback = 0; return SQLITE_IOERR; }
if (fail_member_rollback && !strcmp(sql, "ROLLBACK TO member_record")) {
fail_member_rollback = 0; member_rollback_failed = 1; return SQLITE_IOERR;
}
if (member_rollback_failed && !strcmp(sql, "RELEASE member_record")) releases_after_failure++;
if (take(F_EXEC, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_exec(db, sql, callback, arg, error);
}
extern int __real_sc_ed25519_sign(const uint8_t*, const uint8_t*, size_t, uint8_t*);
int __wrap_sc_ed25519_sign(const uint8_t* key, const uint8_t* bytes, size_t size, uint8_t* signature) {
if (fail_sign) { fail_sign = 0; return SC_ERR_CRYPTO; }
return __real_sc_ed25519_sign(key, bytes, size, signature);
}
extern int __real_sc_ed25519_verify(const uint8_t*, const uint8_t*, size_t, const uint8_t*);
int __wrap_sc_ed25519_verify(const uint8_t* key, const uint8_t* bytes, size_t size, const uint8_t* signature) {
int join = active && size == 112 && !memcmp(bytes + 72, active->my_keys.public_key, 32);
int update = size >= 73 && !memcmp(bytes, original_join, 64);
if ((fail_verify == 1 && join) || (fail_verify == 2 && update)) { fail_verify = 0; return SC_ERR_CRYPTO; }
return __real_sc_ed25519_verify(key, bytes, size, signature);
}
extern void* __real_u_malloc_impl(uint32_t, const char*);
void* __wrap_u_malloc_impl(uint32_t bytes, const char* location) {
if (fail_member_malloc && strstr(location, "member_sync.c")) { fail_member_malloc = 0; return NULL; }
return __real_u_malloc_impl(bytes, location);
}
static int tag_allocation(const char* location, const char* module) {
if (!strstr(location, module)) return 0;
if (tag_alloc_record) tag_alloc_seen++;
if (tag_alloc_fail && !--tag_alloc_fail) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "tags test: allocation refused at %s", location); return 1;
}
return 0;
}
extern void* __real_u_calloc_impl(uint32_t, uint32_t, const char*);
void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* location) {
return tag_allocation(location, "chat_core.c") ? NULL : __real_u_calloc_impl(count, bytes, location);
}
extern void* __real_u_realloc_impl(void*, uint32_t, const char*);
void* __wrap_u_realloc_impl(void* ptr, uint32_t bytes, const char* location) {
return tag_allocation(location, "strbuf.c") ? NULL : __real_u_realloc_impl(ptr, bytes, location);
}
extern char* __real_u_strdup_impl(const char*, const char*);
char* __wrap_u_strdup_impl(const char* text, const char* location) {
return tag_allocation(location, "chat_core.c") ? NULL : __real_u_strdup_impl(text, location);
}
extern int __real_member_sync_add_apply_cbk(struct UTUN_INSTANCE*, member_apply_cbk_fn, void*);
int __wrap_member_sync_add_apply_cbk(struct UTUN_INSTANCE* inst, member_apply_cbk_fn callback, void* arg) {
if (fail_apply_registration) { fail_apply_registration = 0; fail_member_malloc = 1; }
return __real_member_sync_add_apply_cbk(inst, callback, arg);
}
static void snapshot(sqlite3* db, uint8_t out[32]) {
char peers[128]; snprintf(peers, sizeof(peers), "SELECT * FROM peers_%s ORDER BY node_id", channel);
const char* queries[] = {peers, "SELECT * FROM nodes ORDER BY node_id",
"SELECT namespace,level,prefix64,hash FROM merkle_tree_hash ORDER BY namespace,level,prefix64"};
EVP_MD_CTX* hash = EVP_MD_CTX_new(); assert(hash && EVP_DigestInit_ex(hash, EVP_sha256(), NULL) == 1);
for (unsigned i = 0; i < 3; i++) {
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(db, queries[i], -1, &st, NULL) == SQLITE_OK); int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) for (int c = 0; c < sqlite3_column_count(st); c++) {
int type = sqlite3_column_type(st, c), bytes = sqlite3_column_bytes(st, c); const void* value = sqlite3_column_blob(st, c);
assert(!bytes || value);
assert(EVP_DigestUpdate(hash, &type, sizeof(type)) == 1 && EVP_DigestUpdate(hash, &bytes, sizeof(bytes)) == 1);
if (bytes) assert(EVP_DigestUpdate(hash, value, (size_t)bytes) == 1);
}
assert(rc == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
}
unsigned length; assert(EVP_DigestFinal_ex(hash, out, &length) == 1 && length == 32); EVP_MD_CTX_free(hash);
}
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int bytes) {
if (type != CHAT_EVT_MEMBER_UPDATED) return;
assert(bytes >= 1 && bytes >= data[0] + 1);
if (strlen(channel) != data[0] || memcmp(data + 1, channel, data[0])) return;
assert(sqlite3_get_autocommit(inst->topo_sqlite_db));
char profile[MS_USERINFO_MAX + 1]; uint8_t signature[64], writer[32], committed[32]; uint64_t timestamp;
assert(!chat_core_get_my_profile(inst, channel, profile, sizeof(profile), signature, &timestamp));
snapshot(inst->topo_sqlite_db, writer); snapshot(reader, committed); assert(!memcmp(writer, committed, 32)); events++;
if (delete_on_event) { delete_on_event = 0; chat_core_delete_channel(inst, channel); }
}
static int update(struct UTUN_INSTANCE* inst) {
char value[32]; snprintf(value, sizeof(value), "%u", ++sequence);
const struct chat_profile_option option = {"test", value}; return chat_core_set_my_profile_options(inst, channel, &option, 1);
}
static void refused(struct UTUN_INSTANCE* inst) {
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
assert(update(inst) < 0 && fault == F_NONE && !fail_sign && !fail_verify && sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
}
static void retry(struct UTUN_INSTANCE* inst) { unsigned count = events; assert(!update(inst) && events == count + 1); }
static void read_refused(struct UTUN_INSTANCE* inst) {
char out[MS_USERINFO_MAX + 1], before[MS_USERINFO_MAX + 1]; uint8_t signature[64], old[64]; uint64_t timestamp = 123;
memset(out, 0xa5, sizeof(out)); memcpy(before, out, sizeof(out)); memset(signature, 0xa5, 64); memcpy(old, signature, 64);
assert(chat_core_get_my_profile(inst, channel, out, sizeof(out), signature, &timestamp) < 0);
assert(!memcmp(out, before, sizeof(out)) && !memcmp(signature, old, 64) && timestamp == 123 && fault == F_NONE && !fail_verify);
}
static void reader_faults(struct UTUN_INSTANCE* inst) {
const char* queries[] = {"SELECT userinfo,update_sig,update_ts", "SELECT name, owner_node_id", "SELECT json_valid(?1),CASE",
"SELECT count(*),count(DISTINCT key)", "SELECT key,value FROM json_each"}; unsigned cases = 0;
for (unsigned q = 0; q < 5; q++) {
target = queries[q];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
fault = point; index_number = 1; read_refused(inst); cases++;
char out[MS_USERINFO_MAX + 1]; uint8_t signature[64]; uint64_t timestamp;
assert(!chat_core_get_my_profile(inst, channel, out, sizeof(out), signature, &timestamp));
}
}
for (int verify = 1; verify <= 2; verify++) { fail_verify = verify; read_refused(inst); }
printf("PASS profile reader:%u SQL faults/retries,JOIN/update crypto failure,all outputs preserved\n", cases);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void writer_faults(struct UTUN_INSTANCE* inst) {
const char* queries[] = {"SELECT join_sig,join_ts,update_ts", "SELECT name, owner_node_id", "SELECT json_valid(?1),CASE",
"SELECT count(*),count(DISTINCT key)", "SELECT key,value FROM json_each", "SELECT NOT EXISTS(SELECT key,value",
"INSERT INTO \"peers_", "INSERT INTO nodes(node_id, name, x25519_pubkey"};
const int bindings[] = {1, 1, 1, 1, 1, 2, 11, 6}; unsigned cases = 0;
for (unsigned q = 0; q < sizeof(bindings) / sizeof(bindings[0]); q++) {
target = queries[q];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES && q < 6) continue;
for (int index = 1; index <= (point == F_BIND ? bindings[q] : 1); index++) {
index_number = index; fault = point; refused(inst); retry(inst); cases++;
}
}
}
const char* exec[] = {"BEGIN IMMEDIATE /* profile */", "SAVEPOINT member_record", "RELEASE member_record", "COMMIT /* profile */"};
for (unsigned i = 0; i < 4; i++) { target = exec[i]; fault = F_EXEC; refused(inst); retry(inst); cases++; }
fail_sign = 1; refused(inst); retry(inst);
for (int verify = 1; verify <= 2; verify++) { fail_verify = verify; refused(inst); retry(inst); }
unsigned commits = 0; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits); refused(inst); assert(commits == 1);
sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL); retry(inst);
char sql[256]; const char* actions[] = {"ABORT,'profile update'", "IGNORE"};
for (unsigned i = 0; i < 2; i++) {
snprintf(sql, sizeof(sql), "CREATE TEMP TRIGGER reject_profile BEFORE INSERT ON peers_%s BEGIN SELECT RAISE(%s); END", channel, actions[i]);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK); refused(inst);
assert(sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER reject_profile", NULL, NULL, NULL) == SQLITE_OK); retry(inst);
}
target = "INSERT INTO \"peers_"; fault = F_FINALIZE; fail_member_rollback = 1; refused(inst);
assert(member_rollback_failed && !fail_member_rollback && !releases_after_failure); member_rollback_failed = 0; retry(inst);
printf("PASS profile writer:%u SQL faults/retries,sign/verify,ABORT/IGNORE/veto,nested rollback failure;records/nodes/Merkle/events preserved\n", cases);
}
static void semantic_profile(struct UTUN_INSTANCE* inst) {
const struct chat_profile_option options[] = {{"z", "А😀\x01"}, {"a", "unchanged"}, {"protect_attachments", "yes"}};
assert(!chat_core_set_my_profile_options(inst, channel, options, 3));
char json[MS_USERINFO_MAX + 1], before[MS_USERINFO_MAX + 1]; uint8_t signature[64], old[64]; uint64_t timestamp, previous;
assert(!chat_core_get_my_profile(inst, channel, before, sizeof(before), old, &previous));
unsigned count = events, commits = 0; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits);
assert(!chat_core_set_my_profile_options(inst, channel, options, 3) && !commits && events == count);
sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL);
assert(!chat_core_get_my_profile(inst, channel, json, sizeof(json), signature, &timestamp));
assert(timestamp == previous && !strcmp(before, json) && !memcmp(signature, old, 64));
char value[128]; assert(!json_flat_get(json, "z", value, sizeof(value)) && !strcmp(value, options[0].value));
assert(!json_flat_get(json, "protect_attachments", value, sizeof(value)) && !strcmp(value, "yes"));
const char* bad[] = {"{\"a\":\"x\",\"a\":\"y\"}", "{\"x\":1}", "{\"x\":\"a\\u0000b\"}", "{\"x\":\"y\"} trailing",
"{\"x\":\"y\",}", "{\"x\":\"y\" \"z\":\"v\"}"};
for (unsigned i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) assert(chat_json_strings(inst->topo_sqlite_db, bad[i], MS_USERINFO_MAX, NULL, NULL) < 0);
assert(!chat_json_strings(inst->topo_sqlite_db, "{\"x\":\"\\uD83D\\uDE00\"}", MS_USERINFO_MAX, NULL, NULL));
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN;INSERT INTO ui_state(key,value) VALUES('foreign-profile','1')", NULL, NULL, NULL) == SQLITE_OK);
assert(chat_core_set_my_profile_options(inst, channel, options, 3) < 0 && chat_core_set_my_avatar(inst, channel, 0, "") < 0);
assert(!sqlite3_get_autocommit(inst->topo_sqlite_db) && events == count);
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
puts("PASS profile:semantic no-op keeps version/signature,Unicode/control and policy survive,strict object/unique/string/NUL validation,foreign txn");
}
static void callback_deletion(struct UTUN_INSTANCE* inst) {
char parent[32]; strcpy(parent, channel); uint64_t child = chat_core_create_channel_auto(inst, "delete from profile event"); assert(child);
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)child); delete_on_event = 1;
assert(!update(inst) && !delete_on_event && !topo_groups_find(inst->topo_groups, child)); strcpy(channel, parent);
puts("PASS profile:channel deleted from committed property callback without using old group/namespace");
}
static int owner_update(struct UTUN_INSTANCE* inst) {
char tags[96]; snprintf(tags, sizeof(tags), "{\"ver\":\"%u\",\"storage\":\"yes\"}", ++sequence);
uint8_t key[32], body[MS_ADM_TAGS_MAX + 8], signature[64];
assert(!topo_node_sqlite_channel_get_priv(inst->topo_sqlite_db, channel, key));
int bytes = member_sync_build_owner_msg(inst->node_id, tags, body, sizeof(body)); assert(bytes > 0);
assert(sc_ed25519_sign(key, body, (size_t)bytes, signature) == SC_OK); OPENSSL_cleanse(key, sizeof(key));
return member_sync_put(inst, channel, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
NULL, 0, NULL, 0, NULL, tags, signature, 0, 0, NULL);
}
static void owner_faults(struct UTUN_INSTANCE* inst) {
char query[128]; snprintf(query, sizeof(query), "UPDATE \"peers_%s\" SET adm_tags=", channel); target = query; unsigned cases = 0;
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
for (int index = 1; index <= (point == F_BIND ? 5 : 1); index++) {
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
fault = point; index_number = index; assert(owner_update(inst) < 0 && fault == F_NONE && sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
int applied = owner_update(inst); assert(applied >= 0 && (applied & MS_APPLY_CHANGED) && events == count + 1); cases++;
}
}
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
fault = F_FINALIZE; fail_member_rollback = 1; assert(owner_update(inst) < 0 && fault == F_NONE && !fail_member_rollback);
assert(member_rollback_failed && !releases_after_failure && sqlite3_get_autocommit(inst->topo_sqlite_db));
member_rollback_failed = 0; snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
int applied = owner_update(inst); assert(applied >= 0 && (applied & MS_APPLY_CHANGED));
for (int bind = 1; bind <= 2; bind++) {
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN", NULL, NULL, NULL) == SQLITE_OK);
fault = F_BIND; index_number = bind;
assert(topo_node_sqlite_member_owner_put(inst->topo_sqlite_db, channel, inst->node_id, NULL, NULL, 0) < 0 && fault == F_NONE);
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
}
target = NULL;
printf("PASS owner block:%u SQL faults/retries,NULL binds,and failed standalone ROLLBACK TO never commits partial owner data\n", cases);
}
static void avatar(struct UTUN_INSTANCE* inst) {
char file[70]; memset(file, 'a', 64); memcpy(file + 64, ".json", 6);
const char* query = "SELECT adm_tags,source,adm_tags_sig,deleted";
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
target = query; fault = point; index_number = 1;
assert(chat_core_set_my_avatar(inst, channel, inst->node_id, file) < 0 && fault == F_NONE);
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
}
assert(!chat_core_set_my_avatar(inst, channel, inst->node_id, file)); unsigned count = events;
assert(!chat_core_set_my_avatar(inst, channel, inst->node_id, file) && events == count);
char json[MS_USERINFO_MAX + 1], value[70]; uint8_t signature[64]; uint64_t version;
assert(!chat_core_get_my_profile(inst, channel, json, sizeof(json), signature, &version));
assert(!json_flat_get(json, "avatar_file", value, sizeof(value)) && !strcmp(value, file));
assert(!chat_core_set_my_avatar(inst, channel, 0, "")); count = events;
assert(!chat_core_set_my_avatar(inst, channel, 0, "") && events == count);
puts("PASS avatar profile:4 ACL SQL failures,publish/identical retry/delete keep signed options and committed events");
}
static void no_apply(const char* ns, uint64_t node, const uint8_t* x, uint64_t parent, const uint8_t* sig, void* arg) {
(void)ns; (void)node; (void)x; (void)parent; (void)sig; (void)arg;
}
static void registration_failure(struct UTUN_INSTANCE* inst, struct UASYNC* ua) {
size_t count = u_get_allocated_count(); fail_member_malloc = 1;
assert(member_sync_add_apply_cbk(inst, no_apply, inst) < 0 && !fail_member_malloc && u_get_allocated_count() == count);
struct UTUN_INSTANCE* other = utun_instance_create_from_str(ua,
"[ntp]\nenabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n");
assert(other && !utun_core_start(other)); fail_apply_registration = 1;
assert(chat_service_start(other) < 0 && !fail_apply_registration && !fail_member_malloc);
assert(other->core_started && !other->chat_started && !other->chat_sync && !other->chat_core);
assert(!chat_service_start(other)); utun_instance_destroy(other);
puts("PASS profile/JOIN callback allocation:refused registration rolls service startup back;core survives,retry succeeds");
}
static void set_profile_row(struct UTUN_INSTANCE* inst, const char* json, const uint8_t signature[64], uint64_t version, const uint8_t join[64]) {
char sql[192]; snprintf(sql, sizeof(sql), "UPDATE peers_%s SET userinfo=?,update_sig=?,update_ts=?,join_sig=? WHERE node_id=?", channel);
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC) == SQLITE_OK);
assert((signature ? sqlite3_bind_blob(st, 2, signature, 64, SQLITE_STATIC) : sqlite3_bind_null(st, 2)) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 3, (sqlite3_int64)version) == SQLITE_OK && sqlite3_bind_blob(st, 4, join, 64, SQLITE_STATIC) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 5, (sqlite3_int64)inst->node_id) == SQLITE_OK && sqlite3_step(st) == SQLITE_DONE);
assert(sqlite3_finalize(st) == SQLITE_OK);
}
static void damaged_profile(struct UTUN_INSTANCE* inst) {
char json[MS_USERINFO_MAX + 1]; uint8_t signature[64], changed[64], body[MS_USERINFO_MAX + 73]; uint64_t version;
assert(!chat_core_get_my_profile(inst, channel, json, sizeof(json), signature, &version));
char sql[192]; snprintf(sql, sizeof(sql), "UPDATE peers_%s SET userinfo=CAST(userinfo AS BLOB) WHERE node_id=%lld", channel, (long long)inst->node_id);
assert(sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK); read_refused(inst); refused(inst);
set_profile_row(inst, json, signature, version, original_join);
set_profile_row(inst, "{\"untrusted\":\"do not sign\"}", signature, version, original_join); read_refused(inst); refused(inst);
set_profile_row(inst, json, signature, version, original_join);
memcpy(changed, original_join, 64); changed[0] ^= 1;
int bytes = member_sync_build_update_msg(changed, version, json, body, sizeof(body)); assert(bytes > 0); uint8_t forged[64];
assert(sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, forged) == SC_OK);
set_profile_row(inst, json, forged, version, changed); read_refused(inst); refused(inst);
set_profile_row(inst, json, signature, version, original_join);
const char* duplicate = "{\"name\":\"profile test\",\"x\":\"one\",\"x\":\"two\"}";
bytes = member_sync_build_update_msg(original_join, version, duplicate, body, sizeof(body)); assert(bytes > 0);
assert(sc_ed25519_sign(inst->my_ed25519_privkey, body, (size_t)bytes, forged) == SC_OK);
set_profile_row(inst, duplicate, forged, version, original_join); read_refused(inst); refused(inst);
set_profile_row(inst, "{\"untrusted\":\"discard unsigned attributes\"}", NULL, 0, original_join);
assert(!update(inst));
assert(!chat_core_get_my_profile(inst, channel, json, sizeof(json), signature, &version));
char value[64]; assert(json_flat_get(json, "untrusted", value, sizeof(value)) < 0);
puts("PASS profile trust:bad old signature,JOIN,and signed duplicate JSON rejected;unsigned foreign attributes never re-signed");
}
static void unicode(void) {
const char* json = "{\"name\":\"\\u0410\\uD83D\\uDE00\",\"protect\":\"yes\",\"control\":\"\\u0001\"}";
char value[64]; assert(!json_flat_get(json, "name", value, sizeof(value)) && !strcmp(value, "А😀"));
assert(!json_flat_get(json, "protect", value, sizeof(value)) && !strcmp(value, "yes"));
assert(!json_flat_get(json, "control", value, sizeof(value)) && value[0] == 1 && !value[1]);
const char* invalid[] = {"{\"a\":\"\\u0000\"}", "{\"a\":\"\\uD800\"}", "{\"a\":\"\\uDC00\"}",
"{\"a\":\"\\uD800\\u0001\"}", "{\"a\":\"\\uXX12\"}", "{\"a\":\"\\u123\"}", "{\"a\":\"raw\nline\"}"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) assert(json_flat_parse(invalid[i], NULL, NULL) < 0);
puts("PASS profile Unicode reader:UTF-8,surrogate pair,control,following policy field;invalid Unicode/NUL/raw control refused");
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
const char* prefix = "SELECT userinfo,update_sig,update_ts";
int fail = fail_read && st && !strncmp(sqlite3_sql(st), prefix, strlen(prefix));
if (fail) { fail_read = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "profile test: injected read finalize failure"); }
if (st && take(F_FINALIZE, sqlite3_sql(st), 0)) fail = 1;
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
static void tags_reader_failure(struct UTUN_INSTANCE* inst) {
target = "SELECT adm_tags,adm_tags_sig"; fault = F_FINALIZE;
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id);
if (edit) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "tags test: load returned editor after failed finalize pending=%d", fault);
assert(!edit && fault == F_NONE); target = NULL;
edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit); chat_member_tags_free(edit);
puts("PASS tags reader:failed finalize never publishes an editor;retry succeeds");
}
static void tags_refused(struct UTUN_INSTANCE* inst, struct chat_member_tags* edit) {
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
char version[16]; snprintf(version, sizeof(version), "%s", chat_member_tags_get(edit, "ver"));
assert(chat_member_tags_commit(inst, edit) < 0 && fault == F_NONE && !fail_sign && sqlite3_get_autocommit(inst->topo_sqlite_db));
assert(!strcmp(version, chat_member_tags_get(edit, "ver")));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
}
static void tags_faults(struct UTUN_INSTANCE* inst) {
char owner[128]; snprintf(owner, sizeof(owner), "UPDATE \"peers_%s\" SET adm_tags=", channel);
const char* queries[] = {"SELECT adm_tags,adm_tags_sig", "SELECT ed25519_privkey FROM channels", owner,
"SELECT join_sig,join_ts,update_ts", "SELECT json_valid(?1)", "SELECT count(*),count(DISTINCT key)", "SELECT key,value FROM json_each"};
unsigned cases = 0;
for (size_t q = 0; q < sizeof(queries) / sizeof(queries[0]); q++) {
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES && q != 2) continue;
for (int index = 1; index <= (point == F_BIND && q == 2 ? 5 : 1); index++) {
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
char value[32]; snprintf(value, sizeof(value), "%u", ++sequence);
assert(!chat_member_tags_set(edit, "owner_test", value)); target = queries[q]; fault = point; index_number = index;
tags_refused(inst, edit); assert(!strcmp(value, chat_member_tags_get(edit, "owner_test")));
unsigned count = events; assert(!chat_member_tags_commit(inst, edit) && events == count + 1);
chat_member_tags_free(edit); cases++;
}
}
}
const char* commands[] = {"BEGIN IMMEDIATE /* tags */", "SAVEPOINT member_record", "RELEASE member_record", "COMMIT /* tags */"};
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
char value[32]; snprintf(value, sizeof(value), "%u", ++sequence); assert(!chat_member_tags_set(edit, "owner_test", value));
target = commands[i]; fault = F_EXEC; tags_refused(inst, edit);
unsigned count = events; assert(!chat_member_tags_commit(inst, edit) && events == count + 1); chat_member_tags_free(edit); cases++;
}
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
assert(!chat_member_tags_set(edit, "owner_test", "sign retry")); fail_sign = 1; tags_refused(inst, edit);
assert(!chat_member_tags_commit(inst, edit));
assert(!chat_member_tags_set(edit, "owner_test", "commit hook retry")); unsigned commits = 0;
sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits); tags_refused(inst, edit);
sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL); assert(commits == 1 && !chat_member_tags_commit(inst, edit));
assert(!chat_member_tags_set(edit, "owner_test", "nested rollback retry"));
target = owner; fault = F_FINALIZE; fail_member_rollback = 1; tags_refused(inst, edit);
assert(member_rollback_failed && !releases_after_failure); member_rollback_failed = 0;
assert(!chat_member_tags_commit(inst, edit)); chat_member_tags_free(edit); target = NULL;
printf("PASS tags writer:%u SQL faults/retries,sign/veto/nested rollback;editor,data,nodes,Merkle,events preserved\n", cases);
}
static void tags_patch(struct UTUN_INSTANCE* inst, const char* json) {
struct chat_member_tags_req* req = u_calloc(1, sizeof(*req)); assert(req);
req->inst = inst; strcpy(req->ch_id, channel); req->node_id = inst->node_id;
assert(strlen(json) < sizeof(req->json)); strcpy(req->json, json); chat_member_tags_commit_trampoline(req);
}
static void tags_allocation_faults(struct UTUN_INSTANCE* inst) {
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
assert(!chat_member_tags_set(edit, "owner_test", "memory-00000000"));
tag_alloc_seen = 0; tag_alloc_record = 1; assert(!chat_member_tags_commit(inst, edit)); tag_alloc_record = 0;
unsigned cases = tag_alloc_seen; assert(cases >= 8); chat_member_tags_free(edit);
for (unsigned i = 1; i <= cases; i++) {
edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
char value[32]; snprintf(value, sizeof(value), "memory-%08u", i); assert(!chat_member_tags_set(edit, "owner_test", value));
tag_alloc_fail = i; tags_refused(inst, edit); assert(!tag_alloc_fail && !strcmp(chat_member_tags_get(edit, "owner_test"), value));
assert(!chat_member_tags_commit(inst, edit)); chat_member_tags_free(edit);
}
printf("PASS tags memory:%u allocation faults/retries through snapshot,JSON growth,and prepared editor\n", cases);
}
static void tags_semantics(struct UTUN_INSTANCE* inst) {
struct chat_member_tags* first = chat_member_tags_load(inst, channel, inst->node_id);
struct chat_member_tags* stale = chat_member_tags_load(inst, channel, inst->node_id); assert(first && stale);
assert(!chat_member_tags_set(first, "owner_test", "first") && !chat_member_tags_commit(inst, first));
assert(!chat_member_tags_set(stale, "owner_test", "stale"));
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
assert(chat_member_tags_commit(inst, stale) == -2 && !strcmp(chat_member_tags_get(stale, "owner_test"), "stale"));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count); chat_member_tags_free(stale);
unsigned commits = 0; sqlite3_commit_hook(inst->topo_sqlite_db, veto, &commits);
assert(!chat_member_tags_commit(inst, first) && !commits && events == count); sqlite3_commit_hook(inst->topo_sqlite_db, NULL, NULL);
const char* bad[] = {"{\"x\":\"a\",\"x\":\"b\"}", "{\"x\":1}", "{\"x\":\"a\\u0000b\"}",
"{\"x\":\"y\",}", "{\"x\":\"y\"}tail", "{\"admin\":\"bogus\"}", "{\"ver\":\"99999999\"}"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
assert(chat_member_tags_replace(first, bad[i]) < 0 && !strcmp(chat_member_tags_get(first, "owner_test"), "first"));
}
assert(chat_member_tags_set(first, "ver", "0") < 0 && chat_member_tags_set(first, "", "x") < 0);
assert(!chat_member_tags_set(first, "owner_test", "control\x01"));
assert(!chat_member_tags_set(first, "admin", "yes") && !chat_member_tags_set(first, "moder", "yes"));
assert(!chat_member_tags_commit(inst, first));
char version[16]; snprintf(version, sizeof(version), "%s", chat_member_tags_get(first, "ver"));
assert(!chat_member_tags_set(first, "admin", "no")); target = "COMMIT /* tags */"; fault = F_EXEC; tags_refused(inst, first);
assert(!chat_member_tags_commit(inst, first) && !strcmp(chat_member_tags_get(first, "admin"), "no"));
assert(atoi(chat_member_tags_get(first, "ver")) == atoi(version) + 1);
assert(!chat_member_tags_set(first, "admin", "yes") && !chat_member_tags_commit(inst, first));
chat_member_tags_free(first);
char sql[256]; snprintf(sql, sizeof(sql), "SELECT json_valid(adm_tags),json_extract(adm_tags,'$.admin') FROM peers_%s WHERE node_id=?", channel);
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
assert(sqlite3_column_int(st, 0) == 1); const char* history = (const char*)sqlite3_column_text(st, 1);
unsigned enabled = 0, disabled = 0; for (const char* p = history; *p; p++) { enabled += *p == 'e'; disabled += *p == 'd'; }
assert(enabled == 2 && disabled == 1 && sqlite3_finalize(st) == SQLITE_OK);
tags_patch(inst, "{\"admin\":\"no\"}"); first = chat_member_tags_load(inst, channel, inst->node_id); assert(first);
assert(!strcmp(chat_member_tags_get(first, "admin"), "no") && !strcmp(chat_member_tags_get(first, "moder"), "yes"));
assert(!strcmp(chat_member_tags_get(first, "owner_test"), "control\x01"));
char patch[64]; snprintf(patch, sizeof(patch), "{\"ver\":\"%d\",\"admin\":\"yes\"}", atoi(chat_member_tags_get(first, "ver")) - 1);
snapshot(reader, before); count = events; tags_patch(inst, patch); tags_patch(inst, "{\"admin\":\"yes\",}");
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
assert(sqlite3_exec(inst->topo_sqlite_db, "BEGIN;INSERT INTO ui_state VALUES('tags-foreign','1')", NULL, NULL, NULL) == SQLITE_OK);
assert(chat_member_tags_commit(inst, first) < 0 && !sqlite3_get_autocommit(inst->topo_sqlite_db));
assert(sqlite3_exec(inst->topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); chat_member_tags_free(first); target = NULL;
const char* versions[] = {"0", "-1", "+1", "01", "2147483648", "1junk", "", "999999999999999999999999"};
for (size_t i = 0; i < sizeof(versions) / sizeof(versions[0]); i++) {
char json[128]; snprintf(json, sizeof(json), "{\"ver\":\"%s\"}", versions[i]);
assert(member_sync_owner_version(inst->topo_sqlite_db, json) < 0);
}
assert(member_sync_owner_version(inst->topo_sqlite_db, "{\"ver\":\"2147483647\"}") == INT_MAX);
puts("PASS tags:stale editor,no-op,repeated edits,history retry,control JSON,GUI patch,foreign txn,canonical version bounds");
}
static void tags_long_history(struct UTUN_INSTANCE* inst) {
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
int version = atoi(chat_member_tags_get(edit, "ver")) + 1; chat_member_tags_free(edit);
char history[400]; size_t used = 0;
for (unsigned i = 0; i < 160; i++) { history[used++] = 'e'; history[used++] = '1'; }
memcpy(history + used, "d2", 3);
char json[512]; snprintf(json, sizeof(json), "{\"ver\":\"%d\",\"admin\":\"%s\"}", version, history);
uint8_t key[32], body[MS_ADM_TAGS_MAX + 8], signature[64], display[CHAT_MEMBER_DISPLAY_SIZE];
assert(!topo_node_sqlite_channel_get_priv(inst->topo_sqlite_db, channel, key));
int bytes = member_sync_build_owner_msg(inst->node_id, json, body, sizeof(body));
assert(bytes > 0 && sc_ed25519_sign(key, body, (size_t)bytes, signature) == SC_OK); OPENSSL_cleanse(key, sizeof(key));
int changes = member_sync_put(inst, channel, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
NULL, 0, NULL, 0, NULL, json, signature, 0, 0, NULL); assert(changes >= 0 && (changes & MS_APPLY_CHANGED));
assert(!chat_core_get_single_member(inst, channel, inst->node_id, display) && !(display[10] & CHAT_MEMBER_FLAG_ADMIN));
edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit && !strcmp(chat_member_tags_get(edit, "admin"), "no"));
assert(!chat_member_tags_set(edit, "admin", "yes") && !chat_member_tags_commit(inst, edit)); chat_member_tags_free(edit);
assert(!chat_core_get_single_member(inst, channel, inst->node_id, display) && (display[10] & CHAT_MEMBER_FLAG_ADMIN));
char sql[128]; snprintf(sql, sizeof(sql), "SELECT json_extract(adm_tags,'$.admin') FROM peers_%s WHERE node_id=?", channel);
sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW);
const char* saved = (const char*)sqlite3_column_text(st, 0); assert(!strncmp(saved, history, strlen(history)) && saved[strlen(history)] == 'e');
assert(sqlite3_finalize(st) == SQLITE_OK);
puts("PASS tags:role history beyond256 bytes preserves the final action in editor/display and appends without dropping history");
}
static void tags_callback_deletion(struct UTUN_INSTANCE* inst) {
char parent[32]; strcpy(parent, channel); uint64_t child = chat_core_create_channel_auto(inst, "delete from tags event"); assert(child);
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)child);
struct chat_member_tags* edit = chat_member_tags_load(inst, channel, inst->node_id); assert(edit);
assert(!chat_member_tags_set(edit, "test", "delete")); delete_on_event = 1;
assert(!chat_member_tags_commit(inst, edit) && !delete_on_event && !topo_groups_find(inst->topo_groups, child));
chat_member_tags_free(edit); strcpy(channel, parent);
puts("PASS tags:channel deletion from committed notification keeps editor ownership valid");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); size_t baseline = u_get_allocated_count();
unicode();
utun_instance_set_tun_init_enabled(0); struct UASYNC* ua = uasync_create(); assert(ua);
char directory[] = "/tmp/utun-profile-XXXXXX"; assert(mkdtemp(directory)); char config[512], database[512];
snprintf(config, sizeof(config), "[global]\nmy_node_name=profile test\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", directory);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
uint64_t group = chat_core_create_channel_auto(inst, "profile"); assert(group);
snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group);
active = inst; snprintf(database, sizeof(database), "%s/chats.db", directory);
assert(sqlite3_open_v2(database, &reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
uint64_t joined; assert(!topo_node_sqlite_member_get_join(inst->topo_sqlite_db, channel, inst->node_id, original_join, &joined));
char profile[MS_USERINFO_MAX + 1], previous[MS_USERINFO_MAX + 1]; uint8_t signature[64], old_signature[64];
memset(profile, 0xa5, sizeof(profile)); memcpy(previous, profile, sizeof(profile));
memset(signature, 0xa5, sizeof(signature)); memcpy(old_signature, signature, sizeof(signature)); uint64_t timestamp = 123;
fail_read = 1; int rc = chat_core_get_my_profile(inst, channel, profile, sizeof(profile), signature, &timestamp);
if (rc >= 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "profile test: failed finalize rc=%d pending=%d", rc, fail_read);
assert(rc < 0 && !fail_read && timestamp == 123 && !memcmp(profile, previous, sizeof(profile)) && !memcmp(signature, old_signature, 64));
assert(!chat_core_get_my_profile(inst, channel, profile, sizeof(profile), signature, &timestamp) && timestamp > 0);
chat_event_set_handler(inst, event); reader_faults(inst); writer_faults(inst); damaged_profile(inst); semantic_profile(inst);
owner_faults(inst); avatar(inst); tags_reader_failure(inst); tags_faults(inst); tags_allocation_faults(inst);
tags_semantics(inst); tags_long_history(inst); tags_callback_deletion(inst);
registration_failure(inst, ua); callback_deletion(inst);
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
target = "COMMIT /* profile */"; fault = F_EXEC; fail_rollback = 1; inst->running = 1;
assert(update(inst) < 0 && fault == F_NONE && !fail_rollback && !inst->running && !sqlite3_get_autocommit(inst->topo_sqlite_db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
utun_instance_destroy(inst); active = NULL; snapshot(reader, after); assert(!memcmp(before, after, 32)); assert(sqlite3_close(reader) == SQLITE_OK);
uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
puts("PASS profile complete read and retry"); return 0;
}
+91
View File
@@ -0,0 +1,91 @@
/* Чистое чтение настроек, независимость копий и различие validation/apply-отказов. */
#include <assert.h>
#include <string.h>
#include "../src/chat/chat_setting.h"
#include "../src/utun_instance.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
static int fail_apply;
static unsigned applied;
extern int __real_chat_local_apply_settings(struct UTUN_INSTANCE*);
int __wrap_chat_local_apply_settings(struct UTUN_INSTANCE* inst) {
applied++;
if (fail_apply) { fail_apply = 0; return -1; }
return __real_chat_local_apply_settings(inst);
}
static void test_reads(void) {
struct chat_setting_state state = {0};
assert(!chat_setting_get_state(&state, "stream_subblocks"));
assert(chat_setting_get_int_state(&state, "stream_subblocks", 77) == 77);
assert(!strcmp(chat_setting_get_state(&state, "call_video_bitrate_range_kbps"), "300:4000"));
assert(!chat_setting_set_state(&state, "stream_subblocks", "8"));
struct chat_setting_state before = state;
const char* blocks = chat_setting_get_state(&state, "stream_subblocks");
const char* bitrate = chat_setting_get_state(&state, "radio_opus_bitrate");
assert(!strcmp(blocks, "8") && !strcmp(bitrate, "32") && blocks != bitrate);
assert(!memcmp(&before, &state, sizeof(state)));
assert(!chat_setting_get_state(&state, NULL) && !chat_setting_get_state(&state, "unknown"));
assert(chat_setting_get_int_state(&state, NULL, 77) == 77);
assert(chat_setting_get_int_state(&state, "member_files_path", 77) == 77);
assert(!chat_setting_get_state(NULL, "stream_subblocks") && chat_setting_get_int_state(NULL, NULL, 77) == 77);
assert(!chat_setting_set_state(&state, "radio_opus_bitrate", "48") && !strcmp(blocks, "8") && !strcmp(bitrate, "48"));
struct chat_setting_state copy = state;
assert(!chat_setting_set_state(&copy, "stream_subblocks", "12"));
assert(!strcmp(blocks, "8") && !strcmp(chat_setting_get_state(&copy, "stream_subblocks"), "12"));
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS settings reads: const state unchanged, distinct stable values, NULL/type defaults, independent copied state");
}
static void test_validation(void) {
const char* names[] = {"unknown", "stream_subblocks", "stream_subblocks", "stream_subblocks", "stream_subblocks",
"stream_subblocks", "stream_subblocks", "local_invites_enabled", "call_video_bitrate_range_kbps", "", NULL};
const char* values[] = {"1", "0", "256", "", " ", "8x", "99999999999999999999999999999", "maybe", "4000:300", "1", "1"};
for (unsigned initialized = 0; initialized < 2; initialized++) {
struct chat_setting_state state = {0};
if (initialized) assert(!chat_setting_set_state(&state, "stream_subblocks", "9"));
const struct chat_setting_state before = state;
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
assert(chat_setting_set_state(&state, names[i], values[i]) == (i == 0 ? -1 : -2));
assert(!memcmp(&before, &state, sizeof(state)));
}
assert(chat_setting_set_state(&state, "stream_subblocks", NULL) == -2);
assert(!memcmp(&before, &state, sizeof(state)));
char oversized[257]; memset(oversized, 'a', sizeof(oversized)); oversized[256] = 0;
assert(chat_setting_set_state(&state, "member_files_path", oversized) == -2);
assert(!memcmp(&before, &state, sizeof(state)));
}
struct chat_setting_state state = {0};
const char* booleans[] = {"0", "no", "false", "1", "yes", "true"};
for (size_t i = 0; i < sizeof(booleans) / sizeof(booleans[0]); i++) {
assert(!chat_setting_set_state(&state, "local_invites_enabled", booleans[i]));
assert(chat_setting_get_int_state(&state, "local_invites_enabled", 77) == (i >= 3));
}
char maximum[256]; memset(maximum, 'b', sizeof(maximum)); maximum[255] = 0;
assert(!chat_setting_set_state(&state, "member_files_path", maximum));
assert(!strcmp(chat_setting_get_state(&state, "member_files_path"), maximum));
assert(!chat_setting_set_state(&state, "member_files_path", ""));
assert(!strcmp(chat_setting_get_state(&state, "member_files_path"), ""));
assert(!chat_setting_set_state(&state, "radio_level_on_dbfs", "-90"));
assert(!strcmp(chat_setting_get_state(&state, "radio_level_on_dbfs"), "-90"));
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS settings validation: invalid values preserve zero/initialized state, bool aliases, signed range,255-byte path and clearing");
}
static void test_runtime_apply(void) {
struct UTUN_INSTANCE inst; memset(&inst, 0, sizeof(inst));
fail_apply = 1;
assert(chat_setting_set(&inst, "local_broadcasts_enabled", "0") == -3 && applied == 1 && !fail_apply);
assert(chat_setting_get_int(&inst, "local_broadcasts_enabled", 77) == 0);
assert(!chat_setting_set(&inst, "local_broadcasts_enabled", "0") && applied == 2);
assert(chat_setting_set(&inst, "local_broadcasts_enabled", "invalid") == -2 && applied == 2);
assert(!chat_setting_set(&inst, "local_invites_enabled", "1") && applied == 3);
assert(chat_setting_set(NULL, "local_invites_enabled", "1") == -2);
assert(!chat_setting_get(NULL, "stream_subblocks") && chat_setting_get_int(NULL, NULL, 77) == 77);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS settings apply: desired value survives LAN failure, distinct error, identical retry reapplies, validation does not apply");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); size_t baseline = u_get_allocated_count();
test_reads(); test_validation(); test_runtime_apply();
assert(u_get_allocated_count() == baseline); return 0;
}
+137 -1
View File
@@ -42,6 +42,9 @@ struct media_test {
unsigned phase, ticks, heartbeats, hold, forced_cleanup;
unsigned grant_bind_faults, grant_rollbacks, result_acks; /* Linux: отказ записи GRANT и настоящий повтор до расшифровки. */
unsigned decision_failures, decision_events; /* Linux: два отказа автора, restart и одно событие после commit. */
unsigned request_rejections, request_restarts, request_sends, request_acks, request_events;
unsigned observation_faults, observation_events, observation_recovered;
int request_external; /* Linux: транзакция фикстуры вокруг первого настоящего REQUEST от B. */
int grant_state, grant_outbox; /* Снимок получателя до единственного отказа bind. */
int failed;
void* timer;
@@ -63,6 +66,72 @@ static void failure(struct media_test* t, const char* reason) {
#ifdef __linux__
static struct media_test* active_test;
static timeout_callback_t backfill_cb;
static etcp_recv_fn attachment_receive;
static void request_restart(void* arg);
/* История уже committed даже при отказе локального индекса; сообщение не должно пропасть из событий UI. */
static void observation_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int bytes) {
(void)data; (void)bytes;
struct media_test* t = active_test;
if (type != CHAT_EVT_MSG_RECEIVED || !t || inst != t->nodes[B]) return;
sqlite3* reader = NULL; sqlite3_stmt* st = NULL;
int rc = sqlite3_open_v2(sqlite3_db_filename(inst->topo_sqlite_db, "main"), &reader, SQLITE_OPEN_READONLY, NULL);
if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(reader, "SELECT (SELECT count(*) FROM msg_" CHANNEL
" WHERE json_extract(data,'$.ct')='" ENCRYPTED_ATTACHMENT_TYPE "'),(SELECT count(*) FROM attachment_keys)", -1, &st, NULL);
int saved = rc == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) == 1 && sqlite3_column_int(st, 1) == 0;
if (sqlite3_finalize(st) != SQLITE_OK) saved = 0;
if (sqlite3_close(reader) != SQLITE_OK) saved = 0;
if (!saved || t->observation_faults != 1 || !sqlite3_get_autocommit(inst->topo_sqlite_db) ||
CC(inst)->media_channel_backfill_pending != 1 || !CC(inst)->media_channel_backfill_timer)
failure(t, "committed message notification lost or index retry missing after SQL failure");
t->observation_events++;
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS observation failure: reader history=1 index=0 before UI event, bounded backfill queued");
}
static int author_request_count(struct media_test* t) {
char query[256];
snprintf(query, sizeof(query), "SELECT count(*) FROM attachment_key_requests WHERE channel='" CHANNEL
"' AND peer=%lld AND incoming=1", (long long)(sqlite3_int64)t->ids[B]);
return value(t->nodes[A], query);
}
static void rejected_request_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int bytes) {
(void)inst; (void)data; (void)bytes;
if (type == CHAT_EVT_ATTACHMENT_KEYS_CHANGED) {
active_test->request_events++; failure(active_test, "REQUEST event inside external transaction");
}
}
/* Router уже проверил реальный E2E пакет. Внешняя транзакция открывается до входа в обработчик сервиса. */
static void receive_with_transaction(struct ETCP_CONN* conn, struct ll_entry* entry) {
struct media_test* t = active_test; uint64_t source = 0;
if (entry->len >= ROUTER_SVC_PAYLOAD_OFF + 85) memcpy(&source, entry->dgram + ROUTER_SVC_SRC_OFF, 8);
if (!t || conn->instance != t->nodes[A] || t->request_rejections || source != t->ids[B] ||
entry->len != ROUTER_SVC_PAYLOAD_OFF + 85 || entry->dgram[ROUTER_SVC_PAYLOAD_OFF + 4] != 1) {
attachment_receive(conn, entry); return;
}
struct UTUN_INSTANCE* a = t->nodes[A]; sqlite3* db = a->topo_sqlite_db;
int rc = sqlite3_exec(db, "BEGIN IMMEDIATE", NULL, NULL, NULL);
if (rc != SQLITE_OK) { failure(t, "cannot open REQUEST outer transaction"); attachment_receive(conn, entry); return; }
rc = sqlite3_exec(db, "UPDATE attachment_keys SET error=7 WHERE channel='" CHANNEL "'", NULL, NULL, NULL);
chat_event_handler_fn previous = a->chat_event_handler;
chat_event_set_handler(a, rejected_request_event); t->request_external = 1; t->request_rejections++;
attachment_receive(conn, entry); /* Освобождает entry; фикстура больше не обращается к нему. */
int preserved = rc == SQLITE_OK && !t->failed && !sqlite3_get_autocommit(db) && !author_request_count(t) &&
value(a, "SELECT error FROM attachment_keys") == 7 && !t->request_acks && !t->request_events;
rc = sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL);
t->request_external = 0; chat_event_set_handler(a, previous);
if (!preserved || rc != SQLITE_OK || !sqlite3_get_autocommit(db)) { failure(t, "REQUEST changed outer transaction or sent ACK"); return; }
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS REQUEST rejection: external marker preserved, no author row/ACK/event, owner rollback completed");
if (!uasync_set_timeout(t->ua, 100, t, request_restart, "restart after rejected REQUEST"))
failure(t, "cannot schedule REQUEST author restart");
}
extern int __real_etcp_router_bind(struct UTUN_INSTANCE*, uint8_t, etcp_recv_fn);
int __wrap_etcp_router_bind(struct UTUN_INSTANCE* inst, uint8_t service, etcp_recv_fn callback) {
if (service == ETCP_RT_ID_ATTACHMENT_KEYS) { attachment_receive = callback; callback = receive_with_transaction; }
return __real_etcp_router_bind(inst, service, callback);
}
/* Авторское событие должно видеть уже сохранённые решение и GRANT через независимый reader. */
static void decision_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int bytes) {
@@ -133,6 +202,13 @@ static void verify_grant_rollback(void* arg) {
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* data, int bytes, void (*destroy)(void*)) {
struct media_test* t = active_test;
const char* observation = "INSERT INTO attachment_keys(channel,crypto_id,message_id,author";
if (t && !t->observation_faults && t->nodes[B] && index == 5 && sqlite3_db_handle(st) == t->nodes[B]->topo_sqlite_db &&
!strncmp(sqlite3_sql(st), observation, strlen(observation))) {
t->observation_faults++;
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "encrypted attachment fixture: one observation hash bind NOMEM bytes=%d", bytes);
return SQLITE_NOMEM;
}
if (t && !t->grant_bind_faults && t->nodes[B] && index == 1 && sqlite3_db_handle(st) == t->nodes[B]->topo_sqlite_db &&
strstr(sqlite3_sql(st), "UPDATE attachment_keys SET key=?,error=0 WHERE channel=? AND message_id=? AND author=?")) {
t->grant_bind_faults++;
@@ -149,6 +225,43 @@ int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* data, int
extern int __real_etcp_router_conn_send(struct ETCP_ROUTER_CONN*, const uint8_t*, size_t, int);
int __wrap_etcp_router_conn_send(struct ETCP_ROUTER_CONN* conn, const uint8_t* data, size_t length, int mode) {
struct media_test* t = active_test;
if (t && conn->svc_id == ETCP_RT_ID_ATTACHMENT_KEYS && length == 85 && !memcmp(data, "AK01", 4)) {
if (conn->inst == t->nodes[B] && conn->remote_node_id == t->ids[A] && data[4] == 1) {
t->request_sends++;
if (!t->observation_recovered) {
sqlite3* reader = NULL; sqlite3_stmt* st = NULL;
int rc = sqlite3_open_v2(sqlite3_db_filename(conn->inst->topo_sqlite_db, "main"), &reader, SQLITE_OPEN_READONLY, NULL);
if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(reader, "SELECT crypto_id,cipher_hash FROM attachment_keys"
" WHERE channel='" CHANNEL "' AND message_id=? AND key IS NULL AND downloaded=1 AND wanted=1", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, data + 5, 32, SQLITE_STATIC);
int saved = rc == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 16 &&
sqlite3_column_bytes(st, 1) == 32 && !memcmp(sqlite3_column_blob(st, 0), data + 37, 16) &&
!memcmp(sqlite3_column_blob(st, 1), data + 53, 32);
if (sqlite3_finalize(st) != SQLITE_OK) saved = 0;
if (sqlite3_close(reader) != SQLITE_OK) saved = 0;
if (!saved || t->observation_faults != 1 || t->observation_events != 1 || !sqlite3_get_autocommit(conn->inst->topo_sqlite_db))
failure(t, "REQUEST without recovered committed manifest index after SQL failure");
t->observation_recovered++;
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS observation recovery: independent reader identity/hash/downloaded/wanted committed before real REQUEST");
}
}
if (conn->inst == t->nodes[A] && conn->remote_node_id == t->ids[B] && data[4] == 2) {
sqlite3* reader = NULL; sqlite3_stmt* st = NULL;
int rc = sqlite3_open_v2(sqlite3_db_filename(conn->inst->topo_sqlite_db, "main"), &reader, SQLITE_OPEN_READONLY, NULL);
if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(reader, "SELECT state FROM attachment_key_requests"
" WHERE channel=? AND message_id=? AND peer=? AND incoming=1", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, CHANNEL, -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, data + 5, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)t->ids[B]);
int saved = rc == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) == 0;
if (sqlite3_finalize(st) != SQLITE_OK) saved = 0;
if (sqlite3_close(reader) != SQLITE_OK) saved = 0;
if (t->request_external || !saved || !sqlite3_get_autocommit(conn->inst->topo_sqlite_db))
failure(t, "REQUEST_ACK before committed request");
t->request_acks++;
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS REQUEST_ACK: readonly pending request visible before real ACK, sends=%u", t->request_sends);
}
}
if (t && t->nodes[B] && conn->inst == t->nodes[B] && conn->svc_id == ETCP_RT_ID_ATTACHMENT_KEYS &&
length == 85 && !memcmp(data, "AK01", 4) && data[4] == 5) {
sqlite3* reader = NULL; sqlite3_stmt* st = NULL;
@@ -263,9 +376,28 @@ static int start_node(struct media_test* t, int index) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "encrypted attachment fixture: node=%d port=%u retained for restart", index, t->ports[index]);
}
if (t->nodes[index] && index != S) test_etcp_set_peer(t->nodes[index], "to_s", t->nodes[S], "s1");
return t->nodes[index] ? start_with_recovery(t->nodes[index], db) : -1;
int rc = t->nodes[index] ? start_with_recovery(t->nodes[index], db) : -1;
#ifdef __linux__
if (!rc && index == B) chat_event_set_handler(t->nodes[index], observation_event);
#endif
return rc;
}
#ifdef __linux__
/* Отказ не удалил исходящую очередь B; автор перезапускается вне своего callback. */
static void request_restart(void* arg) {
struct media_test* t = arg;
if (author_request_count(t) || value(t->nodes[B], "SELECT state FROM attachment_key_requests WHERE incoming=0") != 0 ||
value(t->nodes[B], "SELECT count(*) FROM attachment_key_outbox WHERE command=1") != 1 || t->request_acks || t->request_events) {
failure(t, "rejected REQUEST lost recipient retry"); return;
}
utun_instance_destroy(t->nodes[A]); t->nodes[A] = NULL;
if (start_node(t, A)) { failure(t, "author restart after rejected REQUEST"); return; }
t->request_restarts++;
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS REQUEST retry retained: B state=0 REQUEST=1, author restarted; waiting for real resend");
}
#endif
/* Фикстура создаёт канал напрямую, поэтому сама снимает gate после реального Merkle round. */
static void members_ready(uint64_t peer, const char* channel, int result, void* arg) {
struct UTUN_INSTANCE* inst = arg;
@@ -538,6 +670,10 @@ int main(void) {
failure(&t, "GRANT SQL fault, rollback and real ACK after retry were not exercised");
if (!t.failed && (t.decision_failures != 2 || t.decision_events != 1))
failure(&t, "author decision failures, restart and committed event were not exercised");
if (!t.failed && (t.request_rejections != 1 || t.request_restarts != 1 || t.request_sends < 2 || !t.request_acks ||
t.request_events || t.request_external)) failure(&t, "REQUEST rejection, restart, real retry and committed ACK were not exercised");
if (!t.failed && (t.observation_faults != 1 || t.observation_events != 1 || t.observation_recovered != 1))
failure(&t, "observation SQL failure, committed message notification and real index recovery were not exercised");
#endif
for (int i = 0; i < NODES; i++) if (t.nodes[i]) utun_instance_destroy(t.nodes[i]);
uasync_destroy(t.ua, 0);
+13
View File
@@ -12,6 +12,7 @@ static struct UASYNC* ua;
static unsigned received_blocks;
static uint64_t old_version;
static int capture_blocks;
static int cancel_on_update;
static uint8_t group_private[32], group_public[32], group_x[32];
static void hex(const uint8_t* input, char* out) { for (unsigned i = 0; i < 32; i++) snprintf(out + i * 2, 3, "%02x", input[i]); }
@@ -25,6 +26,13 @@ static void capture(int level, const char* category, const char* message) {
struct group_avatar_record current; assert(!group_avatar_get(nodes[1], GROUP, &current));
assert(current.version == old_version); // Даже последний блок ещё не опубликован до проверки всей записи.
}
static void cancel_received_avatar(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int size) {
(void)data; (void)size;
if (type != CHAT_EVT_CHANNEL_UPDATED || !cancel_on_update) return;
assert(inst == nodes[1] && sqlite3_get_autocommit(inst->topo_sqlite_db));
cancel_on_update = 0; group_avatar_cancel_group(inst, GROUP);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "test group avatar: cancelled all peer tasks from committed receive event");
}
static void put_member(unsigned destination, unsigned member) {
uint8_t join[64], tree[64], message[512];
int n = member_sync_build_join_msg(group_x, group_public, nodes[member]->node_id,
@@ -163,6 +171,11 @@ int main(void) {
assert(sc_ed25519_sign(group_private, signed_bytes, ga_message(GROUP, &copy, signed_bytes), copy.signature) == SC_OK);
int accepted = ga_compare(copy.version, copy.hash, next.version, next.hash) > 0;
assert(group_avatar_apply(nodes[0], GROUP, &copy) == accepted);
// Событие вправе удалить peer-контекст: приём не обращается к нему после callback.
chat_event_set_handler(nodes[1], cancel_received_avatar); cancel_on_update = 1;
image[1] ^= 1; assert(!group_avatar_set(nodes[0], GROUP, image, 17));
assert(!group_avatar_get(nodes[0], GROUP, &next) && wait_record(1, next.version, next.hash) && !cancel_on_update);
chat_event_set_handler(nodes[1], NULL);
assert(!group_avatar_set(nodes[0], GROUP, NULL, 0)); assert(!group_avatar_get(nodes[0], GROUP, &next));
assert(wait_record(1, next.version, next.hash)); assert(!group_avatar_get(nodes[1], GROUP, &copy) && !copy.size);
assert(group_avatar_apply(nodes[1], GROUP, &first) == 0);
+279
View File
@@ -0,0 +1,279 @@
/* Аватарка: настоящий commit и подпись, отказ SQL не меняет запись и не публикует событие. */
#define _GNU_SOURCE
#include "../src/chat/group_avatar.c"
#include "../lib/debug_config.h"
#include <assert.h>
#include <stdlib.h>
#include <openssl/evp.h>
static int fail_hash;
static unsigned events;
static sqlite3* reader;
static uint64_t group_id;
enum fault { F_NONE, F_PREPARE, F_BIND, F_STEP, F_CHANGES, F_FINALIZE, F_EXEC };
static enum fault fault;
static const char* target;
static int bind_index, last_target, fail_rollback, fail_schema_rollback, releases_after_failed_rollback;
static unsigned image_number;
static int fail_sign;
extern int __real_sc_ed25519_sign(const uint8_t*, const uint8_t*, size_t, uint8_t*);
int __wrap_sc_ed25519_sign(const uint8_t* key, const uint8_t* data, size_t size, uint8_t* signature) {
if (fail_sign) { fail_sign = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "avatar test: signing failed"); return SC_ERR_CRYPTO; }
return __real_sc_ed25519_sign(key, data, size, signature);
}
static int matches(const char* sql) { return target && !strncmp(sql, target, strlen(target)); }
static int take(enum fault point, const char* sql, int index) {
if (fault != point || (sql && !matches(sql)) || (point == F_BIND && index != bind_index)) return 0;
fault = F_NONE; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "avatar test: SQL fault stage=%d bind=%d target=%s", point, index, target); return 1;
}
extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**);
int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*callback)(void*, int, char**, char**), void* arg, char** error) {
if (fail_rollback && !strcmp(sql, "ROLLBACK")) { fail_rollback = 0; return SQLITE_IOERR; }
if (fail_schema_rollback && !strcmp(sql, "ROLLBACK TO group_avatar_schema")) { fail_schema_rollback = 0; return SQLITE_IOERR; }
if (!fail_schema_rollback && releases_after_failed_rollback && !strcmp(sql, "RELEASE group_avatar_schema"))
releases_after_failed_rollback++;
if (take(F_EXEC, sql, 0)) return SQLITE_IOERR;
return __real_sqlite3_exec(db, sql, callback, arg, error);
}
extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**);
int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int bytes, sqlite3_stmt** st, const char** tail) {
if (take(F_PREPARE, sql, 0)) { *st = NULL; return SQLITE_NOMEM; }
return __real_sqlite3_prepare_v2(db, sql, bytes, st, tail);
}
extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*));
int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* value, int size, void (*destroy)(void*)) {
const char* update = "UPDATE channels SET avatar_version=";
if (fail_hash && index == 3 && !strncmp(sqlite3_sql(st), update, strlen(update))) {
fail_hash = 0; DEBUG_WARN(DEBUG_CATEGORY_CHAT, "avatar test: injected hash bind failure"); return SQLITE_NOMEM;
}
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_blob(st, index, value, size, destroy);
}
extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_int64(st, index, value);
}
extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*));
int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* value, int bytes, void (*destroy)(void*)) {
if (take(F_BIND, sqlite3_sql(st), index)) return SQLITE_NOMEM;
return __real_sqlite3_bind_text(st, index, value, bytes, destroy);
}
extern int __real_sqlite3_step(sqlite3_stmt*);
int __wrap_sqlite3_step(sqlite3_stmt* st) {
last_target = matches(sqlite3_sql(st)); if (take(F_STEP, sqlite3_sql(st), 0)) return SQLITE_IOERR;
return __real_sqlite3_step(st);
}
extern int __real_sqlite3_changes(sqlite3*);
int __wrap_sqlite3_changes(sqlite3* db) {
if (last_target && take(F_CHANGES, NULL, 0)) return 0;
return __real_sqlite3_changes(db);
}
extern int __real_sqlite3_finalize(sqlite3_stmt*);
int __wrap_sqlite3_finalize(sqlite3_stmt* st) {
int fail = st && take(F_FINALIZE, sqlite3_sql(st), 0);
int rc = __real_sqlite3_finalize(st); return fail ? SQLITE_IOERR : rc;
}
static void snapshot(sqlite3* db, uint8_t out[32]) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, "SELECT channel_id,avatar_version,avatar_data,avatar_hash,avatar_signature,"
"x25519_privkey,ed25519_privkey FROM channels ORDER BY channel_id", -1, &st, NULL) == SQLITE_OK);
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); assert(ctx && EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) == 1); int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) for (int i = 0; i < 7; i++) {
int type = sqlite3_column_type(st, i), bytes = sqlite3_column_bytes(st, i); const void* value = sqlite3_column_blob(st, i);
assert(!bytes || value);
assert(EVP_DigestUpdate(ctx, &type, sizeof(type)) == 1 && EVP_DigestUpdate(ctx, &bytes, sizeof(bytes)) == 1);
if (bytes) assert(EVP_DigestUpdate(ctx, value, (size_t)bytes) == 1);
}
assert(rc == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); unsigned length;
assert(EVP_DigestFinal_ex(ctx, out, &length) == 1 && length == 32); EVP_MD_CTX_free(ctx);
}
static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int size) {
if (type != CHAT_EVT_CHANNEL_UPDATED) return;
assert(size > 1 && size == data[0] + 1 && sqlite3_get_autocommit(CC(inst)->db));
struct group_avatar_record record; assert(!group_avatar_get(inst, group_id, &record) && record.version);
uint8_t writer[32], committed[32]; snapshot(CC(inst)->db, writer); snapshot(reader, committed);
assert(!memcmp(writer, committed, 32)); events++;
}
static int publish(struct UTUN_INSTANCE* inst) {
uint32_t image = ++image_number;
return group_avatar_set(inst, group_id, (const uint8_t*)&image, sizeof(image));
}
static void refused(struct UTUN_INSTANCE* inst) {
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
assert(publish(inst) < 0 && fault == F_NONE && sqlite3_get_autocommit(CC(inst)->db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
}
static void retry(struct UTUN_INSTANCE* inst) {
unsigned before = events; assert(!publish(inst) && events == before + 1);
}
static int veto(void* arg) { (*(unsigned*)arg)++; return 1; }
static void candidate(struct UTUN_INSTANCE* inst, struct group_avatar_record* r, uint64_t version) {
assert(!group_avatar_get(inst, group_id, r)); r->version = version ? version : r->version + 1;
r->size = 1; r->data[0] ^= 0x77; SHA256(r->data, r->size, r->hash);
uint8_t key[32], bytes[GROUP_AVATAR_MAX + 64]; char channel[32]; ga_channel(group_id, channel);
assert(!topo_node_sqlite_channel_get_priv(CC(inst)->db, channel, key));
int length = ga_message(group_id, r, bytes);
assert(sc_ed25519_sign(key, bytes, (size_t)length, r->signature) == SC_OK); OPENSSL_cleanse(key, sizeof(key));
}
static void sql_regressions(struct UTUN_INSTANCE* inst) {
const char* queries[] = {"SELECT adm_tags,source,adm_tags_sig,deleted", "SELECT ed25519_privkey FROM channels",
"SELECT avatar_version,avatar_data", "SELECT name, owner_node_id", "UPDATE channels SET avatar_version="};
unsigned cases = 0;
for (unsigned query = 0; query < 5; query++) {
target = queries[query];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES && query < 4) continue;
int bindings = point == F_BIND && query == 4 ? 5 : 1;
for (int bind = 1; bind <= bindings; bind++) {
bind_index = bind; fault = point; refused(inst); retry(inst); cases++;
}
}
}
const char* commands[] = {"BEGIN IMMEDIATE /* group avatar */", "COMMIT /* group avatar */"};
for (unsigned i = 0; i < 2; i++) { target = commands[i]; fault = F_EXEC; refused(inst); retry(inst); cases++; }
fail_sign = 1; refused(inst); assert(!fail_sign); retry(inst);
const char* triggers[] = {
"CREATE TEMP TRIGGER reject_avatar BEFORE UPDATE OF avatar_version ON channels BEGIN SELECT RAISE(ABORT,'avatar update'); END",
"CREATE TEMP TRIGGER reject_avatar BEFORE UPDATE OF avatar_version ON channels BEGIN SELECT RAISE(IGNORE); END"
};
for (unsigned i = 0; i < 2; i++) {
assert(sqlite3_exec(CC(inst)->db, triggers[i], NULL, NULL, NULL) == SQLITE_OK); refused(inst);
assert(sqlite3_exec(CC(inst)->db, "DROP TRIGGER reject_avatar", NULL, NULL, NULL) == SQLITE_OK); retry(inst);
}
unsigned commits = 0; sqlite3_commit_hook(CC(inst)->db, veto, &commits); refused(inst); assert(commits == 1);
sqlite3_commit_hook(CC(inst)->db, NULL, NULL); retry(inst);
struct group_avatar_record r; candidate(inst, &r, 0); uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
target = "UPDATE channels SET avatar_version="; fault = F_FINALIZE;
assert(group_avatar_apply(inst, group_id, &r) < 0 && fault == F_NONE);
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
assert(group_avatar_apply(inst, group_id, &r) == 1 && events == count + 1);
assert(group_avatar_apply(inst, group_id, &r) == 0 && events == count + 1);
assert(sqlite3_exec(CC(inst)->db, "BEGIN;INSERT INTO ui_state(key,value) VALUES('foreign-avatar','1')", NULL, NULL, NULL) == SQLITE_OK);
snapshot(reader, before); count = events;
assert(group_avatar_apply(inst, group_id, &r) < 0 && group_avatar_set(inst, group_id, r.data, r.size) < 0);
assert(!sqlite3_get_autocommit(CC(inst)->db)); snapshot(reader, after);
assert(!memcmp(before, after, 32) && count == events);
assert(sqlite3_exec(CC(inst)->db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
commits = 0; sqlite3_commit_hook(CC(inst)->db, veto, &commits);
assert(!group_avatar_set(inst, group_id, r.data, r.size) && events == count && !commits);
sqlite3_commit_hook(CC(inst)->db, NULL, NULL);
candidate(inst, &r, INT64_MAX); assert(group_avatar_apply(inst, group_id, &r) == 1);
count = events; assert(!group_avatar_set(inst, group_id, r.data, r.size) && events == count); refused(inst);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "PASS avatar SQL:%u faults/retries,signing,ABORT/IGNORE/veto,apply retry,foreign/no-op,max version; committed bytes/events preserved", cases);
}
static void reader_regressions(struct UTUN_INSTANCE* inst) {
struct group_avatar_record output, previous; memset(&previous, 0xa5, sizeof(previous));
const char* queries[] = {"SELECT avatar_version,avatar_data", "SELECT name, owner_node_id"};
unsigned cases = 0;
for (unsigned query = 0; query < 2; query++) {
target = queries[query];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
output = previous; bind_index = 1; fault = point;
assert(group_avatar_get(inst, group_id, &output) < 0 && fault == F_NONE && !memcmp(&output, &previous, sizeof(output)));
assert(!group_avatar_get(inst, group_id, &output)); cases++;
}
}
char channel[32]; ga_channel(group_id, channel); uint8_t x[32], ed[32], untouched[32]; memset(untouched, 0xa5, sizeof(untouched));
const char* keys[] = {"SELECT ed25519_privkey FROM channels", "SELECT x25519_privkey, ed25519_privkey FROM channels"};
for (unsigned pair = 0; pair < 2; pair++) {
target = keys[pair];
for (enum fault point = F_PREPARE; point <= F_FINALIZE; point++) {
if (point == F_CHANGES) continue;
memcpy(x, untouched, 32); memcpy(ed, untouched, 32); bind_index = 1; fault = point;
int rc = pair ? topo_node_sqlite_channel_get_privs(CC(inst)->db, channel, x, ed) :
topo_node_sqlite_channel_get_priv(CC(inst)->db, channel, ed);
assert(rc < 0 && fault == F_NONE && !memcmp(x, untouched, 32) && !memcmp(ed, untouched, 32));
assert(!topo_node_sqlite_channel_get_privs(CC(inst)->db, channel, x, ed)); cases++;
}
}
const char* corrupt[] = {"NULL", "zeroblob(31)", "zeroblob(33)", "printf('%032d',1)"};
for (unsigned i = 0; i < sizeof(corrupt) / sizeof(corrupt[0]); i++) {
char sql[256]; snprintf(sql, sizeof(sql), "BEGIN;UPDATE channels SET ed25519_privkey=%s WHERE channel_id='%s'", corrupt[i], channel);
assert(sqlite3_exec(CC(inst)->db, sql, NULL, NULL, NULL) == SQLITE_OK);
memcpy(x, untouched, 32); memcpy(ed, untouched, 32);
assert(topo_node_sqlite_channel_get_priv(CC(inst)->db, channel, ed) < 0 && !memcmp(ed, untouched, 32));
assert(topo_node_sqlite_channel_get_privs(CC(inst)->db, channel, x, ed) < 0 && !memcmp(ed, untouched, 32) && !memcmp(x, untouched, 32));
assert(sqlite3_exec(CC(inst)->db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK);
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "PASS avatar/key readers:%u SQL faults/retries,unchanged outputs,NULL/31/33/TEXT keys rejected", cases);
}
static int columns(sqlite3* db) {
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(db, "SELECT count(*) FROM pragma_table_info('channels')", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW); int count = sqlite3_column_int(st, 0); assert(sqlite3_finalize(st) == SQLITE_OK); return count;
}
static void schema_base(sqlite3* db) {
assert(sqlite3_exec(db, "CREATE TABLE channels(channel_id TEXT PRIMARY KEY,name TEXT,ed25519_privkey BLOB);"
"INSERT INTO channels VALUES('42','retained',zeroblob(32))", NULL, NULL, NULL) == SQLITE_OK);
}
static void schema_regressions(const char* directory) {
const char* queries[] = {"PRAGMA table_info(channels)", "PRAGMA table_info(channels)", "PRAGMA table_info(channels)",
"SAVEPOINT group_avatar_schema", "ALTER TABLE channels ADD COLUMN avatar_version", "ALTER TABLE channels ADD COLUMN avatar_data",
"ALTER TABLE channels ADD COLUMN avatar_hash", "ALTER TABLE channels ADD COLUMN avatar_signature", "RELEASE group_avatar_schema"};
const enum fault points[] = {F_PREPARE, F_STEP, F_FINALIZE, F_EXEC, F_EXEC, F_EXEC, F_EXEC, F_EXEC, F_EXEC};
for (unsigned i = 0; i < sizeof(points) / sizeof(points[0]); i++) {
sqlite3* db = NULL; assert(sqlite3_open(":memory:", &db) == SQLITE_OK); schema_base(db); target = queries[i]; fault = points[i];
assert(group_avatar_schema_init(db) < 0 && fault == F_NONE && sqlite3_get_autocommit(db) && columns(db) == 3);
assert(!group_avatar_schema_init(db) && columns(db) == 7 && !group_avatar_schema_init(db)); assert(sqlite3_close(db) == SQLITE_OK);
}
sqlite3 *db = NULL, *view = NULL; char path[512]; snprintf(path, sizeof(path), "%s/schema.db", directory);
assert(sqlite3_open(path, &db) == SQLITE_OK); schema_base(db);
assert(sqlite3_open_v2(path, &view, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
unsigned commits = 0; sqlite3_commit_hook(db, veto, &commits);
assert(group_avatar_schema_init(db) < 0 && commits == 1 && sqlite3_get_autocommit(db) && columns(view) == 3);
sqlite3_commit_hook(db, NULL, NULL);
target = "ALTER TABLE channels ADD COLUMN avatar_data"; fault = F_EXEC; fail_schema_rollback = 1; releases_after_failed_rollback = 1;
assert(group_avatar_schema_init(db) < 0 && fault == F_NONE && !fail_schema_rollback && !sqlite3_get_autocommit(db));
assert(releases_after_failed_rollback == 1 && columns(db) == 4 && columns(view) == 3); releases_after_failed_rollback = 0;
assert(sqlite3_close(db) == SQLITE_OK && columns(view) == 3 && sqlite3_close(view) == SQLITE_OK);
assert(sqlite3_open(path, &db) == SQLITE_OK && !group_avatar_schema_init(db) && columns(db) == 7 && sqlite3_close(db) == SQLITE_OK);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "PASS avatar schema:9 SQL faults/retries,commit veto,failed ROLLBACK TO never RELEASEs partial ALTER;owner close restores old schema");
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_CHAT, DEBUG_LEVEL_INFO);
size_t baseline = u_get_allocated_count(); utun_instance_set_tun_init_enabled(0);
char directory[] = "/tmp/utun-avatar-sql-XXXXXX"; assert(mkdtemp(directory));
char config[512], database[512]; snprintf(config, sizeof(config), "[global]\ndb_path=%s\n[ntp]\nenabled=no\n"
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", directory);
struct UASYNC* ua = uasync_create(); assert(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && !utun_core_start(inst) && !chat_service_start(inst));
group_id = chat_core_create_channel_auto(inst, "avatar SQL"); assert(group_id);
snprintf(database, sizeof(database), "%s/chats.db", directory);
assert(sqlite3_open_v2(database, &reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
chat_event_set_handler(inst, event);
const uint8_t image[] = {1, 2, 3}; fail_hash = 1;
int rc = group_avatar_set(inst, group_id, image, sizeof(image));
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "avatar test: hash bind failure result=%d events=%u fault_pending=%d", rc, events, fail_hash);
assert(rc < 0 && !events && !fail_hash);
struct group_avatar_record current; assert(!group_avatar_get(inst, group_id, &current) && !current.version);
assert(!group_avatar_set(inst, group_id, image, sizeof(image)) && events == 1);
reader_regressions(inst); sql_regressions(inst); schema_regressions(directory);
uint8_t before[32], after[32]; snapshot(reader, before); unsigned count = events;
target = "SELECT avatar_version,avatar_data"; fault = F_STEP; fail_rollback = 1; inst->running = 1;
assert(publish(inst) < 0 && fault == F_NONE && !fail_rollback && !inst->running && !sqlite3_get_autocommit(CC(inst)->db));
snapshot(reader, after); assert(!memcmp(before, after, 32) && events == count);
utun_instance_destroy(inst); snapshot(reader, after); assert(!memcmp(before, after, 32) && sqlite3_close(reader) == SQLITE_OK);
uasync_poll(ua, 0); uasync_destroy(ua, 0); assert(u_get_allocated_count() == baseline);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "PASS avatar SQL rollback and successful retry"); return 0;
}
+35 -1
View File
@@ -6,6 +6,39 @@
void test_member_purge(struct UASYNC* ua);
static unsigned apply_calls, cancelled_calls;
static int nested_apply;
static void cancelled_apply(const char* ns, uint64_t node, const uint8_t* x, uint64_t parent, const uint8_t* sig, void* arg) {
(void)ns; (void)node; (void)x; (void)parent; (void)sig; (void)arg; cancelled_calls++;
}
static void removing_apply(const char* ns, uint64_t node, const uint8_t* x, uint64_t parent, const uint8_t* sig, void* arg) {
struct UTUN_INSTANCE* inst = arg; apply_calls++; assert(!strcmp(ns, "1001"));
if (!nested_apply) { nested_apply = 1; _fire_apply_cbk(inst, ns, node, x, parent, sig); nested_apply = 0; }
member_sync_remove_apply_cbk(inst, removing_apply, inst); member_sync_remove_apply_cbk(inst, cancelled_apply, inst);
}
static void free_namespace(uint64_t node, const char* tags, const char* ns, void* arg) {
(void)node; (void)tags; assert(!strcmp(ns, "1001")); char** owned = arg; u_free(*owned); *owned = NULL;
}
static void check_namespace(uint64_t node, const char* tags, const char* ns, void* arg) {
(void)node; (void)tags; (void)arg; assert(!strcmp(ns, "1001"));
}
static void callback_lifetime(void) {
size_t baseline = u_get_allocated_count(); struct UTUN_INSTANCE inst = {0};
struct chat_core_ctx cc = {.inst = &inst}; inst.chat_core = &cc;
uint8_t key[32] = {1}, signature[64] = {1};
assert(!member_sync_add_apply_cbk(&inst, cancelled_apply, &inst));
assert(!member_sync_add_apply_cbk(&inst, removing_apply, &inst));
_fire_apply_cbk(&inst, "1001", 7, key, 0, signature);
assert(apply_calls == 2 && !cancelled_calls && !cc.apply_cbks && !cc.apply_dispatch_depth);
char* owned = u_strdup("1001"); assert(owned);
assert(!member_sync_add_props_cbk(&inst, check_namespace, &owned));
assert(!member_sync_add_props_cbk(&inst, free_namespace, &owned));
_fire_props_changed(&inst, 7, NULL, owned); assert(!owned);
member_sync_remove_props_cbk(&inst, check_namespace, &owned); member_sync_remove_props_cbk(&inst, free_namespace, &owned);
assert(!cc.props_cbks && !cc.props_dispatch_depth && u_get_allocated_count() == baseline);
puts("PASS: nested apply dispatch,self/other cancellation,and namespace owner freed during properties callback");
}
static void setup(struct UTUN_INSTANCE* inst, struct UASYNC* ua) {
memset(inst, 0, sizeof(*inst)); inst->ua = ua; inst->node_id = 1;
assert(sqlite3_open(":memory:", &inst->topo_sqlite_db) == SQLITE_OK);
@@ -76,7 +109,7 @@ static int test_snapshot_event_bounds(struct UASYNC* ua) {
}
if (!result) {
uint8_t* wire = NULL; int count = -1;
assert(chat_core_get_member_list(&inst, "1001", &wire, &count) == 0 && count == UINT16_MAX + 1);
assert(chat_core_get_member_list(&inst, "1001", &wire, &count) == -1 && !wire && !count);
u_free(wire); wire = NULL;
assert(sqlite3_exec(cc.db, "DELETE FROM peers_1001;"
"WITH n(id) AS (VALUES(1),(2),(-1),(-9223372036854775808)) "
@@ -278,6 +311,7 @@ static void test_owner_sizes(struct UASYNC* ua) {
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR);
callback_lifetime();
size_t baseline = u_get_allocated_count();
struct UASYNC* ua = uasync_create(); assert(ua);
if (test_snapshot_event_bounds(ua) < 0) { uasync_destroy(ua, 0); return 1; }
+6
View File
@@ -12,6 +12,9 @@ TCP JSON-RPC клиент для управления чатом uTun без GUI
--------
JSON line: запрос {"id":N,"cmd":"...","params"} → ответ {"id":N,"ok":true,"data":...}
При подписке (subscribe) сервер шлёт асинхронные события: {"event":"...",...}
Успешные poll/vote/react обычно возвращают снимок. Если изменение уже сохранено,
но чтение снимка отказало,data содержит ch,message_id,committed:true,snapshot_pending:true.
В этом случае повторяют только feedback <ch_id> <message_id>:повтор poll/react меняет состояние ещё раз.
Переменные окружения
--------------------
@@ -32,12 +35,15 @@ status
channels
Список всех каналов с метаданными: id, имя, owner, число мемберов,
число сообщений, сколько онлайн.
Уже сохранённый канал с отложенной подготовкой истории виден с msgs=0.
Пример: chatcli channels
members <ch_id>
Список участников канала с полным состоянием:
node_id, имя, online, connected, публичные ключи (x25519/ed25519 hex),
адреса (ip, port, proto, rtt).
proto: UDP, TCP или TCP+REALITY; deleted-участник показывается offline.
Строки возвращаются целиком с JSON escaping;ошибка SQL/размера не даёт частичный успешный список.
Пример: chatcli members 12345
messages <ch_id> [count]
@@ -520,9 +520,16 @@ class ChatViewModel : ViewModel() {
ReactionState(it.getInt("id"), it.getString("emoji"))
} }
} else if (result.optString("ch") == _currentChannel.value?.id) {
val state = parseFeedback(result)
feedbackCache[state.messageId] = state
_messages.update { rows -> rows.map { if (it.messageId == state.messageId) it.copy(feedback = state) else it } }
if (result.optBoolean("snapshot_pending")) {
val message = result.getString("message_id")
LogManager.addLog("WARN", "POLL", "command saved, requesting snapshot message=$message")
sendInteraction(JSONObject().put("cmd", "feedback").put("ch", result.getString("ch"))
.put("message_id", message), false)
} else {
val state = parseFeedback(result)
feedbackCache[state.messageId] = state
_messages.update { rows -> rows.map { if (it.messageId == state.messageId) it.copy(feedback = state) else it } }
}
}
} catch (e: Exception) { LogManager.addLog("ERROR", "POLL", "invalid result: ${e.message}"); _interactionFailed.value = true }
}
@@ -343,6 +343,10 @@ static int handle_chat_setting(int fd, int id, const char* json) {
int rc = chat_setting_set(inst, name, value);
if (rc == -1) { send_response(fd, id, NULL, "unknown setting"); return 0; }
if (rc == -2) { send_response(fd, id, NULL, "invalid value"); return 0; }
if (rc < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONTROL, "headless setting saved but runtime apply failed name=%s rc=%d", name, rc);
send_response(fd, id, NULL, "setting saved but runtime apply failed; retry setting"); return 0;
}
printf("control: chat_setting %s=%s\n", name, value);
send_response(fd, id, "{\"set\":true}", NULL);
return 0;
@@ -28,6 +28,7 @@
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include "../../../src/chat/member_sync.h"
#include "../../../src/media_async/encrypted_file.h"
#include "../../../src/chat/chat_event.h"
#include "../../../src/chat/chat_local.h"
@@ -797,7 +798,7 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = sqlite3_column_int(st, 10);
int storage_role = 0;
if (adm_tags) {
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) is_super = 1;
if (json_flat_get(adm_tags, "admin", buf, sizeof(buf)) == 0) {
const char* last = buf; for (const char* p = buf; *p; p++) if (*p == 'e' || *p == 'd') last = p;
@@ -1030,13 +1031,11 @@ static void member_tags_edit_trampoline(void* arg) {
u_free(arg);
return;
}
chat_member_tags_set(t, "admin", req->admin ? "yes" : "no");
chat_member_tags_set(t, "moder", req->moder ? "yes" : "no");
chat_member_tags_set(t, "supernode", req->supernode ? "yes" : "no");
chat_member_tags_set(t, "storage", req->storage ? "yes" : "no");
chat_member_tags_set(t, "verified", req->verified ? "yes" : "no");
chat_member_tags_set(t, "deleted", req->deleted ? "yes" : "no");
int rc = chat_member_tags_commit(req->inst, t);
const char* keys[] = {"admin", "moder", "supernode", "storage", "verified", "deleted"};
int values[] = {req->admin, req->moder, req->supernode, req->storage, req->verified, req->deleted};
int rc = 0;
for (size_t i = 0; !rc && i < sizeof(values) / sizeof(values[0]); i++) rc = chat_member_tags_set(t, keys[i], values[i] ? "yes" : "no");
if (!rc) rc = chat_member_tags_commit(req->inst, t);
bridge_log(rc == 0 ? BLEV_INFO : BLEV_ERROR,
"set_member_flags ch=%s nid=0x%016llx admin=%d super=%d moder=%d storage=%d verified=%d deleted=%d rc=%d",
req->ch_id, (unsigned long long)req->node_id, req->admin, req->supernode,
@@ -1178,7 +1177,8 @@ static void sound_setting_trampoline(void* arg) {
int utun_bridge_get_sound_on_message(const char* channel_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst || !chat_core_is_initialized(inst) || !channel_id || !channel_id[0]) return 1;
return chat_core_get_sound_on_message(inst, channel_id);
int on = chat_core_get_sound_on_message(inst, channel_id);
return on < 0 ? 1 : on; /* UI fallback после залогированной ошибки,в сеть он не отправляется. */
}
void utun_bridge_set_sound_on_message(const char* channel_id, int on) {
+5
View File
@@ -136,6 +136,11 @@ void MessageList::onFeedbackResult(const uint8_t* data, int len) {
return;
}
if (state.value("ch").toString() != m_currentChannelId) return;
if (state.value("snapshot_pending").toBool()) {
const QString id = state.value("message_id").toString();
GUI_WARN("feedback: command saved, requesting snapshot ch=%s message=%s", qPrintable(m_currentChannelId), qPrintable(id));
sendFeedback({{"cmd", "feedback"}, {"message_id", id}}, false); return;
}
applyFeedback(state);
}
+11 -5
View File
@@ -6,6 +6,7 @@
#include "../../lib/mem.h"
extern "C" {
#include "chat/chat_core.h"
#include "debug_config.h"
}
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -156,13 +157,16 @@ void SoundSettingsPage::applyAndSave() {
QByteArray volVal = QByteArray::number(e.slider->value());
for (int j = 0; j < 2; j++) {
const char* k = j == 0 ? onKey.toUtf8().constData() : volKey.toUtf8().constData();
const QByteArray key = (j == 0 ? onKey : volKey).toUtf8();
const char* k = key.constData();
const char* v = j == 0 ? onVal.constData() : volVal.constData();
size_t klen = strlen(k), vlen = strlen(v);
size_t total = klen + 1 + vlen + 1;
if (total > 256) continue;
if (total > sizeof(save_ui_state_arg::data)) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Sound settings: key/value too large bytes=%zu", total); continue;
}
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) continue;
if (!arg) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Sound settings: request allocation failed"); continue; }
arg->inst = gui_bridge_get_inst();
memcpy(arg->data, k, klen + 1);
memcpy(arg->data + klen + 1, v, vlen + 1);
@@ -178,9 +182,11 @@ void SoundSettingsPage::applyAndSave() {
for (int j = 0; j < 2; j++) {
size_t klen = strlen(keys[j]), vlen = vals[j].size();
size_t total = klen + 1 + vlen + 1;
if (total > 256) continue;
if (total > sizeof(save_ui_state_arg::data)) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Sound settings: global key/value too large bytes=%zu", total); continue;
}
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) continue;
if (!arg) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Sound settings: global request allocation failed"); continue; }
arg->inst = gui_bridge_get_inst();
memcpy(arg->data, keys[j], klen + 1);
memcpy(arg->data + klen + 1, vals[j].constData(), vlen + 1);
+15
View File
@@ -4,6 +4,7 @@
#include "../src/messagedelegate.h"
#include "../src/chatview.h"
#include "../src/inputbar.h"
#include "../src/soundsettingspage.h"
#include "../db/db_manager.h"
#include "../transport/utun_node.h"
#include "../transport/gui_bridge.h"
@@ -12,6 +13,8 @@
#include <QFile>
#include <QTextEdit>
#include <QMenu>
#include <QSlider>
#include <QCheckBox>
#include <QScopeGuard>
#include <future>
#include <memory>
@@ -108,6 +111,18 @@ private slots:
QTRY_COMPARE(failures.size(), 1); QCOMPARE(editor->toPlainText(), QString("Stale editor draft"));
QCOMPARE(indexFor(id).data(MsgTextRole).toString(), QString("Remote edit")); QCOMPARE(newMessages, 0);
QTest::keyClick(editor, Qt::Key_Escape); QCOMPARE(editor->toPlainText(), QString("Unsent draft"));
SoundSettingsPage sounds(&db); sounds.loadFromDb();
const auto checks = sounds.findChildren<QCheckBox*>(); const auto sliders = sounds.findChildren<QSlider*>();
QCOMPARE(checks.size(), 9); QCOMPARE(sliders.size(), 9);
for (int i = 0; i < 9; i++) { checks[i]->setChecked(i % 2 == 0); sliders[i]->setValue(20 + i); }
checks[0]->setChecked(false); sounds.applyAndSave();
QCOMPARE(inCore([](UTUN_INSTANCE*) { return 0; }), 0);
QCOMPARE(db.getUiStateInt("sound_enabled", -1), 0); QCOMPARE(db.getUiStateInt("sound_volume", -1), 20);
const char* events[] = {"msg_incoming", "msg_outgoing", "connect", "disconnect", "call_incoming", "call_connecting", "call_ringing", "radio_incoming"};
for (int i = 0; i < 8; i++) {
QCOMPARE(db.getUiStateInt(QString("sound_%1_on").arg(events[i]), -1), (i + 1) % 2 == 0 ? 1 : 0);
QCOMPARE(db.getUiStateInt(QString("sound_%1_vol").arg(events[i]), -1), 21 + i);
}
gui_bridge_set_msg_received_cb(nullptr);
}
};