/* Настоящие DM/SQLite/workers: удаление, повторы, nonce, rollback и поколения. */ #include #include #include #include #include "../src/utun_instance.h" #include "../src/dm/dm_core.h" #include "../src/dm/dm_crypto.h" #include "../src/dm/dm_history.h" #include "../src/chat/chat_core.h" #include "../src/chat/chat_event.h" #include "../src/media_async/attachment_send.h" #include "../lib/mem.h" #include "../lib/debug_config.h" #include "test_utils.h" #define REQUIRE(value) do { if (!(value)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM history test failed line=%d", __LINE__); return 1; } } while (0) static int messages, delete_results, delete_success, preparation_failures; static struct SC_MYKEYS identities[2]; static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { (void)inst; if (type == CHAT_EVT_DM_MSG_RECEIVED) messages++; if (type == CHAT_EVT_ATTACHMENT_PREPARATION && len >= 18 && data[len - 1] == 2) preparation_failures++; if (type == CHAT_EVT_DM_DELETE_RESULT && len == 25) { delete_results++; delete_success = data[24]; } } static int scalar(struct UTUN_INSTANCE* inst, const char* sql) { sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL)) return -1; int n = sqlite3_step(st) == SQLITE_ROW ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); return n; } static int body(struct UTUN_INSTANCE* inst, int seq, uint8_t* out, size_t* len) { sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT body FROM dm_outbox WHERE seq=?", -1, &st, NULL)) return -1; sqlite3_bind_int(st, 1, seq); int rc = sqlite3_step(st); int n = rc == SQLITE_ROW ? sqlite3_column_bytes(st, 0) : -1; if (n > 0 && (size_t)n <= *len) { memcpy(out, sqlite3_column_blob(st, 0), (size_t)n); *len = (size_t)n; } else rc = SQLITE_ERROR; sqlite3_finalize(st); return rc == SQLITE_ROW ? 0 : -1; } static int peer(struct UTUN_INSTANCE* a, struct UTUN_INSTANCE* b, char conv[64]) { sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(a->topo_sqlite_db, "INSERT OR REPLACE INTO nodes(node_id,x25519_pubkey,ed25519_pubkey) VALUES(?,?,?)", -1, &st, NULL)) return -1; sqlite3_bind_int64(st, 1, (sqlite3_int64)b->node_id); sqlite3_bind_blob(st, 2, b->my_keys.public_key, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 3, b->my_ed25519_pubkey, 32, SQLITE_STATIC); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE || dm_start(a, b->node_id, b->my_keys.public_key, b->my_ed25519_pubkey, "peer", NULL)) return -1; snprintf(conv, 64, "%llu", (unsigned long long)dm_derive_conv_id(a->node_id, b->node_id)); return 0; } static struct UTUN_INSTANCE* start(struct UASYNC* ua, const char* directory, int index) { char config[1536], pub[65], priv[65]; for (unsigned i = 0; i < 32; i++) { snprintf(pub + 2 * i, 3, "%02x", identities[index].public_key[i]); snprintf(priv + 2 * i, 3, "%02x", identities[index].private_key[i]); } snprintf(config, sizeof(config), "[global]\nmy_public_key=%s\nmy_private_key=%s\ndb_path=%s/%d\n" "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", pub, priv, directory, index); struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); if (!inst || utun_core_start(inst) || chat_service_start(inst)) return NULL; chat_event_set_handler(inst, event); return inst; } int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); if (getenv("UTUN_TEST_DEBUG")) debug_set_category_level(DEBUG_CATEGORY_DM, DEBUG_LEVEL_DEBUG); char directory[512] = "/tmp/utun_dm_history_XXXXXX", conv[64], path[768]; REQUIRE(!test_mkdtemp(directory)); REQUIRE(!sc_generate_keypair(&identities[0]) && !sc_generate_keypair(&identities[1])); struct UASYNC* ua = uasync_create(); REQUIRE(ua); struct UTUN_INSTANCE* a = start(ua, directory, 0); struct UTUN_INSTANCE* b = start(ua, directory, 1); REQUIRE(a && b && !peer(a, b, conv) && !peer(b, a, conv)); REQUIRE(dm_history_generation(b, conv) == 1); REQUIRE(!dm_send(a, conv, "text", (const uint8_t*)"one", 3)); REQUIRE(!dm_send(a, conv, "text", (const uint8_t*)"two", 3)); uint8_t first[2048], second[2048], file_body[2048], receipt[DM_RECEIPT_SIZE]; size_t first_len = sizeof(first), second_len = sizeof(second), file_len = sizeof(file_body); REQUIRE(!body(a, 1, first, &first_len) && !body(a, 2, second, &second_len)); REQUIRE(!dm_accept_message(b, second, second_len, receipt)); /* seq=1 ещё не получен. */ REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"reply", 5)); int before = messages; REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "CREATE TEMP TRIGGER fail_delete BEFORE DELETE ON dm_messages" " BEGIN SELECT RAISE(ABORT,'injected history delete failure'); END", NULL, NULL, NULL)); REQUIRE(dm_history_delete(b, conv, 1) < 0 && dm_history_generation(b, conv) == 1); REQUIRE(scalar(b, "SELECT count(*) FROM dm_deleted_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_messages") == 2); REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "DROP TRIGGER fail_delete", NULL, NULL, NULL)); struct dm_delete_req* deletion = u_calloc(1, sizeof(*deletion)); REQUIRE(deletion); deletion->inst = b; deletion->generation = 1; deletion->request_id = 123; snprintf(deletion->conv_id, 64, "%s", conv); dm_delete_trampoline(deletion); REQUIRE(delete_results == 1 && delete_success && dm_history_generation(b, conv) == 2); REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_outbox") == 0); REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 1); char json[8192]; size_t length; REQUIRE(!dm_list_conversations_json(b, json, sizeof(json), &length) && !strcmp(json, "[]")); REQUIRE(!dm_accept_message(b, second, second_len, receipt) && messages == before); REQUIRE(!dm_verify_receipt(a, receipt, second, second_len)); REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT hidden FROM dm_history") == 1); uint8_t conflict[2048]; memcpy(conflict, second, second_len); conflict[16] ^= 1; REQUIRE(!sc_ed25519_sign(a->my_ed25519_privkey, conflict, second_len - 64, conflict + second_len - 64)); REQUIRE(dm_accept_message(b, conflict, second_len, receipt) < 0); REQUIRE(!dm_accept_message(b, first, first_len, receipt) && messages == before + 1); REQUIRE(scalar(b, "SELECT hidden FROM dm_history") == 0); /* Впервые доставленное seq=1 остаётся новым. */ REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"after", 5)); REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 2); puts("PASS: atomic delete/rollback, exact replay receipts/conflicts, out-of-order delivery and preserved outgoing seq"); snprintf(path, sizeof(path), "%s/source.bin", directory); FILE* source = fopen(path, "wb"); REQUIRE(source); REQUIRE(fwrite("payload", 1, 7, source) == 7 && !fclose(source)); struct attachment_info info = { .kind = ATTACHMENT_FILE, .name = "source.bin" }; REQUIRE(!dm_send_file(a, conv, path, &info, 0)); for (unsigned i = 0; i < 500 && scalar(a, "SELECT count(*) FROM dm_files") == 0; i++) uasync_poll(ua, 20); REQUIRE(!body(a, 3, file_body, &file_len) && !dm_accept_message(b, file_body, file_len, receipt)); REQUIRE(!dm_history_delete(b, conv, 2)); REQUIRE(!dm_accept_message(b, file_body, file_len, receipt)); uint8_t saved[DM_MEDIA_RECEIPT_SIZE]; memset(saved, 0x55, sizeof(saved)); REQUIRE(dm_history_deleted(b, file_body, file_len, 0, saved) == 1 && saved[0] == 0); REQUIRE(scalar(b, "SELECT count(*) FROM dm_files") == 0); /* Подписанная квитанция загрузки хранится отдельно от квитанции сообщения. */ REQUIRE(!dm_send_file(a, conv, path, &info, 0)); for (unsigned i = 0; i < 500 && scalar(a, "SELECT count(*) FROM dm_files") < 2; i++) uasync_poll(ua, 20); file_len = sizeof(file_body); REQUIRE(!body(a, 4, file_body, &file_len) && !dm_accept_message(b, file_body, file_len, receipt)); uint8_t file_receipt[DM_MEDIA_RECEIPT_SIZE]; memcpy(file_receipt, "DMFILE01", 8); memcpy(file_receipt + 8, file_body, 16); memcpy(file_receipt + 24, file_body + 24, 8); memcpy(file_receipt + 32, &b->node_id, 8); memcpy(file_receipt + 40, dm_message_media(file_body, file_len), 16); SHA256(file_body, file_len, file_receipt + 56); REQUIRE(!sc_ed25519_sign(b->my_ed25519_privkey, file_receipt, 88, file_receipt + 88)); REQUIRE(!dm_media_verify_receipt(a, file_receipt, file_body, file_len)); sqlite3_stmt* complete = NULL; REQUIRE(!sqlite3_prepare_v2(b->topo_sqlite_db, "UPDATE dm_files SET receipt=?,ready=1", -1, &complete, NULL)); sqlite3_bind_blob(complete, 1, file_receipt, sizeof(file_receipt), SQLITE_STATIC); REQUIRE(sqlite3_step(complete) == SQLITE_DONE); sqlite3_finalize(complete); REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "CREATE TEMP TRIGGER fail_late_delete BEFORE INSERT ON dm_history" " BEGIN SELECT RAISE(ABORT,'injected failure after media manifest'); END", NULL, NULL, NULL)); int remembered = scalar(b, "SELECT count(*) FROM dm_deleted_messages"); REQUIRE(dm_history_delete(b, conv, 3) < 0 && scalar(b, "SELECT count(*) FROM dm_files") == 1); REQUIRE(scalar(b, "SELECT count(*) FROM dm_deleted_messages") == remembered && dm_history_generation(b, conv) == 3); REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "DROP TRIGGER fail_late_delete", NULL, NULL, NULL)); REQUIRE(!dm_history_delete(b, conv, 3)); REQUIRE(dm_history_deleted(b, file_body, file_len, 0, saved) == 1 && !memcmp(saved, file_receipt, sizeof(saved))); REQUIRE(!dm_history_delete(a, conv, 1)); REQUIRE(!dm_media_accept_receipt(a, file_receipt)); /* Поздний ACK удалённого исходящего файла. */ file_receipt[56] ^= 1; REQUIRE(!sc_ed25519_sign(b->my_ed25519_privkey, file_receipt, 88, file_receipt + 88)); REQUIRE(dm_media_accept_receipt(a, file_receipt) < 0); puts("PASS: media metadata rollback, retained real file receipts and exact validation of late outgoing receipts"); REQUIRE(!peer(b, a, conv)); uint64_t generation = dm_history_generation(b, conv); REQUIRE(generation == 4 && !dm_send_file(b, conv, path, &info, 0)); REQUIRE(!dm_history_delete(b, conv, generation) && !peer(b, a, conv)); REQUIRE(dm_history_delete(b, conv, generation) < 0); /* Старый запрос не удаляет новую историю. */ struct dm_send_req* stale = u_calloc(1, sizeof(*stale) + 4); REQUIRE(stale); stale->inst = b; stale->generation = generation; stale->data_len = 4; stale->data = (uint8_t*)(stale + 1); snprintf(stale->conv_id, sizeof(stale->conv_id), "%s", conv); memcpy(stale->data, "late", 4); dm_send_trampoline(stale); for (unsigned i = 0; i < 500 && scalar(b, "SELECT count(*) FROM dm_local_files WHERE busy>0") > 0; i++) uasync_poll(ua, 20); REQUIRE(scalar(b, "SELECT count(*) FROM dm_files") == 0 && scalar(b, "SELECT count(*) FROM dm_messages") == 0); REQUIRE(!access(path, F_OK)); /* Исходный пользовательский файл не принадлежит задаче. */ struct attachment_send_req* req = u_calloc(1, sizeof(*req)); REQUIRE(req); req->inst = b; req->is_dm = 1; req->dm_generation = dm_history_generation(b, conv); req->info.kind = ATTACHMENT_VOICE; req->preset = 1; req->pcm = u_calloc(48000, sizeof(int16_t)); req->pcm_count = 48000; REQUIRE(req->pcm); snprintf(req->target, sizeof(req->target), "%s", conv); attachment_send_trampoline(req); generation = dm_history_generation(b, conv); REQUIRE(!dm_history_delete(b, conv, generation) && !peer(b, a, conv)); for (unsigned i = 0; i < 500 && scalar(b, "SELECT count(*) FROM dm_local_files WHERE busy>0") > 0; i++) uasync_poll(ua, 20); REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT count(*) FROM dm_files") == 0); int failures_before = preparation_failures; req = u_calloc(1, sizeof(*req)); REQUIRE(req); req->inst = b; req->is_dm = 1; req->dm_generation = generation; req->info.kind = ATTACHMENT_VOICE; snprintf(req->target, sizeof(req->target), "%s", conv); attachment_send_trampoline(req); struct dm_file_req* old_file = u_calloc(1, sizeof(*old_file)); REQUIRE(old_file); old_file->inst = b; old_file->generation = generation; old_file->info = info; snprintf(old_file->conv, sizeof(old_file->conv), "%s", conv); snprintf(old_file->path, sizeof(old_file->path), "%s", path); dm_send_file_trampoline(old_file); REQUIRE(preparation_failures == failures_before); puts("PASS: deleted media replays do not register files or fake download receipts; old file/voice workers cannot send into reopened PM"); /* Перезапуск с занятой durable записью: владельца уже нет, уборка должна продолжиться. */ snprintf(path, sizeof(path), "%s/restart-owned.bin", directory); source = fopen(path, "wb"); REQUIRE(source); REQUIRE(fputc(42, source) != EOF && !fclose(source)); uint64_t current = dm_history_generation(b, conv); REQUIRE(!dm_media_file_hold(b, conv, current, path)); REQUIRE(!dm_history_delete(b, conv, current)); utun_instance_destroy(b); b = start(ua, directory, 1); REQUIRE(b); REQUIRE(dm_history_generation(b, conv) == current + 1 && !dm_history_current(b, conv, current)); REQUIRE(!dm_accept_message(b, first, first_len, receipt)); REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT last_out_seq FROM dm_conversations") == 2); dm_media_forget_committed(b, conv); for (unsigned i = 0; i < 500 && !access(path, F_OK); i++) uasync_poll(ua, 20); REQUIRE(access(path, F_OK) != 0); REQUIRE(!dm_list_conversations_json(b, json, sizeof(json), &length) && !strcmp(json, "[]")); puts("PASS: restart retains replay protection, counters and hidden state; durable file cleanup resumes after owner loss"); REQUIRE(!peer(b, a, conv) && !dm_send(b, conv, "", (const uint8_t*)"x", 1)); first_len = sizeof(first); REQUIRE(!body(b, 3, first, &first_len)); REQUIRE(!dm_history_delete(b, conv, dm_history_generation(b, conv))); REQUIRE(dm_history_deleted(b, first, first_len, 1, NULL) == 1); REQUIRE(!peer(a, b, conv) && !dm_send(a, conv, "file_ab", (const uint8_t*)"x", 1)); first_len = sizeof(first); REQUIRE(!body(a, 5, first, &first_len)); memcpy(first + 33, "file\0ab", 7); REQUIRE(!sc_ed25519_sign(a->my_ed25519_privkey, first, first_len - 64, first + first_len - 64)); REQUIRE(!dm_accept_message(b, first, first_len, receipt)); REQUIRE(!dm_history_delete(b, conv, dm_history_generation(b, conv))); REQUIRE(dm_history_deleted(b, first, first_len, 0, NULL) == 1); puts("PASS: replay hashes preserve exact protocol bytes for empty and embedded-NUL content types"); utun_instance_destroy(b); utun_instance_destroy(a); uasync_destroy(ua, 0); puts("test_dm_history: ALL PASS"); return 0; }