546 lines
34 KiB
C
546 lines
34 KiB
C
/* Настоящие SQLite/crypto/workers: commit авторского ключа до передачи source и единственный результат. */
|
|
#define _GNU_SOURCE
|
|
#include <assert.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <errno.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/media_async/media_async.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, 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 unsigned completion_calls;
|
|
static int block_presence;
|
|
static unsigned blocked_completions;
|
|
static int completion_transaction;
|
|
static const char content[] = "attachment source must survive SQL failure";
|
|
|
|
struct fixture {
|
|
struct UASYNC* ua;
|
|
struct UTUN_INSTANCE* inst;
|
|
sqlite3* reader;
|
|
char directory[256], source[512], channel[32];
|
|
uint8_t id[16], key[32];
|
|
unsigned rows, events, submissions, decrypt_submissions, key_events, deleted_events;
|
|
int success, inspect_submit, decrypt_mode, allow_worker, result_error, delete_mode;
|
|
};
|
|
static struct fixture* active;
|
|
|
|
static int take(enum fault target) {
|
|
if (fault != target) return 0;
|
|
fault = NONE; if (target < FAULT_COUNT) hits[target]++;
|
|
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected stage=%d", target); return 1;
|
|
}
|
|
|
|
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;
|
|
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) {
|
|
int completion_scope = fault_prefix && strcmp(fault_prefix, "UPDATE attachment_keys SET decrypting=1 WHERE");
|
|
int targeted = !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)) {
|
|
fail_rollback = 0; rollback_fault++;
|
|
DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected rollback failure"); return SQLITE_IOERR;
|
|
}
|
|
int rc = __real_sqlite3_exec(db, sql, cb, arg, error);
|
|
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, "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);
|
|
}
|
|
|
|
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 length, void (*destroy)(void*)) {
|
|
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 (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_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_target(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM;
|
|
return __real_sqlite3_bind_int64(st, index, value);
|
|
}
|
|
|
|
extern int __real_sqlite3_step(sqlite3_stmt*);
|
|
int __wrap_sqlite3_step(sqlite3_stmt* st) {
|
|
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_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));
|
|
assert(rc == SQLITE_OK);
|
|
}
|
|
|
|
static unsigned rows(struct fixture* f) {
|
|
sqlite3_stmt* st = NULL;
|
|
assert(sqlite3_prepare_v2(f->reader, "SELECT count(*) FROM attachment_keys", -1, &st, NULL) == SQLITE_OK);
|
|
assert(sqlite3_step(st) == SQLITE_ROW);
|
|
unsigned count = (unsigned)sqlite3_column_int(st, 0);
|
|
assert(sqlite3_finalize(st) == SQLITE_OK); return count;
|
|
}
|
|
|
|
/* Читаем точные bytes всех полей с другого соединения: незавершённый commit здесь не виден. */
|
|
static void committed(struct fixture* f, int published) {
|
|
sqlite3_stmt* st = NULL;
|
|
assert(sqlite3_get_autocommit(f->inst->topo_sqlite_db));
|
|
assert(sqlite3_prepare_v2(f->reader, "SELECT key,author,info,content_type,cipher_path,plain_path,message_id,ready"
|
|
" 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));
|
|
assert((uint64_t)sqlite3_column_int64(st, 1) == f->inst->node_id);
|
|
struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "source.bin"};
|
|
uint8_t encoded[ATTACHMENT_WIRE_MAX]; int length = attachment_encode(&info, encoded, sizeof(encoded));
|
|
assert(length > 0 && sqlite3_column_bytes(st, 2) == length && !memcmp(sqlite3_column_blob(st, 2), encoded, (size_t)length));
|
|
assert(!strcmp((const char*)sqlite3_column_text(st, 3), "application/octet-stream"));
|
|
char hex[33], expected[512];
|
|
for (unsigned i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", f->id[i]);
|
|
snprintf(expected, sizeof(expected), "%s/media/%s/%s.uenc", f->directory, f->channel, hex);
|
|
assert(!strcmp((const char*)sqlite3_column_text(st, 4), expected));
|
|
snprintf(expected, sizeof(expected), "%s/decrypted/%s/%s.file", f->directory, f->channel, hex);
|
|
assert(!strcmp((const char*)sqlite3_column_text(st, 5), expected));
|
|
assert(sqlite3_column_bytes(st, 6) == (published ? 32 : 0) && sqlite3_column_int(st, 7) == 1);
|
|
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) {
|
|
active->inspect_submit = 0; active->submissions++;
|
|
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 event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int length) {
|
|
if (type == CHAT_EVT_CHANNEL_DELETED && active && active->delete_mode) { 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];
|
|
}
|
|
|
|
static void source(struct fixture* f) {
|
|
FILE* file = fopen(f->source, "wb"); assert(file);
|
|
assert(fwrite(content, 1, sizeof(content), file) == sizeof(content) && !fclose(file));
|
|
f->events = f->submissions = 0; f->success = -1; f->inspect_submit = 1;
|
|
}
|
|
|
|
static void source_preserved(struct fixture* f) {
|
|
char buffer[sizeof(content)]; FILE* file = fopen(f->source, "rb"); assert(file);
|
|
assert(fread(buffer, 1, sizeof(buffer), file) == sizeof(buffer) && !memcmp(buffer, content, sizeof(buffer)));
|
|
assert(fgetc(file) == EOF && !fclose(file));
|
|
}
|
|
|
|
static int send_attachment(struct fixture* f) {
|
|
struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "source.bin"}; uint8_t reply[32] = {0};
|
|
return chat_attachment_send(f->inst, f->channel, f->source, &info, "application/octet-stream", reply, 71);
|
|
}
|
|
|
|
static void unchanged(struct fixture* f) {
|
|
assert(fault == NONE && rows(f) == f->rows && !f->events && !f->submissions);
|
|
assert(sqlite3_get_autocommit(f->inst->topo_sqlite_db)); source_preserved(f);
|
|
sqlite3_stmt* st = NULL;
|
|
assert(sqlite3_prepare_v2(f->reader, "SELECT key FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK);
|
|
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32);
|
|
uint8_t expected[32] = {85}; assert(!memcmp(sqlite3_column_blob(st, 0), expected, 32));
|
|
assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK);
|
|
}
|
|
|
|
static void publish(struct fixture* f) {
|
|
assert(!send_attachment(f) && f->submissions == 1); f->rows++;
|
|
for (unsigned poll = 0; poll < 2000 && !f->events; poll++) uasync_poll(f->ua, 10);
|
|
assert(f->events == 1 && f->success == 1 && rows(f) == f->rows && ma_file_size(f->source) < 0);
|
|
committed(f, 1); uasync_poll(f->ua, 0); assert(f->events == 1);
|
|
}
|
|
|
|
static void setup(struct fixture* f) {
|
|
memset(f, 0, sizeof(*f)); strcpy(f->directory, "/tmp/utun-attachment-contract-XXXXXX"); assert(mkdtemp(f->directory));
|
|
char config[1024], database[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));
|
|
active = f; chat_event_set_handler(f->inst, event); chat_core_create_channel_auto(f->inst, "attachment contract");
|
|
sqlite3_stmt* st = NULL;
|
|
assert(sqlite3_prepare_v2(f->inst->topo_sqlite_db, "SELECT channel_id FROM channels WHERE name='attachment contract'",
|
|
-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);
|
|
sql(f->inst->topo_sqlite_db, "INSERT INTO attachment_keys(channel,crypto_id,author,key,cipher_path,plain_path)"
|
|
" VALUES('900',zeroblob(16),999,x'5500000000000000000000000000000000000000000000000000000000000000','',''),"
|
|
"('901',zeroblob(16),999,NULL,'','')");
|
|
snprintf(database, sizeof(database), "%s/chats.db", f->directory);
|
|
assert(sqlite3_open_v2(database, &f->reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
|
snprintf(f->source, sizeof(f->source), "%s/source.bin", f->directory); f->rows = rows(f); assert(f->rows == 2);
|
|
}
|
|
|
|
static void cleanup(struct fixture* f) {
|
|
chat_event_set_handler(f->inst, NULL); utun_instance_destroy(f->inst); f->inst = NULL;
|
|
assert(sqlite3_close(f->reader) == SQLITE_OK); uasync_destroy(f->ua, 0); active = NULL;
|
|
}
|
|
|
|
static void count_files(void* arg, const char* path, int64_t bytes) { (void)path; (void)bytes; (*(unsigned*)arg)++; }
|
|
|
|
static void test_sql_faults(void) {
|
|
struct fixture f; setup(&f);
|
|
for (enum fault stage = BEGIN; stage < FAULT_COUNT; stage++) {
|
|
source(&f); unsigned before = 0, after = 0; assert(!ma_dir_walk(f.directory, count_files, &before));
|
|
fault = stage; assert(send_attachment(&f) == -1); unchanged(&f);
|
|
assert(!ma_dir_walk(f.directory, count_files, &after) && before == after);
|
|
source(&f); publish(&f);
|
|
}
|
|
cleanup(&f);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment:13 SQL faults preserve source/rows/files, no worker/event; every retry publishes");
|
|
}
|
|
|
|
static int veto_commit(void* arg) { (*(unsigned*)arg)++; return 1; }
|
|
|
|
static void test_sql_controls(void) {
|
|
struct fixture f; setup(&f); unsigned vetoes = 0;
|
|
source(&f); sqlite3_commit_hook(f.inst->topo_sqlite_db, veto_commit, &vetoes);
|
|
assert(send_attachment(&f) == -1 && vetoes == 1); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); unchanged(&f);
|
|
source(&f); publish(&f);
|
|
const char* triggers[] = {
|
|
"CREATE TRIGGER reject_preparation BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(ABORT,'test insert failure'); END",
|
|
"CREATE TRIGGER reject_preparation BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(IGNORE); END"
|
|
};
|
|
for (unsigned i = 0; i < 2; i++) {
|
|
source(&f); sql(f.inst->topo_sqlite_db, triggers[i]); assert(send_attachment(&f) == -1); unchanged(&f);
|
|
sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_preparation"); source(&f); publish(&f);
|
|
}
|
|
source(&f); sql(f.inst->topo_sqlite_db, "BEGIN; UPDATE attachment_keys SET ready=7 WHERE channel='900'");
|
|
assert(send_attachment(&f) == -1 && !f.events && !f.submissions && !sqlite3_get_autocommit(f.inst->topo_sqlite_db));
|
|
source_preserved(&f); assert(rows(&f) == f.rows);
|
|
sqlite3_stmt* st = NULL;
|
|
assert(sqlite3_prepare_v2(f.inst->topo_sqlite_db, "SELECT ready FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK);
|
|
assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) == 7 && sqlite3_finalize(st) == SQLITE_OK);
|
|
sql(f.inst->topo_sqlite_db, "ROLLBACK"); unchanged(&f); source(&f); publish(&f);
|
|
chat_service_stop(f.inst); assert(!chat_service_start(f.inst)); committed(&f, 1);
|
|
cleanup(&f);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: real commit veto, INSERT abort/ignore, external transaction preserved, retry/restart");
|
|
}
|
|
|
|
/* Submit может вызвать done до возврата send; SQL уже виден reader, результат остаётся единственным. */
|
|
static void test_inline_failure(void) {
|
|
struct fixture f; setup(&f); source(&f); fail_submit = 1;
|
|
assert(!send_attachment(&f) && !fail_submit && f.submissions == 1 && f.events == 1 && !f.success);
|
|
assert(rows(&f) == f.rows + 1 && ma_file_size(f.source) < 0); committed(&f, 0);
|
|
uasync_poll(f.ua, 0); assert(f.events == 1); cleanup(&f);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: inline submit failure after commit transfers source and reports exactly once");
|
|
}
|
|
|
|
static void fail_from_callback(void* arg) {
|
|
struct fixture* f = arg; fault = COMMIT; fail_rollback = 1;
|
|
assert(send_attachment(f) == -1 && !fail_rollback && !f->inst->running && !f->submissions && !f->events);
|
|
assert(!sqlite3_get_autocommit(f->inst->topo_sqlite_db)); source_preserved(f);
|
|
}
|
|
|
|
/* Callback только запрашивает stop; владелец после poll уничтожает живое ядро и закрывает БД. */
|
|
static void test_rollback_failure(void) {
|
|
struct fixture f; setup(&f); source(&f);
|
|
struct posted_task* task = u_calloc(1, sizeof(*task)); assert(task);
|
|
task->callback = fail_from_callback; task->arg = &f; uasync_post_reserved(f.ua, task);
|
|
while (f.inst->running) uasync_poll(f.ua, 10);
|
|
assert(f.inst->chat_started && f.inst->chat_core && rows(&f) == f.rows);
|
|
assert(send_attachment(&f) == -1 && !f.events && !f.submissions); source_preserved(&f);
|
|
chat_event_set_handler(f.inst, NULL); utun_instance_destroy(f.inst); f.inst = NULL;
|
|
assert(rows(&f) == f.rows); uasync_poll(f.ua, 0);
|
|
assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0); active = NULL;
|
|
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 test_channel_delete_rollback(void) {
|
|
struct fixture f; setup(&f); source(&f); publish(&f); f.delete_mode = 1;
|
|
char path[512]; plaintext_path(&f, path);
|
|
const char* trigger = "CREATE TEMP TRIGGER reject_channel BEFORE DELETE ON channels BEGIN SELECT RAISE(ABORT,'channel'); END";
|
|
sql(f.inst->topo_sqlite_db, trigger); chat_core_delete_channel(f.inst, f.channel);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "delete test: failed metadata delete reader_keys=%u expected=%u plaintext_bytes=%lld events=%u",
|
|
rows(&f), f.rows, (long long)ma_file_size(path), f.deleted_events);
|
|
assert(!f.deleted_events && rows(&f) == f.rows && ma_file_size(path) == sizeof(content)); committed(&f, 1);
|
|
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);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS channel delete: metadata failure retains committed key/plaintext; retry removes channel and derived records together");
|
|
}
|
|
|
|
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();
|
|
assert(u_get_allocated_count() == baseline);
|
|
DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: independent committed key/metadata before worker, allocations restored");
|
|
return 0;
|
|
}
|