340 lines
36 KiB
Markdown
340 lines
36 KiB
Markdown
# Зашифрованные вложения группового чата
|
||
|
||
В desktop chatgui после выбора файла или перетаскивания в чат рядом с его именем
|
||
появляется флажок «Зашифровать вложение». Он применяется к выбранному файлу,
|
||
изображению или видео. Удаление, замена файла и переход в другую беседу сбрасывают
|
||
флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки.
|
||
В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички.
|
||
|
||
Android показывает зашифрованное вложение отдельной карточкой со статусом запроса
|
||
ключа. После одобрения карточка обновляется на исходный файл, изображение, видео
|
||
или голосовое. В интерфейс передаётся только расшифрованный путь; контейнер `.uenc`
|
||
нельзя открыть или сохранить как готовое вложение. До расшифровки цитата показывает
|
||
название «Зашифрованное вложение», после — обычный preview исходного вложения.
|
||
Список чатов сохраняет общее название зашифрованного вложения вместо manifest.
|
||
|
||
Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую
|
||
подготовку. Ключ, UUID, автор, пути и метаданные записываются в локальную SQLite
|
||
собственной короткой транзакцией до передачи подготовленного файла worker.
|
||
Проверяются prepare, все bind, step, ровно одна добавленная строка, finalize и commit.
|
||
Внешняя транзакция отклоняется без изменений. До принятия ошибка возвращает `-1`:
|
||
файл остаётся у вызывающего, worker и событие отправки не создаются.
|
||
После commit возвращается `0`, файл принадлежит задаче и удаляется также при её отказе;
|
||
ненулевой request получает один `MSG_SUBMITTED`, в том числе синхронно при отказе запуска worker.
|
||
Исходный пользовательский файл сохраняется: общий frontend передаёт сюда собственную подготовленную копию.
|
||
|
||
Неудачный rollback с оставшейся открытой транзакцией отключает модуль вложений и
|
||
запрашивает остановку ядра. Desktop и Android учитывают запрос в своих poll-циклах;
|
||
владелец освобождает ядро и закрывает SQLite после возврата из poll. Текущий poll может
|
||
доставить другие callbacks до выхода. Политика общей БД остаётся WAL/synchronous=NORMAL:
|
||
завершённый commit до worker не является гарантией сохранности после отключения питания.
|
||
История дефекта и проверок — в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||
|
||
Готовый контейнер передаётся через обычные media_index/media_delivery. UUID транспортного медиа
|
||
и crypto_UUID имеют разные назначения: первый идентифицирует сетевые блоки,
|
||
второй — контейнер и ключ. Повтор доставки передаёт прежний контейнер.
|
||
|
||
Связь авторского ключа с `message_id` и SHA256 контейнера фиксируется собственной
|
||
транзакцией до публикации сообщения. Проверяются все SQL-этапы и одна строка;
|
||
точный повтор допустим, замена сообщения или hash запрещена. Внешняя транзакция
|
||
остаётся нетронутой, SQL-отказ сохраняет прежнюю пару. UUID-префикс принимает только
|
||
канонический base64url с правильными padding/завершающими битами и lowercase hex;
|
||
при отказе выходной UUID не изменяется.
|
||
|
||
## Подписанное сообщение и контейнер
|
||
|
||
В подписанном сообщении `ct=application/x-utun-encrypted`, базовое имя тела —
|
||
`<crypto_UUID:32hex>.uenc`. Остальные поля manifest описывают шифротекст:
|
||
размер, SHA256 всего контейнера, UUID и подписи блоков. Ключ, исходное имя,
|
||
MIME, waveform и остальные медиаметаданные в историю канала не записываются.
|
||
|
||
Контейнер:
|
||
|
||
```text
|
||
magic "UTENC001":8
|
||
crypto_UUID:16
|
||
plaintext_size:8BE
|
||
encrypted_metadata_size:2BE
|
||
SHA256(encrypted_file_body):32
|
||
encrypted_metadata:variable
|
||
encrypted_file_body:variable
|
||
```
|
||
|
||
Метаданные содержат MIME в 32 байтах и сериализованный `attachment_info`.
|
||
MIME — непустая NUL-строка длиной1..31 байт: ASCII33..126 без двойной кавычки
|
||
и обратной косой черты. Общий `encrypted_file_validate_type` применяется в send,
|
||
write и read; это проверка представления поля, без полного разбора MIME-грамматики.
|
||
Send отклоняет неверное значение до создания задачи/ключа/worker, write — до файловых
|
||
операций. Read проверяет расшифрованное аутентифицированное поле и при ошибке сохраняет
|
||
прежние info/type. Сырые неверные строки в диагностику не выводятся.
|
||
|
||
Каждая часть шифруется AES-256-CCM с тегом 16 байт и nonce 13 байт.
|
||
Nonce метаданных использует `part=UINT64_MAX`; файл — порции 32768 байт
|
||
с номерами от нуля. Общие примитивы находятся в `media_async/media_crypto`;
|
||
формат существующего DM при их выделении не менялся.
|
||
|
||
Получатель проверяет подписанный SHA256 полного контейнера, UUID, tag метаданных,
|
||
все tags файла, точный размер и hash тела. Результат пишется во временный файл
|
||
и публикуется после успешной проверки и durable rename. Подготовка файлов
|
||
и криптография выполняются в media_async worker.
|
||
|
||
`encrypted_file_write/read` возвращают закрытый проверенный `file_io_temp`,
|
||
не изменяя конечное назначение. Задача владеет им до publish/discard; запись идёт
|
||
в эксклюзивно созданный соседний файл с правами 0600 на POSIX. Одинаковые файлы,
|
||
включая `/./`, hardlink и конечную ссылку назначения, отклоняются до подготовки;
|
||
публикация повторно проверяет идентичность. При ошибке read не выдаёт новые метаданные.
|
||
|
||
Режим `FILE_IO_DURABLE` действует весь срок temp: flush/sync открытого файла/close,
|
||
затем rename и сохранение каталога. После успешного rename ошибка directory sync
|
||
не откатывает установленный результат; temp уже отсоединён от владельца.
|
||
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`.
|
||
Конечные файлы и имена других форматов этот проход сохраняет; активные temp другого
|
||
instance того же процесса остаются защищены общим реестром.
|
||
|
||
## Хранение и разрешения
|
||
|
||
Локальные таблицы не регистрируются в db_sync или Merkle:
|
||
|
||
- `attachment_keys`: ключ автора/получателя, связь с общим message_id,
|
||
crypto_UUID, hash и пути двух копий, расшифрованные метаданные и состояние.
|
||
- `attachment_key_requests`: один запрос на `(channel,message_id,peer)`,
|
||
направление, решение и дата создания.
|
||
- `attachment_key_outbox`: команда, адресат и время следующей попытки.
|
||
|
||
Шифротекст хранится в `media/<channel>/<crypto_UUID>.uenc`, локальная открытая
|
||
копия — в `decrypted/<channel>/<crypto_UUID>.file`. В media_files и объявлениях
|
||
держателей остаётся только шифротекст, включая backfill после restart.
|
||
Квота медиакеша учитывает обе копии; очистка кеша не удаляет ключ. При повторном
|
||
открытии удалённая открытая копия восстанавливается с сохранённым ключом.
|
||
|
||
Производная запись контейнера создаётся из проверенной истории собственной
|
||
транзакцией: новая строка проверяется целиком, точный дубликат сохраняет ключ,
|
||
метаданные и флаги. 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-клиента кнопка «Запросы расшифровки» видна при наличии запросов без решения
|
||
и показывает их число.
|
||
Список содержит участника, вложение, дату и отдельные кнопки выдачи/отказа.
|
||
Двойной клик переводит к исходному сообщению. После выдачи видны ожидание доставки
|
||
и подтверждённая доставка ключа; отказ также сохраняется.
|
||
|
||
## Адресный протокол
|
||
|
||
Router-сервис `ETCP_RT_ID_ATTACHMENT_KEYS=0x42`. Каждый пакет обязан иметь
|
||
`ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED`; промежуточные узлы ключа не видят.
|
||
Протокол не создаёт личную беседу и не публикует служебные сообщения в канал.
|
||
|
||
```text
|
||
magic "AK01":4 | command:1 | message_id:32 | crypto_UUID:16 | cipher_hash:32
|
||
GRANT дополнительно содержит key:32
|
||
|
||
REQUEST=1, REQUEST_ACK=2, GRANT=3, DENY=4, RESULT_ACK=5
|
||
```
|
||
|
||
Автор принимает запрос только на собственное опубликованное сообщение с
|
||
сохранённым ключом. Участие запрашивающего проверяется по полному подписанному
|
||
мемберу, join и дереву приглашений; удалённые мемберы отклоняются. Проверка
|
||
повторяется при решении и перед каждой отправкой GRANT. Получатель принимает
|
||
ответ только от автора, по своему существующему запросу и точной идентичности
|
||
контейнера. Конфликтующие ключи и результаты отклоняются.
|
||
|
||
Приём 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 с решением автора. Проверка прав
|
||
также выполняется внутри неё. Изменяется ровно один входящий запрос в state=0;
|
||
уже принятое решение повторной командой не заменяется. Общий помощник записи outbox
|
||
проверяет prepare, все bind, step, ровно одну строку и finalize, транзакцией управляет вызывающий.
|
||
Внешняя транзакция отклоняется до проверки прав и остаётся нетронутой. SQL-отказ,
|
||
включая пропуск записи через RAISE(IGNORE), сохраняет прежние состояние и очередь.
|
||
Возврат0 и единственное уведомление UI следуют после проверенного commit; доставка
|
||
выполняется позже. Trampoline при ошибке отправляет событие с success=0, позволяя повторить команду.
|
||
Неудачный rollback с открытой транзакцией отключает модуль и запрашивает stop ядра после poll.
|
||
История дефекта и исправления — в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||
|
||
Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK
|
||
применяются собственной короткой транзакцией: чтение запроса и очереди, запись ключа
|
||
при GRANT, изменение состояния и удаление ожидаемой команды. Все prepare/bind/step,
|
||
числа строк, finalize и commit проверяются. Удаляемая команда может уже отсутствовать
|
||
после REQUEST_ACK либо предыдущего результата; если она существовала, пропуск DELETE
|
||
считается ошибкой. Внешняя транзакция отклоняется без изменений.
|
||
|
||
RESULT_ACK для GRANT/DENY и уведомление интерфейса отправляются после commit.
|
||
При SQL-отказе прежние ключ/состояние/outbox сохраняются, подтверждение не отправляется,
|
||
автор повторяет выдачу. Если rollback оставил открытую транзакцию, модуль отключается
|
||
и запрашивает остановку ядра владельцем после poll. Найденный ранее bind-дефект и
|
||
его исправление описаны в [chat_audit_findings.txt](chat_audit_findings.txt).
|
||
Повторы возвращают прежнее решение и не
|
||
создают новых строк. Таймер обрабатывает ограниченную порцию очереди с
|
||
backpressure и повтором через 5 секунд; restart продолжает очереди.
|
||
|
||
Для доставки нужен живой маршрут обеих сторон: офлайн-mailbox этого сервиса
|
||
не реализован. Запрос ждёт появления автора; решение ждёт появления получателя.
|
||
Уже выданный ключ невозможно отозвать у получателя. Защита содержимого не
|
||
скрывает автора, канал, время и размер шифротекста. Автоматическая публикация
|
||
транскрипции зашифрованного голосового в канал не запускается.
|
||
|
||
## Проверки
|
||
|
||
`test_encrypted_file`: границы порций, пустой файл, metadata roundtrip,
|
||
неверные ключ/автор/UUID/hash, подмена метаданных с пересчитанным внешним hash,
|
||
лишние байты, alias обоих API, сохранность существующего назначения/метаданных,
|
||
23 сценария отказов I/O с контролем fd/temp, включая ошибку после rename.
|
||
Для MIME проверяются все256 значений первого байта, NULL и границы поля,
|
||
восемь ранних отказов writer с сохранением файлов/output, три допустимых roundtrip
|
||
и семь аутентифицированных некорректных wire-полей с сохранением info/type/target.
|
||
`test_media_primitives` проверяет оба режима temp и границу durable-публикации;
|
||
`test_attachment_send` проверяет восемь отказов MIME до ключей/worker/файлов,
|
||
затем останавливает сервис после настоящего crypto rename до completion.
|
||
|
||
`test_chat_attachment` проверяет 13 SQL-отказов с сохранением source/прежних строк/файлов,
|
||
отсутствием worker и события до принятия, затем настоящую публикацию при каждом повторе.
|
||
Отдельное 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-отказов/повторов выдачи и отказа, с пустой
|
||
и существующей очередью: чтение подписанного мембера, допуск автора, запись состояния,
|
||
outbox, BEGIN/COMMIT. Участник имеет настоящие join/invitation подписи, сообщение
|
||
сохранено штатным chat_core; запись локального вложения создаётся фикстурой.
|
||
Readonly-соединение в событии проверяет committed ключ/состояние/команду/retry_at/created_at
|
||
и сохранность соседнего ключа. Дополнительно: настоящий commit veto, ABORT/IGNORE для
|
||
UPDATE состояния и INSERT/UPDATE очереди, внешняя транзакция, удалённый мембер,
|
||
неверное направление и повторное решение, один GUI event, остановка при отказе rollback.
|
||
|
||
`test_encrypted_attachment`: реальные A–S–B/C, скачивание с S при выключенном A,
|
||
перезапуски автора с запросами и получателя с ключом, выдача 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.
|
||
На Linux первый bind полученного GRANT намеренно возвращает SQLITE_NOMEM: проверяются
|
||
полный откат и отсутствие ACK, сохранение GRANT у автора, затем реальная повторная выдача
|
||
и расшифровка. Перед каждым RESULT_ACK получателя отдельное readonly-соединение видит ключ.
|
||
`test_attachment_results` отдельно проверяет 105 SQL-отказов/повторов четырёх ответов,
|
||
commit veto, ABORT/IGNORE для key/state/outbox, внешнюю транзакцию, backpressure,
|
||
дубликаты, конфликт ключей/решений и остановку при отказе rollback. В точке ACK/события
|
||
readonly-соединение проверяет committed ключ/состояние/очередь и сохранность соседней записи.
|
||
При startup/restart дополнительно проверяются оба дерева: stale temp удаляются,
|
||
открытый и закрытый активные temp, конечные файлы и чужие имена сохраняются.
|