From 539bfd6db90d792666b4b4854b6a0c390c06f0ec Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:56:17 +0200 Subject: [PATCH 01/49] Extend Merkle sessions with datasets and full record cursors --- src/chat/merkle_sync.c | 174 ++++++++++++++++++++++++++--------------- src/chat/merkle_sync.h | 33 +++++--- 2 files changed, 135 insertions(+), 72 deletions(-) diff --git a/src/chat/merkle_sync.c b/src/chat/merkle_sync.c index 5f1b3376..d850f04f 100644 --- a/src/chat/merkle_sync.c +++ b/src/chat/merkle_sync.c @@ -11,11 +11,12 @@ #include #include -/* Wire v2: svc:1, namespace:8, type:1, round:8, request:4, payload. +/* Wire v3: svc:1, namespace:8, type:1, round:8, request:4, dataset:1, payload. * Все целые заголовка big-endian. Один запрос на проход и один исходящий пакет на сессию. * Меньший node_id ведёт раунд: pull -> TURN -> reverse pull -> CHECK -> DONE. */ -#define MS_HEADER 22 -#define MS_QUERY_SIZE 18 +#define MS_HEADER 23 +#define MS_QUERY_SIZE (10 + MT_KEY_SIZE) +#define MS_PAGE_HEADER (1 + MT_KEY_SIZE) #define MS_PROGRESS_TIMEOUT_TB (30u * 10000u) enum { MS_WAKE = 0x10, MS_BEGIN, MS_READY, MS_QUERY, MS_HASHES, MS_PAGE, MS_TURN, MS_CHECK, MS_DONE, MS_ERROR }; enum { MS_IDLE, MS_BEGIN_WAIT, MS_PULL, MS_SERVE, MS_DONE_WAIT, MS_FAILED, MS_FAILING }; @@ -32,9 +33,16 @@ struct ms_frame { uint8_t level; }; +struct ms_dataset { + const struct merkle_sync_data_ops* ops; + void* data_ctx; + uint8_t id; +}; + struct ms_session { struct ms_session* next; struct merkle_sync* ms; + struct ms_dataset* dataset; struct ETCP_CONN* conn; struct ll_queue* send_q; struct queue_waiter_handle waiter; @@ -45,10 +53,10 @@ struct ms_session { int failure_error; struct ms_request* requests; uint64_t ns_id, peer, round, revision, verified_revision; - char ns[21]; + char ns[21], tree_ns[25]; int state, leader, dirty, depth, after_valid, waiting; uint32_t request_id, remote_request_id; - uint64_t after; + merkle_key_t after; struct ms_frame stack[MT_MAX_LEVEL + 1]; uint8_t previous_roots[2 * MT_HASH_SIZE]; int previous_valid; @@ -58,8 +66,7 @@ struct ms_session { struct merkle_sync { struct UTUN_INSTANCE* inst; uint8_t svc_id; - const struct merkle_sync_data_ops* ops; - void* data_ctx; + struct ms_dataset datasets[256]; struct ms_session* sessions; uint64_t round_seq; struct ETCP_CONN* closing_conn; @@ -82,9 +89,9 @@ static int ms_namespace(const char* ns, uint64_t* id) { return 0; } -static struct ms_session* ms_find(struct merkle_sync* ms, struct ETCP_CONN* conn, uint64_t ns_id) { +static struct ms_session* ms_find(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id) { for (struct ms_session* s = ms->sessions; s; s = s->next) - if (s->conn == conn && s->ns_id == ns_id) return s; + if (s->conn == conn && s->dataset->id == dataset && s->ns_id == ns_id) return s; return NULL; } @@ -110,7 +117,7 @@ static void ms_fail(struct ms_session* s, int error, const char* reason, int rep static void ms_abort_pull(struct ms_session* s) { if (!s->pull) return; void* pull = s->pull; s->pull = NULL; - s->ms->ops->abort_pull(s->ms->data_ctx, pull); + s->dataset->ops->abort_pull(s->dataset->data_ctx, pull); } static void ms_stop_deadline(struct ms_session* s) { @@ -155,14 +162,15 @@ static void ms_free(struct ms_session* s) { u_free(s); } -static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* conn, uint64_t ns_id) { +static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* conn, uint8_t dataset, uint64_t ns_id) { if (!conn || !conn->send_input_q || !conn->initialized || !conn->links_up || conn->state == 2) return NULL; struct ms_session* s = u_calloc(1, sizeof(*s)); if (!s) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: session allocation failed"); return NULL; } if (etcp_conn_ref_take(conn) != 0) { u_free(s); return NULL; } - s->ms = ms; s->conn = conn; s->send_q = conn->send_input_q; + s->ms = ms; s->dataset = &ms->datasets[dataset]; s->conn = conn; s->send_q = conn->send_input_q; s->peer = conn->peer_node_id; s->ns_id = ns_id; snprintf(s->ns, sizeof(s->ns), "%llu", (unsigned long long)ns_id); + snprintf(s->tree_ns, sizeof(s->tree_ns), "%u:%s", dataset, s->ns); s->leader = ms->inst->node_id < s->peer; s->next = ms->sessions; ms->sessions = s; DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: attach ns=%s peer=%016llx conn=%p leader=%d", @@ -170,12 +178,16 @@ static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* co return s; } -int merkle_sync_read_hash(struct UTUN_INSTANCE* inst, const char* ns, uint8_t level, uint64_t prefix, uint8_t hash[MT_HASH_SIZE]) { - return merkle_tree_get(inst ? inst->topo_sqlite_db : NULL, ns, level, prefix, hash); +int merkle_sync_read_hash(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns, + uint8_t level, uint64_t prefix, uint8_t hash[MT_HASH_SIZE]) { + char tree_ns[25]; + if (!ns || strlen(ns) > 20) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid hash namespace"); return -1; } + snprintf(tree_ns, sizeof(tree_ns), "%u:%s", dataset, ns); + return merkle_tree_get(inst ? inst->topo_sqlite_db : NULL, tree_ns, level, prefix, hash); } static int ms_root(struct ms_session* s, uint8_t root[MT_HASH_SIZE]) { - return merkle_sync_read_hash(s->ms->inst, s->ns, 0, 0, root); + return merkle_sync_read_hash(s->ms->inst, s->dataset->id, s->ns, 0, 0, root); } static void ms_sent(struct ll_queue* q, void* arg) { @@ -232,6 +244,7 @@ static int ms_send(struct ms_session* s, int type, uint32_t request, const uint8 e->dgram[9] = (uint8_t)type; ms_write64(e->dgram + 10, type == MS_WAKE ? 0 : s->round); ms_write32(e->dgram + 18, request); + e->dgram[22] = s->dataset->id; if (len) memcpy(e->dgram + MS_HEADER, payload, len); s->tx = e; /* Transport normalizer configures deferred waiters. Не вызываем callback рекурсивно. */ @@ -271,7 +284,7 @@ static int ms_query(struct ms_session* s) { struct ms_frame* f = &s->stack[s->depth]; uint8_t body[MS_QUERY_SIZE]; body[0] = f->level; ms_write64(body + 1, f->prefix); - body[9] = (uint8_t)s->after_valid; ms_write64(body + 10, s->after); + body[9] = (uint8_t)s->after_valid; memcpy(body + 10, s->after.bytes, MT_KEY_SIZE); if (++s->request_id == 0) return -1; s->waiting = 1; return ms_send(s, MS_QUERY, s->request_id, body, sizeof(body)); @@ -279,9 +292,9 @@ static int ms_query(struct ms_session* s) { static int ms_pull_start(struct ms_session* s) { ms_abort_pull(s); - if (s->ms->ops->begin_pull && s->ms->ops->begin_pull(s->ms->data_ctx, s->ns, s->peer, s->round, &s->pull) < 0) + if (s->dataset->ops->begin_pull && s->dataset->ops->begin_pull(s->dataset->data_ctx, s->ns, s->peer, s->round, &s->pull) < 0) return MT_ERR_DATA; - s->state = MS_PULL; s->depth = 0; s->after_valid = 0; s->after = 0; + s->state = MS_PULL; s->depth = 0; s->after_valid = 0; memset(&s->after, 0, sizeof(s->after)); memset(s->stack, 0, sizeof(s->stack)); return ms_query(s); } @@ -295,19 +308,19 @@ static int ms_walk_next(struct ms_session* s) { uint8_t level = f->level + 1; uint64_t prefix = f->prefix | ((uint64_t)i << (64 - level * 5)); s->stack[++s->depth] = (struct ms_frame){ .prefix = prefix, .level = level }; - s->after_valid = 0; s->after = 0; + s->after_valid = 0; memset(&s->after, 0, sizeof(s->after)); return ms_query(s); } s->depth--; } s->waiting = 0; if (s->pull) { - int rc = s->ms->ops->commit_pull(s->ms->data_ctx, s->pull); + int rc = s->dataset->ops->commit_pull(s->dataset->data_ctx, s->pull); ms_abort_pull(s); if (rc < 0) return rc; } if (s->leader) { s->state = MS_SERVE; return ms_send(s, MS_TURN, 0, NULL, 0); } - if (s->ms->ops->finish && s->ms->ops->finish(s->ms->data_ctx, s->ns) < 0) return MT_ERR_DATA; + if (s->dataset->ops->finish && s->dataset->ops->finish(s->dataset->data_ctx, s->ns) < 0) return MT_ERR_DATA; uint8_t root[MT_HASH_SIZE]; if (ms_root(s, root) < 0) return -1; s->state = MS_DONE_WAIT; @@ -335,11 +348,11 @@ static void ms_work(void* arg) { if (rc < 0) ms_fail(s, MT_ERR_IO, "start send", 1); } -void merkle_sync_changed(struct UTUN_INSTANCE* inst, const char* ns) { +void merkle_sync_changed(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns) { struct merkle_sync* ms = inst ? inst->msync : NULL; if (!ms || !ns) return; for (struct ms_session* s = ms->sessions; s; s = s->next) { - if (strcmp(s->ns, ns)) continue; + if (s->dataset->id != dataset || strcmp(s->ns, ns)) continue; s->revision++; s->dirty = 1; if (s->state == MS_FAILED) { s->state = MS_IDLE; s->previous_valid = 0; } if (s->state == MS_IDLE && ms_schedule(s) < 0) { @@ -348,15 +361,27 @@ void merkle_sync_changed(struct UTUN_INSTANCE* inst, const char* ns) { } } -int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, const char* ns, uint64_t key) { +/* Дерево индексируется по dataset:channel, модель получает исходный channel. */ +struct ms_leaf_context { struct ms_dataset* dataset; const char* ns; }; +static int ms_leaf_hash(void* ctx, const char* tree_ns, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) { + struct ms_leaf_context* leaf = ctx; + (void)tree_ns; + return leaf->dataset->ops->update_bucket_hash(leaf->dataset->data_ctx, leaf->ns, level, prefix, hash); +} + +int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns, uint64_t key) { struct merkle_sync* ms = inst ? inst->msync : NULL; sqlite3* db = inst ? inst->topo_sqlite_db : NULL; - if (!ms || !db || !ns) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid recompute"); return -1; } + if (!ms || !db || !ns || strlen(ns) > 20 || !ms->datasets[dataset].ops) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid recompute dataset=%u", dataset); return -1; + } + char tree_ns[25]; snprintf(tree_ns, sizeof(tree_ns), "%u:%s", dataset, ns); + struct ms_leaf_context leaf = { &ms->datasets[dataset], ns }; int own = sqlite3_get_autocommit(db); if (own && sqlite3_exec(db, "SAVEPOINT merkle_update", NULL, NULL, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: begin update ns=%s: %s", ns, sqlite3_errmsg(db)); return -1; } - int rc = merkle_tree_update(db, ns, key, ms->ops->update_bucket_hash, ms->data_ctx); + int rc = merkle_tree_update(db, tree_ns, key, ms_leaf_hash, &leaf); if (own) { if (rc < 0 && sqlite3_exec(db, "ROLLBACK TO merkle_update", NULL, NULL, NULL) != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: rollback ns=%s: %s", ns, sqlite3_errmsg(db)); @@ -366,7 +391,7 @@ int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, const char* ns, uint6 DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: commit cleanup ns=%s: %s", ns, sqlite3_errmsg(db)); rc = -1; } - if (rc > 0) merkle_sync_changed(inst, ns); + if (rc > 0) merkle_sync_changed(inst, dataset, ns); } if (rc < 0) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: recompute failed ns=%s key=%016llx", ns, (unsigned long long)key); return rc; @@ -375,25 +400,30 @@ int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, const char* ns, uint6 static int ms_serve_query(struct ms_session* s, uint32_t request, const uint8_t* p, size_t len) { if (s->state != MS_SERVE || len != MS_QUERY_SIZE || request != s->remote_request_id + 1) return MT_ERR_PROTOCOL; uint8_t level = p[0]; - uint64_t prefix = ms_read64(p + 1), after = ms_read64(p + 10); + uint64_t prefix = ms_read64(p + 1); + merkle_key_t after; memcpy(after.bytes, p + 10, MT_KEY_SIZE); + static const merkle_key_t empty; if (level > MT_MAX_LEVEL || prefix != merkle_sync_level_prefix(prefix, level) || p[9] > 1) return MT_ERR_PROTOCOL; - if (level < MT_MAX_LEVEL && (p[9] || after)) return MT_ERR_PROTOCOL; - if (p[9] && merkle_sync_level_prefix(after, MT_MAX_LEVEL) != prefix) return MT_ERR_PROTOCOL; + if ((!p[9] || level < MT_MAX_LEVEL) && memcmp(&after, &empty, sizeof(after))) return MT_ERR_PROTOCOL; + if (level < MT_MAX_LEVEL && p[9]) return MT_ERR_PROTOCOL; + if (p[9] && merkle_sync_level_prefix(ms_read64(after.bytes), MT_MAX_LEVEL) != prefix) return MT_ERR_PROTOCOL; s->remote_request_id = request; if (level < MT_MAX_LEVEL) { uint8_t hashes[MT_BUCKETS][MT_HASH_SIZE]; - if (merkle_tree_children(s->ms->inst->topo_sqlite_db, s->ns, level, prefix, hashes) < 0) return MT_ERR_IO; + if (merkle_tree_children(s->ms->inst->topo_sqlite_db, s->tree_ns, level, prefix, hashes) < 0) return MT_ERR_IO; return ms_send(s, MS_HASHES, request, &hashes[0][0], sizeof(hashes)) < 0 ? MT_ERR_IO : 0; } uint8_t body[MT_PAGE_SIZE - MS_HEADER]; - size_t size = sizeof(body) - 9; - uint64_t next = 0; int more = 0; - int rc = s->ms->ops->get_page(s->ms->data_ctx, s->ns, prefix, p[9], after, body + 9, &size, &next, &more); + size_t size = sizeof(body) - MS_PAGE_HEADER; + merkle_key_t next = {{0}}; int more = 0; + int rc = s->dataset->ops->get_page(s->dataset->data_ctx, s->ns, prefix, p[9], &after, + body + MS_PAGE_HEADER, &size, &next, &more); if (rc < 0) return MT_ERR_DATA; - if (size > sizeof(body) - 9 || (more != 0 && more != 1) - || (more && (merkle_sync_level_prefix(next, MT_MAX_LEVEL) != prefix || (p[9] && next <= after)))) return MT_ERR_DATA; - body[0] = (uint8_t)more; ms_write64(body + 1, next); - return ms_send(s, MS_PAGE, request, body, size + 9) < 0 ? MT_ERR_IO : 0; + if (size > sizeof(body) - MS_PAGE_HEADER || (more != 0 && more != 1) + || (more && (!size || merkle_sync_level_prefix(ms_read64(next.bytes), MT_MAX_LEVEL) != prefix + || (p[9] && memcmp(next.bytes, after.bytes, MT_KEY_SIZE) <= 0)))) return MT_ERR_DATA; + body[0] = (uint8_t)more; memcpy(body + 1, next.bytes, MT_KEY_SIZE); + return ms_send(s, MS_PAGE, request, body, size + MS_PAGE_HEADER) < 0 ? MT_ERR_IO : 0; } static int ms_receive_hashes(struct ms_session* s, uint32_t request, const uint8_t* p, size_t len) { @@ -401,7 +431,7 @@ static int ms_receive_hashes(struct ms_session* s, uint32_t request, const uint8 struct ms_frame* f = &s->stack[s->depth]; if (f->level >= MT_MAX_LEVEL) return MT_ERR_PROTOCOL; uint8_t local[MT_BUCKETS][MT_HASH_SIZE]; - if (merkle_tree_children(s->ms->inst->topo_sqlite_db, s->ns, f->level, f->prefix, local) < 0) return MT_ERR_IO; + if (merkle_tree_children(s->ms->inst->topo_sqlite_db, s->tree_ns, f->level, f->prefix, local) < 0) return MT_ERR_IO; f->children = 0; for (unsigned i = 0; i < MT_BUCKETS; i++) if (memcmp(local[i], p + i * MT_HASH_SIZE, MT_HASH_SIZE)) f->children |= UINT32_C(1) << i; @@ -410,13 +440,15 @@ static int ms_receive_hashes(struct ms_session* s, uint32_t request, const uint8 } static int ms_receive_page(struct ms_session* s, uint32_t request, const uint8_t* p, size_t len) { - if (s->state != MS_PULL || !s->waiting || request != s->request_id || len < 9 || p[0] > 1) return MT_ERR_PROTOCOL; + if (s->state != MS_PULL || !s->waiting || request != s->request_id || len < MS_PAGE_HEADER || p[0] > 1) return MT_ERR_PROTOCOL; struct ms_frame* f = &s->stack[s->depth]; - uint64_t next = ms_read64(p + 1); - if (f->level != MT_MAX_LEVEL || (p[0] && (len == 9 || merkle_sync_level_prefix(next, MT_MAX_LEVEL) != f->prefix - || (s->after_valid && next <= s->after)))) return MT_ERR_PROTOCOL; - int rc = s->pull ? s->ms->ops->stage_page(s->ms->data_ctx, s->pull, f->prefix, s->after_valid, s->after, - next, p[0], p + 9, len - 9) : s->ms->ops->apply_items(s->ms->data_ctx, s->ns, s->peer, p + 9, len - 9); + merkle_key_t next; memcpy(next.bytes, p + 1, MT_KEY_SIZE); + if (f->level != MT_MAX_LEVEL || (p[0] && (len == MS_PAGE_HEADER + || merkle_sync_level_prefix(ms_read64(next.bytes), MT_MAX_LEVEL) != f->prefix + || (s->after_valid && memcmp(next.bytes, s->after.bytes, MT_KEY_SIZE) <= 0)))) return MT_ERR_PROTOCOL; + int rc = s->pull ? s->dataset->ops->stage_page(s->dataset->data_ctx, s->pull, f->prefix, s->after_valid, &s->after, + &next, p[0], p + MS_PAGE_HEADER, len - MS_PAGE_HEADER) + : s->dataset->ops->apply_items(s->dataset->data_ctx, s->ns, s->peer, p + MS_PAGE_HEADER, len - MS_PAGE_HEADER); if (rc < 0) return rc == MT_ERR_CONFLICT ? rc : MT_ERR_DATA; s->waiting = 0; if (p[0]) { s->after = next; s->after_valid = 1; return ms_query(s) < 0 ? MT_ERR_IO : 0; } @@ -433,14 +465,19 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { const uint8_t* d = entry->dgram; uint64_t ns_id = ms_read64(d + 1), round = ms_read64(d + 10); uint32_t request = ms_read32(d + 18); - uint8_t type = d[9]; + uint8_t type = d[9], dataset = d[22]; + struct ms_dataset* model = &ms->datasets[dataset]; + if (!model->ops) { + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unknown dataset=%u", dataset); + queue_dgram_free(entry); queue_entry_free(entry); return; + } char ns[21]; snprintf(ns, sizeof(ns), "%llu", (unsigned long long)ns_id); - if (ms->ops->validate_peer && ms->ops->validate_peer(ms->data_ctx, ns, conn->peer_node_id) != 1) { + if (model->ops->validate_peer && model->ops->validate_peer(model->data_ctx, ns, conn->peer_node_id) != 1) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: unauthorized ns=%s peer=%016llx", ns, (unsigned long long)conn->peer_node_id); queue_dgram_free(entry); queue_entry_free(entry); return; } - struct ms_session* s = ms_find(ms, conn, ns_id); - if (!s && (type == MS_WAKE || type == MS_BEGIN)) s = ms_create(ms, conn, ns_id); + struct ms_session* s = ms_find(ms, conn, dataset, ns_id); + if (!s && (type == MS_WAKE || type == MS_BEGIN)) s = ms_create(ms, conn, dataset, ns_id); if (!s) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: no session ns=%s type=%u", ns, type); queue_dgram_free(entry); queue_entry_free(entry); return; @@ -490,7 +527,7 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { if (!s->leader || s->state != MS_SERVE || request || len != MT_HASH_SIZE) rc = MT_ERR_PROTOCOL; else if (ms_root(s, roots) < 0) rc = MT_ERR_IO; else if (!memcmp(roots, p, MT_HASH_SIZE)) { - if (ms->ops->finish && ms->ops->finish(ms->data_ctx, ns) < 0) rc = MT_ERR_DATA; + if (model->ops->finish && model->ops->finish(model->data_ctx, ns) < 0) rc = MT_ERR_DATA; else { s->previous_valid = 0; s->verified_revision = s->revision; rc = ms_send(s, MS_DONE, 0, roots, MT_HASH_SIZE) < 0 ? MT_ERR_IO : 0; @@ -556,22 +593,34 @@ static void ms_conn_status(struct ETCP_CONN* conn, int event, void* arg) { } int merkle_sync_init(struct UTUN_INSTANCE* inst, uint8_t svc_id, const struct merkle_sync_data_ops* ops, void* data_ctx) { - if (!inst || inst->msync || !inst->ua || !inst->topo_sqlite_db || !ops || !ops->update_bucket_hash || !ops->get_page - || (!ops->apply_items && !ops->stage_page) - || ((ops->begin_pull || ops->commit_pull || ops->abort_pull || ops->stage_page) - && !(ops->begin_pull && ops->commit_pull && ops->abort_pull && ops->stage_page))) { + if (!inst || inst->msync || !inst->ua || !inst->topo_sqlite_db) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid initialization"); return -1; } if (merkle_tree_init(inst->topo_sqlite_db) < 0) return -1; struct merkle_sync* ms = u_calloc(1, sizeof(*ms)); if (!ms) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: allocation failed"); return -1; } - ms->inst = inst; ms->svc_id = svc_id; ms->ops = ops; ms->data_ctx = data_ctx; + ms->inst = inst; ms->svc_id = svc_id; /* Round counter is local to a connection lifetime; monotonic across session cancellation. */ ms->round_seq = get_time_tb(); inst->msync = ms; + if (merkle_sync_register(inst, MT_DATA_MEMBERS, ops, data_ctx) < 0) { inst->msync = NULL; u_free(ms); return -1; } etcp_bind(inst, svc_id, ms_receive); etcp_add_conn_status_cbk(inst, ms_conn_status, ms); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: initialized service=%u protocol=2", svc_id); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: initialized service=%u protocol=3", svc_id); + return 0; +} + +/* Регистрирует независимую модель в общем движке. */ +int merkle_sync_register(struct UTUN_INSTANCE* inst, uint8_t dataset, const struct merkle_sync_data_ops* ops, void* data_ctx) { + struct merkle_sync* ms = inst ? inst->msync : NULL; + if (!ms || ms->datasets[dataset].ops || !ops || !ops->update_bucket_hash || !ops->get_page + || (!ops->apply_items && !ops->stage_page) + || ((ops->begin_pull || ops->commit_pull || ops->abort_pull || ops->stage_page) + && !(ops->begin_pull && ops->commit_pull && ops->abort_pull && ops->stage_page))) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: invalid dataset registration dataset=%u", dataset); return -1; + } + ms->datasets[dataset] = (struct ms_dataset){ ops, data_ctx, dataset }; + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: registered dataset=%u", dataset); return 0; } @@ -584,11 +633,12 @@ void merkle_sync_destroy(struct UTUN_INSTANCE* inst) { inst->msync = NULL; u_free(ms); } -int merkle_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns, merkle_sync_done_cb cb, void* arg) { +int merkle_sync_start(struct UTUN_INSTANCE* inst, uint8_t dataset, uint64_t peer, const char* ns, + merkle_sync_done_cb cb, void* arg) { struct merkle_sync* ms = inst ? inst->msync : NULL; uint64_t ns_id; struct ETCP_CONN* conn = inst ? instance_find_conn(inst, peer) : NULL; - if (!ms || ms_namespace(ns, &ns_id) < 0 || !conn || !conn->initialized || !conn->links_up || conn->state == 2 + if (!ms || !ms->datasets[dataset].ops || ms_namespace(ns, &ns_id) < 0 || !conn || !conn->initialized || !conn->links_up || conn->state == 2 || conn->peer_sleep_phase || peer == inst->node_id || conn == ms->closing_conn) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: start unavailable ns=%s peer=%016llx", ns ? ns : "-", (unsigned long long)peer); return -1; @@ -599,8 +649,8 @@ int merkle_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns, if (!r) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: request allocation failed"); return -1; } r->cb = cb; r->arg = arg; } - struct ms_session* s = ms_find(ms, conn, ns_id); - if (!s) s = ms_create(ms, conn, ns_id); + struct ms_session* s = ms_find(ms, conn, dataset, ns_id); + if (!s) s = ms_create(ms, conn, dataset, ns_id); if (!s) { u_free(r); return -1; } if (s->state == MS_FAILING) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: start during terminal handoff ns=%s", ns); @@ -615,11 +665,11 @@ int merkle_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns, return 0; } -void merkle_sync_cancel(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns) { +void merkle_sync_cancel(struct UTUN_INSTANCE* inst, uint8_t dataset, uint64_t peer, const char* ns) { struct merkle_sync* ms = inst ? inst->msync : NULL; if (!ms || !ns) return; struct ms_session* s = ms->sessions; - while (s) { struct ms_session* next = s->next; if (s->peer == peer && !strcmp(s->ns, ns)) ms_free(s); s = next; } + while (s) { struct ms_session* next = s->next; if (s->dataset->id == dataset && s->peer == peer && !strcmp(s->ns, ns)) ms_free(s); s = next; } } void merkle_sync_cancel_ns(struct UTUN_INSTANCE* inst, const char* ns) { diff --git a/src/chat/merkle_sync.h b/src/chat/merkle_sync.h index 7d882c8d..1571aeb0 100644 --- a/src/chat/merkle_sync.h +++ b/src/chat/merkle_sync.h @@ -1,7 +1,7 @@ /* merkle_sync — обмен различиями между наборами записей по Merkle-дереву. * Движок сверяет хеши и передаёт страницы изменившихся листьев; модель данных * через data_ops читает записи, проверяет подписи/права и сохраняет изменения. - * Сессия относится к (пир, namespace), отправка учитывает backpressure. + * Сессия относится к (пир, dataset, namespace), отправка учитывает backpressure. * Все операции — в потоке uasync; один движок на UTUN_INSTANCE. */ #ifndef MERKLE_SYNC_H #define MERKLE_SYNC_H @@ -19,6 +19,13 @@ struct UTUN_INSTANCE; #define MT_ERR_DISCONNECTED -5 #define MT_ERR_TIMEOUT -6 #define MT_PAGE_SIZE 16384 +#define MT_KEY_SIZE 32 +#define MT_DATA_MEMBERS 0 +#define MT_DATA_FEEDBACK 1 + +/* Полный ключ курсора, big-endian; первые 8 байт задают ветвь дерева. + * Для node_id участника: BE64(node_id), затем 24 нулевых байта. */ +typedef struct { uint8_t bytes[MT_KEY_SIZE]; } merkle_key_t; /* Namespace — каноническая десятичная строка uint64_t. Движок не знает CHAT-групп. * SHA256 листа обновляется моделью в порядке unsigned key; родителей считает merkle_tree. @@ -28,8 +35,8 @@ struct merkle_sync_data_ops { /* Одна страница листа: after_valid=0 начинает обход, иначе только key > after. * len: ёмкость -> размер; next — последний ключ; more — есть продолжение. * Пустой результат разрешён только с more=0. Ошибка не кодируется пустой страницей. */ - int (*get_page)(void* ctx, const char* ns, uint64_t prefix, int after_valid, uint64_t after, - uint8_t* buf, size_t* len, uint64_t* next, int* more); + int (*get_page)(void* ctx, const char* ns, uint64_t prefix, int after_valid, const merkle_key_t* after, + uint8_t* buf, size_t* len, merkle_key_t* next, int* more); /* Проверить весь формат, применить записи и обновить дерево в одной DB-транзакции. * <0 — ошибка. Никаких broadcast/send-back из этого callback. */ int (*apply_items)(void* ctx, const char* ns, uint64_t from_peer, const uint8_t* data, size_t len); @@ -42,8 +49,8 @@ struct merkle_sync_data_ops { int (*begin_pull)(void* ctx, const char* ns, uint64_t peer, uint64_t round, void** pull); int (*commit_pull)(void* ctx, void* pull); void (*abort_pull)(void* ctx, void* pull); - int (*stage_page)(void* ctx, void* pull, uint64_t prefix, int after_valid, uint64_t after, - uint64_t next, int more, const uint8_t* data, size_t len); + int (*stage_page)(void* ctx, void* pull, uint64_t prefix, int after_valid, const merkle_key_t* after, + const merkle_key_t* next, int more, const uint8_t* data, size_t len); }; /* result — MT_OK или MT_ERR_*; ns заимствован на время callback. */ @@ -52,6 +59,10 @@ typedef void (*merkle_sync_done_cb)(uint64_t peer, const char* ns, int result, v /* Зарегистрировать сервис; нужны ua и общая SQLite. ops/data_ctx живут до destroy. 0/-1. */ int merkle_sync_init(struct UTUN_INSTANCE* inst, uint8_t svc_id, const struct merkle_sync_data_ops* ops, void* data_ctx); +/* Init регистрирует MEMBERS. Добавить другой набор до запуска его сессий; 0/-1. + * ops/data_ctx живут до уничтожения движка. Каждый набор имеет свой Merkle-корень. */ +int merkle_sync_register(struct UTUN_INSTANCE* inst, uint8_t dataset, + const struct merkle_sync_data_ops* ops, void* data_ctx); /* Отменить сессии без done callbacks, снять обработчики и освободить движок. */ void merkle_sync_destroy(struct UTUN_INSTANCE* inst); @@ -62,8 +73,9 @@ void merkle_sync_destroy(struct UTUN_INSTANCE* inst); * <0: запрос не принят, callback не вызывается. DOWN/DELETE завершают запросы ошибкой. * Явные cancel/destroy освобождают запросы без callback. * Callback может отменить сессию; уничтожение всего экземпляра должно быть отложенным. */ -int merkle_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns, merkle_sync_done_cb cb, void* arg); -void merkle_sync_cancel(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ns); +int merkle_sync_start(struct UTUN_INSTANCE* inst, uint8_t dataset, uint64_t peer, const char* ns, + merkle_sync_done_cb cb, void* arg); +void merkle_sync_cancel(struct UTUN_INSTANCE* inst, uint8_t dataset, uint64_t peer, const char* ns); /* Отменить все сессии namespace / пира без callbacks. */ void merkle_sync_cancel_ns(struct UTUN_INSTANCE* inst, const char* ns); void merkle_sync_cancel_peer(struct UTUN_INSTANCE* inst, uint64_t peer); @@ -72,10 +84,11 @@ void merkle_sync_cancel_peer(struct UTUN_INSTANCE* inst, uint64_t peer); * При внешней транзакции caller вызывает changed ПОСЛЕ commit. * Без внешней транзакции пересчёт сам создаёт savepoint и уведомляет после commit. * Возвращает 1=изменилось, 0=no-op, <0=ошибка. */ -int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, const char* ns, uint64_t key); +int merkle_sync_recompute_path(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns, uint64_t key); /* После commit уведомить сессии namespace о локальном изменении дерева. */ -void merkle_sync_changed(struct UTUN_INSTANCE* inst, const char* ns); +void merkle_sync_changed(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns); /* Прочитать хеш узла дерева в hash. 0=успех, <0=ошибка. */ -int merkle_sync_read_hash(struct UTUN_INSTANCE* inst, const char* ns, uint8_t level, uint64_t prefix, uint8_t hash[MT_HASH_SIZE]); +int merkle_sync_read_hash(struct UTUN_INSTANCE* inst, uint8_t dataset, const char* ns, + uint8_t level, uint64_t prefix, uint8_t hash[MT_HASH_SIZE]); #endif From 5e146f20bce214f2a35eaf554327faec4d96d47a Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:56:52 +0200 Subject: [PATCH 02/49] Adapt member sync and protocol tests to dataset cursors --- src/chat/member_sync.c | 23 ++++++++++++++--------- tests/test_merkle_protocol.c | 30 +++++++++++++++--------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index 28f405a1..d4bf8799 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -204,12 +204,17 @@ static int _member_update_bucket_hash(void* ctx, const char* ns, uint8_t level, return count; } -static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int after_valid, uint64_t after, - uint8_t* buf, size_t* len, uint64_t* next, int* more) { +static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int after_valid, const merkle_key_t* after, + uint8_t* buf, size_t* len, merkle_key_t* next, int* more) { struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)ctx; sqlite3* db = _db(inst); if (!db || !buf || !len) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: get_items — invalid args db=%p buf=%p len=%p", MS_ID, (void*)db, (void*)buf, (void*)len); return -1; } const uint8_t level = MT_MAX_LEVEL; - *next = 0; *more = 0; + memset(next, 0, sizeof(*next)); *more = 0; + uint64_t after_id = member_read64(after->bytes); + static const uint8_t zero_tail[MT_KEY_SIZE - 8]; + if (after_valid && memcmp(after->bytes + 8, zero_tail, sizeof(zero_tail))) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invalid member cursor ns=%s", MS_ID, ns); return -1; + } DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: get_items ns=%s L%d/P%016llx", MS_ID, ns, level, (unsigned long long)prefix); char peers_tbl[128]; _peers_table(ns, peers_tbl, sizeof(peers_tbl)); @@ -227,7 +232,7 @@ static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int afte sqlite3_stmt* stmt = NULL; if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: get_items prepare failed ns=%s", MS_ID, ns); return -1; } sqlite3_bind_int(stmt, 1, after_valid); - sqlite3_bind_int64(stmt, 2, (sqlite3_int64)after); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)after_id); size_t off = 0; if (off + 2 > *len) { sqlite3_finalize(stmt); return -2; } @@ -290,7 +295,7 @@ static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int afte if (sgn && sqlite3_column_bytes(stmt, 11) >= 64) { memcpy(buf + off, sgn, 64); off += 64; } else { memset(buf + off, 0, 64); off += 64; } cnt++; - *next = nid; + member_write64(next->bytes, nid); } if (step != SQLITE_DONE && !*more) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: page read failed ns=%s: %s", MS_ID, ns, sqlite3_errmsg(db)); sqlite3_finalize(stmt); return -1; } sqlite3_finalize(stmt); @@ -694,7 +699,7 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, /* ── recompute и подтвердить реальное изменение по хешу ── */ if (changed) { - int rc = merkle_sync_recompute_path(inst, ch_id, m->node_id); + int rc = merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, ch_id, m->node_id); if (rc < 0) return -1; if (rc == 0) changed = 0; /* запись была no-op (данные идентичны) */ } @@ -705,7 +710,7 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, } static void member_committed(struct UTUN_INSTANCE* inst, const char* ns, uint64_t node_id) { - merkle_sync_changed(inst, ns); + merkle_sync_changed(inst, MT_DATA_MEMBERS, 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); if (g && g->group_type == TOPO_GROUP_TYPE_CHAT) { @@ -1131,11 +1136,11 @@ void member_sync_destroy(struct UTUN_INSTANCE* inst) { int member_sync_start(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ch_id, merkle_sync_done_cb done_cb, void* arg) { member_sync_verify_and_purge(inst, ch_id); - return merkle_sync_start(inst, peer, ch_id, done_cb, arg); + return merkle_sync_start(inst, MT_DATA_MEMBERS, peer, ch_id, done_cb, arg); } void member_sync_cancel(struct UTUN_INSTANCE* inst, uint64_t peer, const char* ch_id) { - merkle_sync_cancel(inst, peer, ch_id); + merkle_sync_cancel(inst, MT_DATA_MEMBERS, peer, ch_id); } void member_sync_cancel_channel(struct UTUN_INSTANCE* inst, const char* ch_id) { diff --git a/tests/test_merkle_protocol.c b/tests/test_merkle_protocol.c index 4329b71a..4b219d3b 100644 --- a/tests/test_merkle_protocol.c +++ b/tests/test_merkle_protocol.c @@ -14,10 +14,10 @@ static int t_hash(void* arg, const char* ns, uint8_t level, uint64_t prefix, EVP return EVP_DigestUpdate(hash, "record", 6) == 1 ? 1 : -1; } -static int t_page(void* arg, const char* ns, uint64_t prefix, int has_after, uint64_t after, - uint8_t* data, size_t* len, uint64_t* next, int* more) { +static int t_page(void* arg, const char* ns, uint64_t prefix, int has_after, const merkle_key_t* after, + uint8_t* data, size_t* len, merkle_key_t* next, int* more) { (void)arg; (void)ns; (void)prefix; (void)has_after; (void)after; - memset(data, 0, *len); *next = 1; *more = 1; return 0; + memset(data, 0, *len); memset(next, 0, sizeof(*next)); ms_write64(next->bytes, 1); *more = 1; return 0; } static int t_apply(void* arg, const char* ns, uint64_t peer, const uint8_t* data, size_t len) { @@ -66,7 +66,7 @@ static void t_destroy(struct fixture* f) { } static struct ms_session* t_session(struct fixture* f) { - struct ms_session* s = ms_create(f->inst.msync, &f->conn, 1001); assert(s); + struct ms_session* s = ms_create(f->inst.msync, &f->conn, MT_DATA_MEMBERS, 1001); assert(s); s->round = 7; s->state = MS_PULL; s->request_id = 1; s->waiting = 1; s->requests = u_calloc(1, sizeof(*s->requests)); assert(s->requests); s->requests->cb = t_done; s->requests->arg = f; @@ -75,7 +75,7 @@ static struct ms_session* t_session(struct fixture* f) { static void t_receive(struct fixture* f, uint8_t type, uint64_t round, uint32_t request, const uint8_t* data, size_t len) { struct ll_entry* e = ll_alloc_lldgram((uint16_t)(MS_HEADER + len)); assert(e); - e->len = (uint16_t)(MS_HEADER + len); e->dgram[0] = 0x72; + e->len = (uint16_t)(MS_HEADER + len); e->dgram[0] = 0x72; e->dgram[22] = MT_DATA_MEMBERS; ms_write64(e->dgram + 1, 1001); e->dgram[9] = type; ms_write64(e->dgram + 10, round); ms_write32(e->dgram + 18, request); if (len) memcpy(e->dgram + MS_HEADER, data, len); @@ -100,7 +100,7 @@ static void test_rejected_data(void) { struct fixture f; t_init(&f); struct ms_session* s = t_session(&f); s->stack[0].level = MT_MAX_LEVEL; f.apply_error = MT_ERR_DATA; - uint8_t page[11] = {0}; + uint8_t page[MS_PAGE_HEADER + 2] = {0}; t_receive(&f, MS_PAGE, 7, 1, page, sizeof(page)); assert(!f.callbacks && s->state == MS_FAILING); uasync_poll(f.inst.ua, 0); @@ -159,9 +159,9 @@ static void test_page_size_and_cursor(void) { static void test_readonly_tree(void) { struct fixture f; t_init(&f); assert(sqlite3_exec(f.inst.topo_sqlite_db, "PRAGMA query_only=ON", NULL, NULL, NULL) == SQLITE_OK); - assert(merkle_sync_recompute_path(&f.inst, "1001", 0) < 0); + assert(merkle_sync_recompute_path(&f.inst, MT_DATA_MEMBERS, "1001", 0) < 0); uint8_t hash[32], empty[32] = {0}; - assert(merkle_sync_read_hash(&f.inst, "1001", 0, 0, hash) == 0 && !memcmp(hash, empty, 32)); + assert(merkle_sync_read_hash(&f.inst, MT_DATA_MEMBERS, "1001", 0, 0, hash) == 0 && !memcmp(hash, empty, 32)); t_destroy(&f); puts("PASS: SQLite failure is reported and cannot publish a root"); } @@ -170,11 +170,11 @@ static void test_backpressure_and_disconnect(void) { struct fixture f; t_init(&f); struct ll_entry* blocker = queue_entry_new(0); assert(blocker); assert(queue_data_put(f.conn.send_input_q, blocker) == 0); - assert(merkle_sync_start(&f.inst, 2, "1001", t_done, &f) == 0); + assert(merkle_sync_start(&f.inst, MT_DATA_MEMBERS, 2, "1001", t_done, &f) == 0); uasync_poll(f.inst.ua, 0); struct ms_session* s = f.inst.msync->sessions; assert(s->tx && s->waiter.internal); - for (int i = 0; i < 1000; i++) merkle_sync_changed(&f.inst, "1001"); + for (int i = 0; i < 1000; i++) merkle_sync_changed(&f.inst, MT_DATA_MEMBERS, "1001"); assert(s->tx && queue_entry_count(f.conn.send_input_q) == 1 && s->dirty); f.conn.links_up = 0; ms_conn_status(&f.conn, ETCP_CONN_STATUS_DOWN, f.inst.msync); @@ -210,14 +210,14 @@ static void test_send_rejection(void) { static void t_cancel_done(uint64_t peer, const char* ns, int result, void* arg) { struct fixture* f = arg; t_done(peer, ns, result, arg); - merkle_sync_cancel(&f->inst, peer, ns); + merkle_sync_cancel(&f->inst, MT_DATA_MEMBERS, peer, ns); } static void test_error_handoff(void) { struct fixture f; t_init(&f); struct ms_session* s = t_session(&f); s->requests->cb = t_cancel_done; s->stack[0].level = MT_MAX_LEVEL; f.apply_error = MT_ERR_DATA; - uint8_t page[11] = {0}; + uint8_t page[MS_PAGE_HEADER + 2] = {0}; t_receive(&f, MS_PAGE, 7, 1, page, sizeof(page)); assert(!f.callbacks && s->state == MS_FAILING); uasync_poll(f.inst.ua, 0); @@ -249,15 +249,15 @@ static int t_begin_pull(void* ctx, const char* ns, uint64_t peer, uint64_t round } static int t_commit_pull(void* ctx, void* pull) { (void)ctx; assert(pull); pull_committed++; return 0; } static void t_abort_pull(void* ctx, void* pull) { (void)ctx; assert(pull); pull_released++; u_free(pull); } -static int t_stage_page(void* ctx, void* pull, uint64_t prefix, int av, uint64_t after, - uint64_t next, int more, const uint8_t* data, size_t len) { +static int t_stage_page(void* ctx, void* pull, uint64_t prefix, int av, const merkle_key_t* after, + const merkle_key_t* next, int more, const uint8_t* data, size_t len) { (void)ctx; (void)pull; (void)prefix; (void)av; (void)after; (void)next; (void)more; (void)data; (void)len; return 0; } static void test_pull_lifecycle(void) { struct fixture f; t_init(&f); struct merkle_sync_data_ops ops = t_ops; ops.begin_pull = t_begin_pull; ops.commit_pull = t_commit_pull; ops.abort_pull = t_abort_pull; ops.stage_page = t_stage_page; - f.inst.msync->ops = &ops; + f.inst.msync->datasets[MT_DATA_MEMBERS].ops = &ops; struct ms_session* s = t_session(&f); assert(ms_pull_start(s) == 0 && s->pull); ms_discard_tx(s); s->stack[0].level = MT_MAX_LEVEL; From 894b24698e22b81f57bb8af55dfd8c4ad54c66e9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:57:25 +0200 Subject: [PATCH 03/49] Update Merkle integration tests and channel index cleanup --- src/routing_layer/topo_node_sqlite.c | 2 +- tests/test_merkle_sync.c | 51 +++++++++++++++------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index b3bd7f67..bc59c903 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -502,7 +502,7 @@ int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id) { /* 3. delete merkle tree hashes for this namespace */ sqlite3_stmt* mh = NULL; - if (sqlite3_prepare_v2(db, "DELETE FROM merkle_tree_hash WHERE namespace=?", -1, &mh, NULL) == SQLITE_OK) { + if (sqlite3_prepare_v2(db, "DELETE FROM merkle_tree_hash WHERE substr(namespace,instr(namespace,':')+1)=?", -1, &mh, NULL) == SQLITE_OK) { sqlite3_bind_text(mh, 1, channel_id, -1, SQLITE_STATIC); sqlite3_step(mh); sqlite3_finalize(mh); diff --git a/tests/test_merkle_sync.c b/tests/test_merkle_sync.c index dcb0ca16..3d59a4b6 100644 --- a/tests/test_merkle_sync.c +++ b/tests/test_merkle_sync.c @@ -112,19 +112,20 @@ static int _bucket_hash(void* ctx, const char* ns, uint8_t level, uint64_t prefi return count; } -static int _get_page(void* ctx, const char* ns, uint64_t prefix, int after_valid, uint64_t after, - uint8_t* buf, size_t* len, uint64_t* next, int* more) { +static int _get_page(void* ctx, const char* ns, uint64_t prefix, int after_valid, const merkle_key_t* after, + uint8_t* buf, size_t* len, merkle_key_t* next, int* more) { (void)ns; struct ms_data* d = ((struct ms_test_ctx*)ctx)->data; uint16_t count = 0; size_t off = 2; - *next = 0; *more = 0; + memset(next, 0, sizeof(*next)); *more = 0; + uint64_t after_id; memcpy(&after_id, after->bytes, 8); after_id = be64toh(after_id); for (int i = 0; i < d->count; i++) { if (merkle_sync_level_prefix(d->items[i].key, MT_MAX_LEVEL) != prefix - || (after_valid && d->items[i].key <= after)) continue; + || (after_valid && d->items[i].key <= after_id)) continue; if (off + 12 > *len) { if (!count) return -1; *more = 1; break; } memcpy(buf + off, &d->items[i].key, 8); off += 8; memcpy(buf + off, &d->items[i].val, 4); off += 4; - *next = d->items[i].key; count++; + uint64_t next_id = htobe64(d->items[i].key); memcpy(next->bytes, &next_id, 8); count++; } memcpy(buf, &count, 2); *len = off; return 0; } @@ -153,7 +154,7 @@ static int _apply_items(void* ctx, const char* ns, uint64_t from_peer, const uin _data_sort(d); /* Страница всегда относится к одному листу: пересчитываем его один раз. */ uint64_t key; memcpy(&key, data + 2, 8); - if (merkle_sync_recompute_path(tc->inst, ns, key) < 0) return -1; + if (merkle_sync_recompute_path(tc->inst, MT_DATA_MEMBERS, ns, key) < 0) return -1; } return 0; } @@ -164,7 +165,7 @@ static const struct merkle_sync_data_ops g_test_ops = { static const uint8_t* _test_hash(struct UTUN_INSTANCE* inst, const char* ns, uint8_t level, uint64_t prefix) { static uint8_t hash[MT_HASH_SIZE]; - if (merkle_sync_read_hash(inst, ns, level, prefix, hash) < 0) abort(); + if (merkle_sync_read_hash(inst, MT_DATA_MEMBERS, ns, level, prefix, hash) < 0) abort(); return hash; } @@ -261,9 +262,10 @@ static void test_level_prefix_bucket_partition(void) { static void _ensure_table(sqlite3* db) { if (merkle_tree_init(db) < 0) abort(); } static int _db_count_rows(sqlite3* db, const char* ns) { + char tree_ns[25]; snprintf(tree_ns, sizeof(tree_ns), "0:%s", ns); sqlite3_stmt* st = NULL; sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM merkle_tree_hash WHERE namespace=?", -1, &st, NULL); - sqlite3_bind_text(st, 1, ns, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 1, tree_ns, -1, SQLITE_STATIC); int n = 0; if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int(st, 0); sqlite3_finalize(st); @@ -271,13 +273,14 @@ static int _db_count_rows(sqlite3* db, const char* ns) { } static int _db_compare_trees(sqlite3* da, sqlite3* db, const char* ns) { + char tree_ns[25]; snprintf(tree_ns, sizeof(tree_ns), "0:%s", ns); sqlite3_stmt* sa = NULL, *sb = NULL; sqlite3_prepare_v2(da, "SELECT level,prefix64,hash FROM merkle_tree_hash WHERE namespace=? ORDER BY level,prefix64", -1, &sa, NULL); sqlite3_prepare_v2(db, "SELECT level,prefix64,hash FROM merkle_tree_hash WHERE namespace=? ORDER BY level,prefix64", -1, &sb, NULL); - sqlite3_bind_text(sa, 1, ns, -1, SQLITE_STATIC); - sqlite3_bind_text(sb, 1, ns, -1, SQLITE_STATIC); + sqlite3_bind_text(sa, 1, tree_ns, -1, SQLITE_STATIC); + sqlite3_bind_text(sb, 1, tree_ns, -1, SQLITE_STATIC); int mismatch = 0; while (1) { int ra = sqlite3_step(sa), rb = sqlite3_step(sb); @@ -306,7 +309,7 @@ static void test_tree_empty_ns(void) { inst.ua = uasync_create(); if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); - merkle_sync_recompute_path(&inst, MS_NS_TEST, 0x1234ULL); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_TEST, 0x1234ULL); if (_db_count_rows(db, MS_NS_TEST) == 0) PASS(); else FAIL("got %d rows", _db_count_rows(db, MS_NS_TEST)); merkle_sync_destroy(&inst); uasync_destroy(inst.ua, 0); sqlite3_close(db); @@ -470,7 +473,7 @@ static void test_tree_consistency(void) { sqlite3_prepare_v2(db, "SELECT level, prefix64 FROM merkle_tree_hash WHERE namespace=? ORDER BY level, prefix64", -1, &st, NULL); - sqlite3_bind_text(st, 1, "ns", -1, SQLITE_STATIC); + sqlite3_bind_text(st, 1, "0:ns", -1, SQLITE_STATIC); int ok = 1; while (sqlite3_step(st) == SQLITE_ROW && ok) { uint8_t lv = (uint8_t)sqlite3_column_int(st, 0); @@ -681,7 +684,7 @@ static void _intg_ins_many(struct ms_data* d, struct UTUN_INSTANCE* inst, int ba } _data_sort(d); for (int i = 0; i < d->count; i++) - merkle_sync_recompute_path(inst, MS_NS_TEST, d->items[i].key); + merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, MS_NS_TEST, d->items[i].key); } static int _intg_compare_data(struct ms_data* a, struct ms_data* b) { @@ -708,7 +711,7 @@ static void test_peer_empty(void) { if (_intg_init_two() != 0) { FAIL("setup failed"); return; } _intg_ins_many(&data_b, i_b, 0, 5); done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); if (!_wait_for("sync done", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("sync timeout"); _intg_cleanup(); return; } printf(" A data=%d B data=%d, A tree=%d B tree=%d\n", @@ -778,8 +781,8 @@ static void test_randomized_two(void) { _data_insert(&data_b, k, (uint32_t)rand()); } _data_sort(&data_a); _data_sort(&data_b); - for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MS_NS_RND, data_a.items[i].key); - for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MS_NS_RND, data_b.items[i].key); + for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_RND, data_a.items[i].key); + for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_RND, data_b.items[i].key); done_sync = 0; merkle_sync_start(i_a, i_b->node_id, MS_NS_RND, _on_sync_done, NULL); @@ -877,15 +880,15 @@ static void test_randomized_three(void) { for (int i = 0; i < nc; i++) { uint64_t k = ((uint64_t)rand()<<32)|(uint64_t)rand(); _data_insert(&data_c, k, (uint32_t)rand()); } _data_sort(&data_a); for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MS_NS_RND, data_a.items[i].key); _data_sort(&data_b); for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MS_NS_RND, data_b.items[i].key); - _data_sort(&data_c); for (int i = 0; i < data_c.count; i++) merkle_sync_recompute_path(i_c, MS_NS_RND, data_c.items[i].key); + _data_sort(&data_c); for (int i = 0; i < data_c.count; i++) merkle_sync_recompute_path(i_c, MT_DATA_MEMBERS, MS_NS_RND, data_c.items[i].key); done_sync = 0; i_phase = 0; /* B→A: B syncs with A, gets A's data, B: SYNCED */ - merkle_sync_start(i_b, i_a->node_id, MS_NS_RND, _on_sync_done, NULL); + merkle_sync_start(i_b, MT_DATA_MEMBERS, i_a->node_id, MS_NS_RND, _on_sync_done, NULL); if (!_wait_for("sync B", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("timeout B iter %d", iter); _intg_cleanup(); break; } done_sync = 0; i_phase = 0; /* C→A: C syncs with A, gets A∪B, A broadcasts C's new items to B (relay) */ - merkle_sync_start(i_c, i_a->node_id, MS_NS_RND, _on_sync_done, NULL); + merkle_sync_start(i_c, MT_DATA_MEMBERS, i_a->node_id, MS_NS_RND, _on_sync_done, NULL); if (!_wait_for("sync C", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("timeout C iter %d", iter); _intg_cleanup(); break; } /* Автоматический следующий раунд A↔B завершается по условию, не по числу poll(). */ if (!_wait_for("automatic convergence of all peers", _cond_all_synced, PHASE3_TIMEOUT_TB)) { @@ -909,7 +912,7 @@ static void test_cancel(void) { _intg_ins_many(&data_b, i_b, 0, 5); done_sync = 0; merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); - merkle_sync_cancel(i_a, i_b->node_id, MS_NS_TEST); + merkle_sync_cancel(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST); for (int i = 0; i < 100 && i_phase == 0; i++) uasync_poll(i_ua, 10); if (!done_sync) PASS(); else FAIL("done_cb was called"); _intg_cleanup(); @@ -1014,8 +1017,8 @@ static void _str_spam_cb(void* arg) { /* spammer: bump own member, sync to hub */ uint64_t key = ((uint64_t)idx) << 60; _data_insert(&gs->data[idx], key, (uint32_t)(gs->data[idx].count + 1)); - merkle_sync_recompute_path(gs->inst[idx], MS_NS_STRESS, key); - merkle_sync_start(gs->inst[idx], gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); + merkle_sync_recompute_path(gs->inst[idx], MT_DATA_MEMBERS, MS_NS_STRESS, key); + merkle_sync_start(gs->inst[idx], MT_DATA_MEMBERS, MT_DATA_MEMBERS, gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); _str_spam_schedule(gs, idx); } @@ -1142,12 +1145,12 @@ static int _str_init(void) { for (int i = 1; i < STRESS_N; i++) { uint64_t key = ((uint64_t)i) << 60; _data_insert(&s->data[i], key, (uint32_t)i); - merkle_sync_recompute_path(s->inst[i], MS_NS_STRESS, key); + merkle_sync_recompute_path(s->inst[i], MT_DATA_MEMBERS, MS_NS_STRESS, key); } /* initial sync: each spoke → hub */ s->sync_pending = STRESS_N - 1; for (int i = 1; i < STRESS_N; i++) - merkle_sync_start(s->inst[i], s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_sync_done, s); + merkle_sync_start(s->inst[i], MT_DATA_MEMBERS, MT_DATA_MEMBERS, s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_sync_done, s); _str_wait_for("initial sync", _str_cond_sync_done, STRESS_SYNC_TB); /* observers: sync with hub too */ From f6bfd36a6c0104596ad47ffb30559527fe247285 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:57:58 +0200 Subject: [PATCH 04/49] Include dataset identity in Merkle diagnostics --- src/chat/merkle_sync.c | 28 ++++++++++++++-------------- tests/test_member_adapter.c | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/chat/merkle_sync.c b/src/chat/merkle_sync.c index d850f04f..958ce7a1 100644 --- a/src/chat/merkle_sync.c +++ b/src/chat/merkle_sync.c @@ -173,8 +173,8 @@ static struct ms_session* ms_create(struct merkle_sync* ms, struct ETCP_CONN* co snprintf(s->tree_ns, sizeof(s->tree_ns), "%u:%s", dataset, s->ns); s->leader = ms->inst->node_id < s->peer; s->next = ms->sessions; ms->sessions = s; - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: attach ns=%s peer=%016llx conn=%p leader=%d", - s->ns, (unsigned long long)s->peer, (void*)conn, s->leader); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: attach dataset=%u ns=%s peer=%016llx conn=%p leader=%d", + s->dataset->id, s->ns, (unsigned long long)s->peer, (void*)conn, s->leader); return s; } @@ -208,8 +208,8 @@ static void ms_sent(struct ll_queue* q, void* arg) { return; } s->sent_bytes += len; - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: TX ns=%s peer=%016llx round=%llu type=%u bytes=%zu", - s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, type, len); + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: TX dataset=%u ns=%s peer=%016llx round=%llu type=%u bytes=%zu", + s->dataset->id, s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, type, len); if (type == MS_ERROR) { s->state = MS_FAILED; ms_stop_deadline(s); @@ -221,8 +221,8 @@ static void ms_sent(struct ll_queue* q, void* arg) { ms_stop_deadline(s); if (s->revision == s->verified_revision) { s->dirty = 0; - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: converged ns=%s peer=%016llx round=%llu tx=%llu rx=%llu", - s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: converged dataset=%u ns=%s peer=%016llx round=%llu tx=%llu rx=%llu", + s->dataset->id, s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, (unsigned long long)s->sent_bytes, (unsigned long long)s->received_bytes); ms_notify(s, MT_OK); return; @@ -254,8 +254,8 @@ static int ms_send(struct ms_session* s, int type, uint32_t request, const uint8 } static void ms_fail(struct ms_session* s, int error, const char* reason, int report) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: failed ns=%s peer=%016llx round=%llu error=%d reason=%s", - s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, error, reason); + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: failed dataset=%u ns=%s peer=%016llx round=%llu error=%d reason=%s", + s->dataset->id, s->ns, (unsigned long long)s->peer, (unsigned long long)s->round, error, reason); s->state = MS_FAILED; s->dirty = 0; s->waiting = 0; s->failure_error = error; if (s->work) { uasync_call_soon_cancel(s->ms->inst->ua, s->work); s->work = NULL; } ms_discard_tx(s); @@ -333,8 +333,8 @@ static int ms_begin(struct ms_session* s) { if (!s->round) return -1; s->request_id = s->remote_request_id = 0; s->state = MS_BEGIN_WAIT; s->dirty = 0; s->waiting = 0; - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: begin ns=%s peer=%016llx round=%llu", - s->ns, (unsigned long long)s->peer, (unsigned long long)s->round); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: begin dataset=%u ns=%s peer=%016llx round=%llu", + s->dataset->id, s->ns, (unsigned long long)s->peer, (unsigned long long)s->round); return ms_send(s, MS_BEGIN, 0, NULL, 0); } @@ -485,8 +485,8 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { s->received_bytes += entry->len; const uint8_t* p = d + MS_HEADER; size_t len = entry->len - MS_HEADER; - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: RX ns=%s peer=%016llx round=%llu type=%u request=%u bytes=%zu state=%d", - ns, (unsigned long long)s->peer, (unsigned long long)round, type, request, len, s->state); + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: RX dataset=%u ns=%s peer=%016llx round=%llu type=%u request=%u bytes=%zu state=%d", + dataset, ns, (unsigned long long)s->peer, (unsigned long long)round, type, request, len, s->state); int rc = 0, notify = 0; if (s->state == MS_FAILING) goto done; if (type != MS_WAKE && type != MS_BEGIN && round != s->round) { @@ -550,8 +550,8 @@ static void ms_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { s->dirty = memcmp(root, p, sizeof(root)) != 0; if (s->dirty) rc = ms_schedule(s); else { - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: converged ns=%s peer=%016llx round=%llu", - s->ns, (unsigned long long)s->peer, (unsigned long long)s->round); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "merkle_sync: converged dataset=%u ns=%s peer=%016llx round=%llu", + s->dataset->id, s->ns, (unsigned long long)s->peer, (unsigned long long)s->round); notify = 1; } } diff --git a/tests/test_member_adapter.c b/tests/test_member_adapter.c index 505a26bc..f0906b8f 100644 --- a/tests/test_member_adapter.c +++ b/tests/test_member_adapter.c @@ -17,7 +17,7 @@ static void put(struct UTUN_INSTANCE* inst, uint64_t id) { } static void root(struct UTUN_INSTANCE* inst, uint8_t hash[32]) { - assert(merkle_sync_read_hash(inst, "1001", 0, 0, hash) == 0); + assert(merkle_sync_read_hash(inst, MT_DATA_MEMBERS, "1001", 0, 0, hash) == 0); } static void public_key(const uint8_t priv[32], uint8_t pub[32]) { @@ -86,7 +86,7 @@ static void test_owner_sizes(struct UASYNC* ua) { root(&target, actual); assert(!memcmp(expected, actual, 32)); assert(sqlite3_exec(target.topo_sqlite_db, "SAVEPOINT hash_tail", NULL, NULL, NULL) == SQLITE_OK); assert(topo_node_sqlite_member_owner_put(target.topo_sqlite_db, "1001", nid, tags, owner, 0) == 0); - assert(merkle_sync_recompute_path(&target, "1001", nid) == 1); + assert(merkle_sync_recompute_path(&target, MT_DATA_MEMBERS, MT_DATA_MEMBERS, "1001", nid) == 1); root(&target, actual); assert(memcmp(expected, actual, 32)); assert(sqlite3_exec(target.topo_sqlite_db, "ROLLBACK TO hash_tail; RELEASE hash_tail", NULL, NULL, NULL) == SQLITE_OK); tags[flip] = 'a'; From f0314ab51c4af7da0cc470d1ef89087ceebda790 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:59:00 +0200 Subject: [PATCH 05/49] Test dataset isolation and colliding cursor prefixes --- tests/test_member_adapter.c | 18 +++++------ tests/test_merkle_protocol.c | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/tests/test_member_adapter.c b/tests/test_member_adapter.c index f0906b8f..51817507 100644 --- a/tests/test_member_adapter.c +++ b/tests/test_member_adapter.c @@ -63,9 +63,9 @@ static void test_owner_sizes(struct UASYNC* ua) { assert(sc_ed25519_sign(owner_priv, msg, n, owner) == SC_OK); assert(member_sync_apply_record(&source, "1001", source.node_id, &rec) & MS_APPLY_CHANGED); assert(member_sync_verify_local_record(source.topo_sqlite_db, "1001", &rec) == 1); - size_t bytes = sizeof(page); uint64_t next; int more; - assert(_member_get_page(&source, "1001", merkle_sync_level_prefix(nid, 5), 0, 0, page, &bytes, &next, &more) == 0); - assert(next == nid && !more && bytes < MT_PAGE_SIZE); + size_t bytes = sizeof(page); merkle_key_t next, after = {{0}}; int more; + assert(_member_get_page(&source, "1001", merkle_sync_level_prefix(nid, 5), 0, &after, page, &bytes, &next, &more) == 0); + assert(member_read64(next.bytes) == nid && !more && bytes < MT_PAGE_SIZE); struct member_wire w; size_t used; assert(member_decode(page + 2, bytes - 2, &w, &used) == 0 && used == bytes - 2 && !strcmp(w.rec.adm_tags, tags)); assert(!strcmp(w.rec.userinfo, userinfo)); @@ -144,17 +144,17 @@ int main(void) { put(&source, 1); put(&source, 2); uint8_t raw[2049], *page = raw + 1; /* проверяем невыровненный буфер */ - size_t len = 300; uint64_t next; int more; - assert(_member_get_page(&source, "1001", 0, 0, 0, page, &len, &next, &more) == 0); - assert(page[0] == 0 && page[1] == 1 && next == 1 && more == 1 && len <= 300); + size_t len = 300; merkle_key_t next, after = {{0}}; int more; + assert(_member_get_page(&source, "1001", 0, 0, &after, page, &len, &next, &more) == 0); + assert(page[0] == 0 && page[1] == 1 && member_read64(next.bytes) == 1 && more == 1 && len <= 300); struct member_wire w; size_t used; assert(member_decode(page + 2, len - 2, &w, &used) == 0 && w.rec.node_id == 1 && used == len - 2); len = 300; - assert(_member_get_page(&source, "1001", 0, 1, next, page, &len, &next, &more) == 0 && next == 2 && !more); + assert(_member_get_page(&source, "1001", 0, 1, &next, page, &len, &next, &more) == 0 && member_read64(next.bytes) == 2 && !more); puts("PASS: bounded member pages, canonical integers and unaligned buffers"); len = 2048; - assert(_member_get_page(&source, "1001", 0, 0, 0, page, &len, &next, &more) == 0 && page[1] == 2 && !more); + assert(_member_get_page(&source, "1001", 0, 0, &after, page, &len, &next, &more) == 0 && page[1] == 2 && !more); assert(_member_apply_items(&target, "1001", 1, page, len - 1) < 0 && member_sync_count(&target, "1001") == 0); sqlite3* db = target.topo_sqlite_db; assert(sqlite3_exec(db, "CREATE TRIGGER fail_second BEFORE INSERT ON peers_1001 WHEN NEW.node_id=2 " @@ -171,7 +171,7 @@ int main(void) { assert(merkle_sync_recompute_path(&target, "1001", 1) < 0); root(&target, hash); assert(!memcmp(hash, expected, 32)); len = 2048; - assert(_member_get_page(&target, "1001", 0, 0, 0, page, &len, &next, &more) < 0); + assert(_member_get_page(&target, "1001", 0, 0, &after, page, &len, &next, &more) < 0); puts("PASS: corrupt BLOB cannot be hashed or serialized"); member_sync_destroy(&target); member_sync_destroy(&source); diff --git a/tests/test_merkle_protocol.c b/tests/test_merkle_protocol.c index 4b219d3b..b867a90e 100644 --- a/tests/test_merkle_protocol.c +++ b/tests/test_merkle_protocol.c @@ -272,6 +272,67 @@ static void test_pull_lifecycle(void) { puts("PASS: pull commits once; restart and disconnect abort exactly their own context"); } +/* Сессии и корни двух моделей одного канала не влияют друг на друга. */ +static void test_dataset_isolation(void) { + struct fixture f; t_init(&f); + assert(merkle_sync_register(&f.inst, MT_DATA_FEEDBACK, &t_ops, &f) == 0); + assert(merkle_sync_register(&f.inst, MT_DATA_FEEDBACK, &t_ops, &f) < 0); + assert(merkle_sync_recompute_path(&f.inst, MT_DATA_MEMBERS, "1001", 0) == 1); + uint8_t member[32], feedback[32], empty[32] = {0}; + assert(merkle_sync_read_hash(&f.inst, MT_DATA_MEMBERS, "1001", 0, 0, member) == 0); + assert(merkle_sync_read_hash(&f.inst, MT_DATA_FEEDBACK, "1001", 0, 0, feedback) == 0); + assert(memcmp(member, empty, 32) && !memcmp(feedback, empty, 32)); + assert(merkle_sync_recompute_path(&f.inst, MT_DATA_FEEDBACK, "1001", 0) == 1); + struct ms_session* a = ms_create(f.inst.msync, &f.conn, MT_DATA_MEMBERS, 1001); assert(a); + struct ms_session* b = ms_create(f.inst.msync, &f.conn, MT_DATA_FEEDBACK, 1001); assert(b); + assert(a != b && ms_find(f.inst.msync, &f.conn, MT_DATA_MEMBERS, 1001) == a); + a->state = b->state = MS_SERVE; + merkle_sync_changed(&f.inst, MT_DATA_FEEDBACK, "1001"); + assert(!a->dirty && !a->revision && b->dirty && b->revision == 1); + merkle_sync_cancel(&f.inst, MT_DATA_FEEDBACK, 2, "1001"); + assert(f.inst.msync->sessions == a && !a->next); + merkle_sync_cancel_ns(&f.inst, "1001"); assert(!f.inst.msync->sessions); + t_destroy(&f); + puts("PASS: dataset roots, sessions, changes and cancellation are independent"); +} + +/* Два разных полных ключа с одинаковыми первыми 64 битами проходят пагинацию. */ +static int t_full_page(void* ctx, const char* ns, uint64_t prefix, int av, const merkle_key_t* after, + uint8_t* data, size_t* len, merkle_key_t* next, int* more) { + (void)ctx; (void)ns; (void)prefix; + assert(*len >= 1); + *next = *after; ms_write64(next->bytes, 1); + next->bytes[MT_KEY_SIZE - 1] = av ? 2 : 1; + data[0] = next->bytes[MT_KEY_SIZE - 1]; *len = 1; *more = !av; + return 0; +} + +static void test_full_cursor(void) { + struct fixture f; t_init(&f); + struct merkle_sync_data_ops ops = t_ops; ops.get_page = t_full_page; + assert(merkle_sync_register(&f.inst, MT_DATA_FEEDBACK, &ops, &f) == 0); + struct ms_session* s = ms_create(f.inst.msync, &f.conn, MT_DATA_FEEDBACK, 1001); assert(s); + s->state = MS_SERVE; + uint8_t query[MS_QUERY_SIZE] = { MT_MAX_LEVEL }; + assert(ms_serve_query(s, 1, query, sizeof(query)) == 0); + assert(s->tx->dgram[22] == MT_DATA_FEEDBACK && s->tx->dgram[MS_HEADER] == 1); + memcpy(query + 10, s->tx->dgram + MS_HEADER + 1, MT_KEY_SIZE); query[9] = 1; + ms_discard_tx(s); + assert(ms_serve_query(s, 2, query, sizeof(query)) == 0); + assert(s->tx->dgram[MS_HEADER] == 0 && s->tx->dgram[MS_HEADER + MT_KEY_SIZE] == 2); + uint8_t page[MS_PAGE_HEADER + 1]; memcpy(page, s->tx->dgram + MS_HEADER, sizeof(page)); + ms_discard_tx(s); + s->state = MS_PULL; s->stack[0].level = MT_MAX_LEVEL; s->waiting = 1; s->request_id = 1; + memcpy(s->after.bytes, query + 10, MT_KEY_SIZE); s->after_valid = 1; + page[0] = 1; + assert(ms_receive_page(s, 1, page, sizeof(page)) == 0); + assert(s->after.bytes[MT_KEY_SIZE - 1] == 2 && s->request_id == 2); + ms_discard_tx(s); + assert(ms_receive_page(s, 2, page, sizeof(page)) == MT_ERR_PROTOCOL); + t_destroy(&f); + puts("PASS: full cursors preserve colliding 64-bit prefixes and reject backwards progress"); +} + int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); size_t baseline = u_get_allocated_count(); @@ -279,6 +340,7 @@ int main(void) { test_page_size_and_cursor(); test_readonly_tree(); test_backpressure_and_disconnect(); test_peer_restart(); test_send_rejection(); test_error_handoff(); test_blocked_error_timeout(); test_pull_lifecycle(); + test_dataset_isolation(); test_full_cursor(); assert(u_get_allocated_count() == baseline); puts("ALL PASS: protocol regressions, no tracked allocations leaked"); return 0; From 49dc837df2f83712428010692522d1a5698b31c5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:59:43 +0200 Subject: [PATCH 06/49] Correct all member dataset arguments in callers --- src/chat/member_sync.c | 6 ++--- tests/test_member_adapter.c | 4 +-- tests/test_merkle_sync.c | 54 ++++++++++++++++++------------------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index d4bf8799..4ad14333 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -699,7 +699,7 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, /* ── recompute и подтвердить реальное изменение по хешу ── */ if (changed) { - int rc = merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, ch_id, m->node_id); + int rc = merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ch_id, m->node_id); if (rc < 0) return -1; if (rc == 0) changed = 0; /* запись была no-op (данные идентичны) */ } @@ -862,7 +862,7 @@ int member_sync_verify_and_purge(struct UTUN_INSTANCE* inst, const char* ch_id) DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: verify_and_purge — PURGE broken record nid=0x%016llx ns=%s", MS_ID, (unsigned long long)nid, ch_id); topo_node_sqlite_member_del(db, ch_id, nid); - merkle_sync_recompute_path(inst, ch_id, nid); + merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ch_id, nid); /* пассивное удаление: если узел подключён напрямую — убрать из CHAT-группы */ if (inst->topo_groups) { uint64_t gid = strtoull(ch_id, NULL, 10); @@ -1061,7 +1061,7 @@ static int _rebuild_all_trees(struct UTUN_INSTANCE* inst) { if (lr == SQLITE_OK) { while ((lr = sqlite3_step(leaves)) == SQLITE_ROW) { uint64_t key = (uint64_t)sqlite3_column_int64(leaves, 0); - if (merkle_sync_recompute_path(inst, ns, key) < 0) { lr = SQLITE_ERROR; break; } + if (merkle_sync_recompute_path(inst, MT_DATA_MEMBERS, ns, key) < 0) { lr = SQLITE_ERROR; break; } } } sqlite3_finalize(leaves); diff --git a/tests/test_member_adapter.c b/tests/test_member_adapter.c index 51817507..129b41d8 100644 --- a/tests/test_member_adapter.c +++ b/tests/test_member_adapter.c @@ -86,7 +86,7 @@ static void test_owner_sizes(struct UASYNC* ua) { root(&target, actual); assert(!memcmp(expected, actual, 32)); assert(sqlite3_exec(target.topo_sqlite_db, "SAVEPOINT hash_tail", NULL, NULL, NULL) == SQLITE_OK); assert(topo_node_sqlite_member_owner_put(target.topo_sqlite_db, "1001", nid, tags, owner, 0) == 0); - assert(merkle_sync_recompute_path(&target, MT_DATA_MEMBERS, MT_DATA_MEMBERS, "1001", nid) == 1); + assert(merkle_sync_recompute_path(&target, MT_DATA_MEMBERS, "1001", nid) == 1); root(&target, actual); assert(memcmp(expected, actual, 32)); assert(sqlite3_exec(target.topo_sqlite_db, "ROLLBACK TO hash_tail; RELEASE hash_tail", NULL, NULL, NULL) == SQLITE_OK); tags[flip] = 'a'; @@ -168,7 +168,7 @@ int main(void) { puts("PASS: malformed page rejected; second-record error rolls back data and tree; complete page converges"); assert(sqlite3_exec(db, "UPDATE peers_1001 SET x25519_pubkey=X'01' WHERE node_id=1", NULL, NULL, NULL) == SQLITE_OK); - assert(merkle_sync_recompute_path(&target, "1001", 1) < 0); + assert(merkle_sync_recompute_path(&target, MT_DATA_MEMBERS, "1001", 1) < 0); root(&target, hash); assert(!memcmp(hash, expected, 32)); len = 2048; assert(_member_get_page(&target, "1001", 0, 0, &after, page, &len, &next, &more) < 0); diff --git a/tests/test_merkle_sync.c b/tests/test_merkle_sync.c index 3d59a4b6..c22ce9f3 100644 --- a/tests/test_merkle_sync.c +++ b/tests/test_merkle_sync.c @@ -309,7 +309,7 @@ static void test_tree_empty_ns(void) { inst.ua = uasync_create(); if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); - merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_TEST, 0x1234ULL); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, 0x1234ULL); if (_db_count_rows(db, MS_NS_TEST) == 0) PASS(); else FAIL("got %d rows", _db_count_rows(db, MS_NS_TEST)); merkle_sync_destroy(&inst); uasync_destroy(inst.ua, 0); sqlite3_close(db); @@ -330,7 +330,7 @@ static void test_tree_single_item(void) { inst.ua = uasync_create(); if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); - merkle_sync_recompute_path(&inst, MS_NS_TEST, key); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, key); int rows = _db_count_rows(db, MS_NS_TEST); if (rows != 6) { FAIL("expected 6 rows got %d", rows); merkle_sync_destroy(&inst); uasync_destroy(inst.ua, 0); sqlite3_close(db); return; } @@ -365,8 +365,8 @@ static void test_tree_multi_item_same_bucket(void) { if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); /* recompute for both */ - merkle_sync_recompute_path(&inst, MS_NS_TEST, k1); - merkle_sync_recompute_path(&inst, MS_NS_TEST, k2); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, k1); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, k2); uint64_t pf1 = merkle_sync_level_prefix(k1, 1); uint64_t pf2 = merkle_sync_level_prefix(k2, 1); @@ -397,8 +397,8 @@ static void test_tree_different_buckets(void) { inst.ua = uasync_create(); if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); - merkle_sync_recompute_path(&inst, "ns", kA); - merkle_sync_recompute_path(&inst, "ns", kB); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, "ns", kA); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, "ns", kB); uint64_t pfA = merkle_sync_level_prefix(kA, 1); uint64_t pfB = merkle_sync_level_prefix(kB, 1); @@ -427,12 +427,12 @@ static void test_tree_delete_empty_bucket(void) { inst.ua = uasync_create(); if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); - merkle_sync_recompute_path(&inst, MS_NS_TEST, k); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, k); if (_db_count_rows(db, MS_NS_TEST) != 6) { FAIL("expected 6 rows"); merkle_sync_destroy(&inst); uasync_destroy(inst.ua, 0); sqlite3_close(db); return; } /* remove item and recompute */ data.count = 0; - merkle_sync_recompute_path(&inst, MS_NS_TEST, k); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, MS_NS_TEST, k); if (_db_count_rows(db, MS_NS_TEST) != 0) { FAIL("expected 0 rows got %d", _db_count_rows(db, MS_NS_TEST)); merkle_sync_destroy(&inst); uasync_destroy(inst.ua, 0); sqlite3_close(db); return; } uint64_t pf = merkle_sync_level_prefix(k, 3); @@ -466,7 +466,7 @@ static void test_tree_consistency(void) { if (merkle_sync_init(&inst, 0x72, &g_test_ops, &ctx) != 0) abort(); for (int i = 0; i < data.count; i++) - merkle_sync_recompute_path(&inst, "ns", data.items[i].key); + merkle_sync_recompute_path(&inst, MT_DATA_MEMBERS, "ns", data.items[i].key); /* verify: recompute each bucket, compare with stored hash */ sqlite3_stmt* st = NULL; @@ -711,7 +711,7 @@ static void test_peer_empty(void) { if (_intg_init_two() != 0) { FAIL("setup failed"); return; } _intg_ins_many(&data_b, i_b, 0, 5); done_sync = 0; - merkle_sync_start(i_a, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); if (!_wait_for("sync done", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("sync timeout"); _intg_cleanup(); return; } printf(" A data=%d B data=%d, A tree=%d B tree=%d\n", @@ -730,7 +730,7 @@ static void test_hash_match(void) { _intg_ins_many(&data_a, i_a, 0, 5); _intg_ins_many(&data_b, i_b, 0, 5); done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); for (int i = 0; i < 200 && !done_sync && i_phase == 0; i++) uasync_poll(i_ua, 1); if (!done_sync) { FAIL("sync did not complete quickly"); _intg_cleanup(); return; } if (_intg_verify_all(i_a, i_b, &data_a, &data_b, MS_NS_TEST) != 0) @@ -745,7 +745,7 @@ static void test_divergence_merge(void) { _intg_ins_many(&data_a, i_a, 0, 3); _intg_ins_many(&data_b, i_b, 10, 2); done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); if (!_wait_for("sync done", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("sync timeout"); _intg_cleanup(); return; } if (_intg_verify_all(i_a, i_b, &data_a, &data_b, MS_NS_TEST) != 0) @@ -781,11 +781,11 @@ static void test_randomized_two(void) { _data_insert(&data_b, k, (uint32_t)rand()); } _data_sort(&data_a); _data_sort(&data_b); - for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_RND, data_a.items[i].key); - for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MT_DATA_MEMBERS, MS_NS_RND, data_b.items[i].key); + for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MT_DATA_MEMBERS, MS_NS_RND, data_a.items[i].key); + for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MS_NS_RND, data_b.items[i].key); done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_RND, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_RND, _on_sync_done, NULL); if (!_wait_for("sync", _cond_done, PHASE_TIMEOUT_TB)) { FAIL("timeout iter %d", iter); _intg_cleanup(); break; } if (_intg_verify_all(i_a, i_b, &data_a, &data_b, MS_NS_RND) != 0) { printf(" A data=%d B data=%d A tree=%d B tree=%d\n", @@ -878,8 +878,8 @@ static void test_randomized_three(void) { for (int i = 0; i < na; i++) { uint64_t k = ((uint64_t)rand()<<32)|(uint64_t)rand(); _data_insert(&data_a, k, (uint32_t)rand()); } for (int i = 0; i < nb; i++) { uint64_t k = ((uint64_t)rand()<<32)|(uint64_t)rand(); _data_insert(&data_b, k, (uint32_t)rand()); } for (int i = 0; i < nc; i++) { uint64_t k = ((uint64_t)rand()<<32)|(uint64_t)rand(); _data_insert(&data_c, k, (uint32_t)rand()); } - _data_sort(&data_a); for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MS_NS_RND, data_a.items[i].key); - _data_sort(&data_b); for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MS_NS_RND, data_b.items[i].key); + _data_sort(&data_a); for (int i = 0; i < data_a.count; i++) merkle_sync_recompute_path(i_a, MT_DATA_MEMBERS, MS_NS_RND, data_a.items[i].key); + _data_sort(&data_b); for (int i = 0; i < data_b.count; i++) merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MS_NS_RND, data_b.items[i].key); _data_sort(&data_c); for (int i = 0; i < data_c.count; i++) merkle_sync_recompute_path(i_c, MT_DATA_MEMBERS, MS_NS_RND, data_c.items[i].key); done_sync = 0; i_phase = 0; @@ -911,7 +911,7 @@ static void test_cancel(void) { if (_intg_init_two() != 0) { FAIL("setup failed"); return; } _intg_ins_many(&data_b, i_b, 0, 5); done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); merkle_sync_cancel(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST); for (int i = 0; i < 100 && i_phase == 0; i++) uasync_poll(i_ua, 10); if (!done_sync) PASS(); else FAIL("done_cb was called"); @@ -927,8 +927,8 @@ static void test_start_overwrite(void) { if (_intg_init_two() != 0) { FAIL("setup failed"); return; } _intg_ins_many(&data_b, i_b, 0, 3); sw_cb1_called = sw_cb2_called = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _sw_done1, NULL); - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _sw_done2, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _sw_done1, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _sw_done2, NULL); for (int i = 0; i < 500 && !sw_cb2_called && i_phase == 0; i++) uasync_poll(i_ua, 10); if (sw_cb1_called && sw_cb2_called) PASS(); else FAIL("cb1=%d cb2=%d", sw_cb1_called, sw_cb2_called); _intg_cleanup(); @@ -1018,7 +1018,7 @@ static void _str_spam_cb(void* arg) { uint64_t key = ((uint64_t)idx) << 60; _data_insert(&gs->data[idx], key, (uint32_t)(gs->data[idx].count + 1)); merkle_sync_recompute_path(gs->inst[idx], MT_DATA_MEMBERS, MS_NS_STRESS, key); - merkle_sync_start(gs->inst[idx], MT_DATA_MEMBERS, MT_DATA_MEMBERS, gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); + merkle_sync_start(gs->inst[idx], MT_DATA_MEMBERS, gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); _str_spam_schedule(gs, idx); } @@ -1027,7 +1027,7 @@ static void _str_obs_spam_cb(void* arg) { if (!gs || !gs->spam_active) return; int idx = (int)(intptr_t)arg; - merkle_sync_start(gs->inst[idx], gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); + merkle_sync_start(gs->inst[idx], MT_DATA_MEMBERS, gs->inst[STRESS_HUB]->node_id, MS_NS_STRESS, NULL, NULL); int delay_tb = ((rand() % 10) + 1) * 10; /* 1-10ms */ uasync_set_timeout(gs->ua, (uint32_t)delay_tb, arg, _str_obs_spam_cb, "obs_spam"); @@ -1150,7 +1150,7 @@ static int _str_init(void) { /* initial sync: each spoke → hub */ s->sync_pending = STRESS_N - 1; for (int i = 1; i < STRESS_N; i++) - merkle_sync_start(s->inst[i], MT_DATA_MEMBERS, MT_DATA_MEMBERS, s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_sync_done, s); + merkle_sync_start(s->inst[i], MT_DATA_MEMBERS, s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_sync_done, s); _str_wait_for("initial sync", _str_cond_sync_done, STRESS_SYNC_TB); /* observers: sync with hub too */ @@ -1211,7 +1211,7 @@ static void _str_start_final_round(void) { s->final_pending = STRESS_N - 1; printf(" final round %d: syncing %d spokes -> hub\n", s->final_round, s->final_pending); for (int i = 1; i < STRESS_N; i++) - merkle_sync_start(s->inst[i], s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_final_done, NULL); + merkle_sync_start(s->inst[i], MT_DATA_MEMBERS, s->inst[STRESS_HUB]->node_id, MS_NS_STRESS, _str_final_done, NULL); } static void _str_final_done(uint64_t peer, const char* ns, int result, void* arg) { @@ -1352,14 +1352,14 @@ static void test_large_leaf_and_automatic_update(void) { if (_intg_init_two() != 0) { FAIL("setup failed"); return; } for (uint64_t i = 0; i < 6000; i++) _data_insert(&data_b, i, (uint32_t)i); _data_sort(&data_b); - if (merkle_sync_recompute_path(i_b, MS_NS_TEST, 0) < 0) { FAIL("recompute failed"); _intg_cleanup(); return; } + if (merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MS_NS_TEST, 0) < 0) { FAIL("recompute failed"); _intg_cleanup(); return; } done_sync = 0; - merkle_sync_start(i_a, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); + merkle_sync_start(i_a, MT_DATA_MEMBERS, i_b->node_id, MS_NS_TEST, _on_sync_done, NULL); if (!_wait_for("large leaf", _cond_done, PHASE_TIMEOUT_TB) || !_cond_pair_equal()) { FAIL("large leaf did not converge"); _intg_cleanup(); return; } data_b.items[0].val = 9999; - merkle_sync_recompute_path(i_b, MS_NS_TEST, 0); + merkle_sync_recompute_path(i_b, MT_DATA_MEMBERS, MS_NS_TEST, 0); if (!_wait_for("automatic change", _cond_pair_equal, PHASE_TIMEOUT_TB)) { FAIL("automatic change did not propagate"); _intg_cleanup(); return; } From 864a250eea952542cbe94aac976ebe70ade36fc6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:02:41 +0200 Subject: [PATCH 07/49] Preserve input cursor when member page outputs alias it --- src/chat/chat_feedback.h | 47 ++++++++++++++++++++++++++++++++++++++++ src/chat/member_sync.c | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/chat/chat_feedback.h diff --git a/src/chat/chat_feedback.h b/src/chat/chat_feedback.h new file mode 100644 index 00000000..a9f83831 --- /dev/null +++ b/src/chat/chat_feedback.h @@ -0,0 +1,47 @@ +/* Подписанные состояния реакций и ответов, отдельная таблица feedback_. + * Каждый (message_id, node_id, kind) хранит последнее полное состояние с ver. + * Пустое data — сохраняемая отмена. Слой хранит и реплицирует подписанные байты; + * интерпретацию ответа относительно опроса выполняет вызывающий модуль. + * Все операции — в uasync-потоке. init вызывается после member_sync_init; + * общий Merkle-движок освобождается member_sync_destroy. */ +#ifndef CHAT_FEEDBACK_H +#define CHAT_FEEDBACK_H + +#include +#include + +struct UTUN_INSTANCE; + +#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; + 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]; +}; + +/* Производный ID неизменяемого сообщения; 0/-1. Числа хешируются как BE64. */ +int chat_message_id(uint64_t channel_id, uint64_t timestamp, const uint8_t signature[64], uint8_t id[32]); +/* Регистрирует FEEDBACK и восстанавливает производный индекс из подписанных записей; 0/-1. */ +int chat_feedback_init(struct UTUN_INSTANCE* inst); +/* Подготовить таблицу канала; 0/-1. Не создаёт членство или сетевую группу. */ +int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel); +/* Прочитать состояние: 1 найдено, 0 отсутствует, -1 ошибка. */ +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 проверяет вызывающий модуль до этого вызова. */ +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); + +#endif diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index 4ad14333..eccc82f2 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -209,12 +209,12 @@ static int _member_get_page(void* ctx, const char* ns, uint64_t prefix, int afte struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)ctx; sqlite3* db = _db(inst); if (!db || !buf || !len) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: get_items — invalid args db=%p buf=%p len=%p", MS_ID, (void*)db, (void*)buf, (void*)len); return -1; } const uint8_t level = MT_MAX_LEVEL; - memset(next, 0, sizeof(*next)); *more = 0; uint64_t after_id = member_read64(after->bytes); static const uint8_t zero_tail[MT_KEY_SIZE - 8]; if (after_valid && memcmp(after->bytes + 8, zero_tail, sizeof(zero_tail))) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invalid member cursor ns=%s", MS_ID, ns); return -1; } + memset(next, 0, sizeof(*next)); *more = 0; DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: get_items ns=%s L%d/P%016llx", MS_ID, ns, level, (unsigned long long)prefix); char peers_tbl[128]; _peers_table(ns, peers_tbl, sizeof(peers_tbl)); From 641a8318a87ba96e1bdd90a6ab509fa8253c4b30 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:05:18 +0200 Subject: [PATCH 08/49] Add signed versioned feedback storage and Merkle adapter --- src/chat/chat_feedback.c | 425 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 src/chat/chat_feedback.c diff --git a/src/chat/chat_feedback.c b/src/chat/chat_feedback.c new file mode 100644 index 00000000..37182e74 --- /dev/null +++ b/src/chat/chat_feedback.c @@ -0,0 +1,425 @@ +#include "chat_feedback.h" +#include "merkle_sync.h" +#include "member_sync.h" +#include "chat_event.h" +#include "../utun_instance.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../transport_layer/secure_channel.h" +#include "../../lib/platform_compat.h" +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include +#include +#include +#include + +#define FB_FIXED_SIZE 115 +#define FB_WIRE_MAX (FB_FIXED_SIZE + CHAT_FEEDBACK_DATA_MAX) + +static void fb_write64(uint8_t* p, uint64_t n) { n = htobe64(n); memcpy(p, &n, 8); } +static uint64_t fb_read64(const uint8_t* p) { uint64_t n; memcpy(&n, p, 8); return be64toh(n); } + +/* SQL-имя строится только из канонического числового ID. */ +static int fb_table(struct UTUN_INSTANCE* inst, const char* channel, char table[40], uint64_t* id) { + if (!inst || !inst->topo_sqlite_db || !channel || !channel[0] || strlen(channel) > 19 || channel[0] == '0') goto invalid; + for (const char* p = channel; *p; p++) if (*p < '0' || *p > '9') goto invalid; + errno = 0; char* end; + uint64_t n = strtoull(channel, &end, 10); + if (errno || *end || n > INT64_MAX) goto invalid; + snprintf(table, 40, "feedback_%s", channel); if (id) *id = n; + return 0; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid channel=%s", channel ? channel : "-"); return -1; +} + +/* Общая диагностика ошибок SQLite. */ +static int fb_db_error(struct UTUN_INSTANCE* inst, const char* channel, const char* op) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: %s ch=%s: %s", op, channel, + inst && inst->topo_sqlite_db ? sqlite3_errmsg(inst->topo_sqlite_db) : "no DB"); + return -1; +} + +/* Канонический ID сообщения не зависит от локального порядкового номера. */ +int chat_message_id(uint64_t channel_id, uint64_t timestamp, const uint8_t signature[64], uint8_t id[32]) { + static const char domain[] = "utun-chat-message-v1"; + uint8_t body[sizeof(domain) - 1 + 16 + 64]; + if (!channel_id || !signature || !id) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid message identity arguments"); return -1; + } + memcpy(body, domain, sizeof(domain) - 1); + fb_write64(body + sizeof(domain) - 1, channel_id); + fb_write64(body + sizeof(domain) - 1 + 8, timestamp); + memcpy(body + sizeof(domain) - 1 + 16, signature, 64); + if (EVP_Digest(body, sizeof(body), id, NULL, EVP_sha256(), NULL) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: message identity digest failed"); return -1; + } + return 0; +} + +/* Каноническая запись: message:32,node:8,kind:1,ver:8,len:2,data:len,signature:64. */ +static int fb_encode(const struct chat_feedback_record* r, uint8_t* wire) { + if (!r || !r->node_id || r->node_id > INT64_MAX || !r->ver || r->ver > INT64_MAX + || (r->kind != CHAT_FEEDBACK_REACTION && r->kind != CHAT_FEEDBACK_ANSWER) || r->data_len > CHAT_FEEDBACK_DATA_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid record fields"); return -1; + } + memcpy(wire, r->message_id, 32); fb_write64(wire + 32, r->node_id); wire[40] = r->kind; + fb_write64(wire + 41, r->ver); wire[49] = r->data_len >> 8; wire[50] = r->data_len & 255; + memcpy(wire + 51, r->data, r->data_len); memcpy(wire + 51 + r->data_len, r->signature, 64); + return FB_FIXED_SIZE + r->data_len; +} + +/* Читает ровно одну запись без предположений о выравнивании входного буфера. */ +static int fb_decode(const uint8_t* wire, size_t len, struct chat_feedback_record* r) { + if (len < FB_FIXED_SIZE) goto invalid; + uint16_t bytes = ((uint16_t)wire[49] << 8) | wire[50]; + if (bytes > CHAT_FEEDBACK_DATA_MAX || len < FB_FIXED_SIZE + (size_t)bytes) goto invalid; + memset(r, 0, sizeof(*r)); memcpy(r->message_id, wire, 32); r->node_id = fb_read64(wire + 32); + r->kind = wire[40]; r->ver = fb_read64(wire + 41); r->data_len = bytes; + memcpy(r->data, wire + 51, bytes); memcpy(r->signature, wire + 51 + bytes, 64); + if (!r->node_id || r->node_id > INT64_MAX || !r->ver || r->ver > INT64_MAX + || (r->kind != CHAT_FEEDBACK_REACTION && r->kind != CHAT_FEEDBACK_ANSWER)) goto invalid; + return FB_FIXED_SIZE + bytes; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: malformed record bytes=%zu", len); return -1; +} + +/* Подпись связывает канал с записью; ключ дерева связывает неизменяемую тройку. */ +static int fb_signed_bytes(uint64_t channel, const struct chat_feedback_record* r, uint8_t* out) { + static const char domain[] = "utun-chat-feedback-v1"; + memcpy(out, domain, sizeof(domain) - 1); fb_write64(out + sizeof(domain) - 1, channel); + int n = fb_encode(r, out + sizeof(domain) - 1 + 8); + return n < 0 ? -1 : (int)(sizeof(domain) - 1 + 8) + n - 64; +} + +static int fb_key(uint64_t channel, const struct chat_feedback_record* r, merkle_key_t* key) { + uint8_t identity[49]; fb_write64(identity, channel); memcpy(identity + 8, r->message_id, 32); + fb_write64(identity + 40, r->node_id); identity[48] = r->kind; + if (EVP_Digest(identity, sizeof(identity), key->bytes, NULL, EVP_sha256(), NULL) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: record key digest failed"); return -1; + } + return 0; +} + +/* Членство и привязка Ed25519 к X25519 подтверждаются существующими подписями мембера. */ +static int fb_author_key(struct UTUN_INSTANCE* inst, const char* channel, uint64_t author, uint8_t key[32]) { + char sql[384]; + snprintf(sql, sizeof(sql), "SELECT x25519_pubkey,ed25519_pubkey,signed_by,signature,join_sig,join_ts" + " FROM peers_%s WHERE node_id=? AND source=0", channel); + sqlite3_stmt* st = NULL; sqlite3* db = inst->topo_sqlite_db; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "author lookup"); + sqlite3_bind_int64(st, 1, (sqlite3_int64)author); + int rc = sqlite3_step(st), ok = 0; + if (rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_bytes(st, 1) == 32 + && sqlite3_column_bytes(st, 3) == 64 && sqlite3_column_bytes(st, 4) == 64 && sqlite3_column_int64(st, 5) > 0) { + struct ms_member_rec member = { .node_id = author, .x25519 = sqlite3_column_blob(st, 0), + .ed25519 = sqlite3_column_blob(st, 1), .signed_by = (uint64_t)sqlite3_column_int64(st, 2), + .signature = sqlite3_column_blob(st, 3), .join_sig = sqlite3_column_blob(st, 4), + .join_ts = (uint64_t)sqlite3_column_int64(st, 5) }; + uint8_t x[32], ed[32], body[112]; + int n = topo_node_sqlite_channel_get(db, channel, NULL, 0, NULL, x, ed, NULL); + if (n == 0) n = member_sync_build_join_msg(x, ed, author, member.x25519, member.join_ts, body, sizeof(body)); + ok = n == 112 && sc_derive_node_id_from_pubkey(member.x25519) == author + && member_sync_verify_signature(db, channel, &member) == 1 + && sc_ed25519_verify(member.ed25519, body, n, member.join_sig) == SC_OK; + if (ok) memcpy(key, member.ed25519, 32); + } + sqlite3_finalize(st); + if (ok) return 0; + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "feedback: author not verified ch=%s node=%016llx lookup=%d", + channel, (unsigned long long)author, rc); return -1; +} + +/* Создаёт одну таблицу подписанных состояний и индекс обхода листьев. */ +int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel) { + char table[40], sql[1024]; uint64_t id; + if (fb_table(inst, channel, table, &id) < 0) return -1; + if (topo_node_sqlite_channel_get(inst->topo_sqlite_db, channel, NULL, 0, NULL, NULL, NULL, NULL) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: unknown channel=%s", channel); return -1; + } + snprintf(sql, sizeof(sql), "CREATE TABLE IF NOT EXISTS %s(" + "record_key BLOB PRIMARY KEY CHECK(length(record_key)=32)," + "bucket INTEGER NOT NULL,message_id BLOB NOT NULL CHECK(length(message_id)=32)," + "node_id INTEGER NOT NULL,kind INTEGER NOT NULL,ver INTEGER NOT NULL CHECK(ver>0)," + "data BLOB NOT NULL,signature BLOB NOT NULL CHECK(length(signature)=64)," + "UNIQUE(message_id,node_id,kind));" + "CREATE INDEX IF NOT EXISTS idx_%s_bucket ON %s(bucket,record_key)", table, table, table); + if (sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL) != SQLITE_OK) return fb_db_error(inst, channel, "ensure table"); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: table ready ch=%s table=%s", channel, table); return 0; +} + +/* Преобразует проверенные столбцы SQLite в самостоятельную запись. */ +static int fb_read_row(sqlite3_stmt* st, struct chat_feedback_record* r) { + int bytes = sqlite3_column_bytes(st, 4); + if (sqlite3_column_bytes(st, 0) != 32 || bytes < 0 || bytes > CHAT_FEEDBACK_DATA_MAX + || sqlite3_column_bytes(st, 5) != 64) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: corrupt local record"); return -1; + } + memset(r, 0, sizeof(*r)); memcpy(r->message_id, sqlite3_column_blob(st, 0), 32); + r->node_id = (uint64_t)sqlite3_column_int64(st, 1); r->kind = sqlite3_column_int(st, 2); + r->ver = (uint64_t)sqlite3_column_int64(st, 3); r->data_len = bytes; + if (bytes) memcpy(r->data, sqlite3_column_blob(st, 4), bytes); + memcpy(r->signature, sqlite3_column_blob(st, 5), 64); return 0; +} + +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) { + char table[40], sql[256]; + if (fb_table(inst, channel, table, NULL) < 0 || !message_id || !out) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid lookup arguments"); return -1; + } + 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"); + return result; +} + +/* Применяет внутри транзакции; уведомления строго после commit вызывающего. */ +static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) { + char table[40], sql[512]; uint64_t id; uint8_t body[FB_WIRE_MAX + 32], key[32]; + if (fb_table(inst, channel, table, &id) < 0) return -1; + int bytes = fb_signed_bytes(id, r, body); + if (bytes < 0 || fb_author_key(inst, channel, r->node_id, key) < 0) return -1; + if (sc_ed25519_verify(key, body, bytes, r->signature) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid signature ch=%s node=%016llx kind=%u ver=%llu", + channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver); return -1; + } + struct chat_feedback_record old; + int found = chat_feedback_get(inst, channel, r->message_id, r->node_id, r->kind, &old); + if (found < 0) return -1; + if (found) { + int cmp = memcmp(r->data, old.data, r->data_len < old.data_len ? r->data_len : old.data_len); + if (!cmp) cmp = (int)r->data_len - old.data_len; + if (r->ver == old.ver && cmp) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, + "feedback: same-version conflict ch=%s node=%016llx kind=%u ver=%llu incoming_wins=%d", + channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver, cmp > 0); + if (r->ver < old.ver || (r->ver == old.ver && cmp <= 0)) { + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "feedback: unchanged ch=%s node=%016llx kind=%u ver=%llu current=%llu", + channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver, (unsigned long long)old.ver); + return 0; + } + } + merkle_key_t record_key; if (fb_key(id, r, &record_key) < 0) return -1; + 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"); + 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 node=%016llx kind=%u ver=%llu previous=%llu bytes=%u", + channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver, + (unsigned long long)(found ? old.ver : 0), r->data_len); return 1; +} + +/* Событие указывает конкретное сообщение; данные SQLite уже опубликованы. */ +static void fb_committed(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) { + uint8_t event[54]; size_t len = strlen(channel); event[0] = len; + memcpy(event + 1, channel, len); memcpy(event + 1 + len, r->message_id, 32); event[33 + len] = r->kind; + chat_event_post(inst, CHAT_EVT_FEEDBACK_CHANGED, event, (int)len + 34); +} + +/* Завершает savepoint, при ошибке не публикует частичные изменения. */ +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"); + return -1; +} + +int chat_feedback_apply(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) { + char table[40]; + if (fb_table(inst, channel, table, NULL) < 0 || !r) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid apply arguments"); return -1; + } + 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) + 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); } + return result; +} + +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) { + char table[40]; uint64_t id; + 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; + } + 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 && old.ver == INT64_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: version exhausted ch=%s", channel); return -1; + } + 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; + } + int result = chat_feedback_apply(inst, channel, &r); + 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); + return result; +} + +/* Читает лист в полном лексикографическом порядке record_key. */ +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]; + if (fb_table(inst, channel, table, NULL) < 0 || *len < 2) { + 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 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); + 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]; + if (fb_read_row(st, &r) < 0 || sqlite3_column_bytes(st, 6) != 32) { result = -1; break; } + int bytes = fb_encode(&r, wire); if (bytes < 0) { result = -1; break; } + if (off + bytes > *len) { + if (!count) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: record exceeds page capacity ch=%s", channel); result = -1; } + else *more = 1; + break; + } + memcpy(data + off, wire, bytes); off += bytes; count++; + 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; +} + +/* Хеширует подписанные записи листа в том же порядке, что и страничный обход. */ +static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) { + struct UTUN_INSTANCE* inst = ctx; char table[40], sql[256]; + if (fb_table(inst, channel, table, NULL) < 0 || level != MT_MAX_LEVEL) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid hash level=%u", level); return -1; + } + snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature 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 step, count = 0; + while ((step = sqlite3_step(st)) == SQLITE_ROW) { + struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX]; + if (fb_read_row(st, &r) < 0) { count = -1; break; } + int bytes = fb_encode(&r, wire); + if (bytes < 0 || EVP_DigestUpdate(hash, wire, bytes) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: leaf digest failed ch=%s", channel); count = -1; break; + } + count++; + } + sqlite3_finalize(st); + if (count >= 0 && step != SQLITE_DONE) return fb_db_error(inst, channel, "hash step"); + return count; +} + +/* Вся страница применяется атомарно; события отправляются после её 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; } + 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; + } + for (unsigned i = 0; i < count; i++) { + struct chat_feedback_record r; int bytes = fb_decode(data + off, len - off, &r); + if (bytes < 0) return -1; + off += bytes; + } + if (off != len) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: trailing page bytes=%zu", len - off); return -1; } + 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) { + u_free(changed); return fb_db_error(inst, channel, "page begin"); + } + off = 2; int result = 0; + for (unsigned i = 0; i < count; i++) { + struct chat_feedback_record r; int bytes = fb_decode(data + off, len - off, &r); + if (bytes < 0) { result = -1; break; } + int rc = fb_apply_row(inst, channel, &r); if (rc < 0) { result = -1; break; } + changed[i] = rc > 0; off += bytes; + } + result = fb_finish_transaction(inst, channel, result); + if (result == 0) { + off = 2; int any = 0; + for (unsigned i = 0; i < count; i++) { + struct chat_feedback_record r; int bytes = fb_decode(data + off, len - off, &r); off += bytes; + if (changed[i]) { fb_committed(inst, channel, &r); any = 1; } + } + if (any) merkle_sync_changed(inst, MT_DATA_FEEDBACK, channel); + } + u_free(changed); return result; +} + +static int fb_peer(void* ctx, const char* channel, uint64_t peer) { + struct UTUN_INSTANCE* inst = ctx; uint8_t key[32]; + return fb_author_key(inst, channel, peer, key) == 0; +} + +static const struct merkle_sync_data_ops fb_ops = { + .update_bucket_hash = fb_hash, .get_page = fb_page, .apply_items = fb_apply_page, .validate_peer = fb_peer +}; + +/* Перестраивает индекс после регистрации модели; подписи проверяются повторно. */ +int chat_feedback_init(struct UTUN_INSTANCE* inst) { + if (merkle_sync_register(inst, MT_DATA_FEEDBACK, &fb_ops, inst) < 0) return -1; + sqlite3_stmt* channels = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &channels, NULL) != SQLITE_OK) + return fb_db_error(inst, "-", "init channels"); + int step, result = 0, records = 0; + while ((step = sqlite3_step(channels)) == SQLITE_ROW) { + char channel[21]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(channels, 0)); + char table[40], sql[256]; + if (chat_feedback_ensure_channel(inst, channel) < 0 || fb_table(inst, channel, table, NULL) < 0) { result = -1; break; } + snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature FROM %s", table); + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { + result = fb_db_error(inst, channel, "init records"); break; + } + int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + struct chat_feedback_record r; uint64_t id; uint8_t body[FB_WIRE_MAX + 32], key[32]; merkle_key_t record_key; + if (fb_read_row(st, &r) < 0 || fb_table(inst, channel, table, &id) < 0 + || fb_author_key(inst, channel, r.node_id, key) < 0) { result = -1; break; } + int bytes = fb_signed_bytes(id, &r, body); + if (bytes < 0 || sc_ed25519_verify(key, body, bytes, r.signature) != SC_OK || fb_key(id, &r, &record_key) < 0 + || merkle_sync_recompute_path(inst, MT_DATA_FEEDBACK, channel, fb_read64(record_key.bytes)) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: rebuild failed ch=%s node=%016llx", + channel, (unsigned long long)r.node_id); result = -1; break; + } + records++; + } + sqlite3_finalize(st); + if (result < 0) break; + if (rc != SQLITE_DONE) { result = fb_db_error(inst, channel, "init records step"); break; } + } + sqlite3_finalize(channels); + if (result == 0 && step != SQLITE_DONE) return fb_db_error(inst, "-", "init channels step"); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "feedback: initialization result=%d records=%d", result, records); return result; +} From 8121f93d2e166ccbe7997a346e521239f7c43197 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:06:38 +0200 Subject: [PATCH 09/49] Build feedback model and expose committed state events --- src/Makefile.am | 4 ++++ src/chat/chat_event.h | 1 + 2 files changed, 5 insertions(+) diff --git a/src/Makefile.am b/src/Makefile.am index 3a5f3800..4b18fa40 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -108,6 +108,10 @@ utun_CORE_SOURCES = \ chat/chat_core.c \ chat/chat_member.c \ chat/chat_msg.c \ + chat/chat_feedback.c \ + chat/chat_feedback.h \ + chat/chat_feedback.c \ + chat/chat_feedback.h \ chat/chat_whisper.c \ chat/chat_channel.c \ chat/chat_profile.c \ diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 6c12837d..7299ab3e 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -74,6 +74,7 @@ extern "C" { #define CHAT_EVT_MEMBER_FILES_TRANSFER 50 /* id/group/server/owner/done/total:8LE,status:2LE,state:1 */ #define CHAT_EVT_AVATAR_RESULT 51 /* request_id/group:8LE, failed:1; результат публикации ссылки профиля */ #define CHAT_EVT_GROUP_AVATAR_RESULT 52 /* request_id/group:8LE, failed:1; результат публикации аватарки группы */ +#define CHAT_EVT_FEEDBACK_CHANGED 53 /* [channel_len:1][channel][message_id:32][kind:1] — состояние после commit */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); From 982e5a06ab6b1a7e39d2c97fabb561ed213185b6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:06:39 +0200 Subject: [PATCH 10/49] Integrate feedback tables with channel and sync lifecycle --- src/chat/chat_channel.c | 2 ++ src/chat/chat_sync.c | 14 ++++++++++++++ src/routing_layer/topo_node_sqlite.c | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/src/chat/chat_channel.c b/src/chat/chat_channel.c index a2fbdb36..d3ed0f21 100644 --- a/src/chat/chat_channel.c +++ b/src/chat/chat_channel.c @@ -7,6 +7,7 @@ #include "chat_core_priv.h" #include "group_avatar.h" #include "chat_event.h" +#include "chat_feedback.h" #include "chat_setting.h" #include "../utun_instance.h" @@ -61,6 +62,7 @@ static int channel_sign_join(const uint8_t* ch_x25519, const uint8_t* ch_ed25519 void chat_core_ensure_channel_ready(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; + if (chat_feedback_ensure_channel(inst, ch_id) < 0) return; if (si_find(inst, ch_id)) return; char tbl_msg[80]; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg)); diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index ac3bcf0b..03c2ec93 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -14,6 +14,7 @@ #include "../../lib/json_flat.h" #include "member_sync.h" #include "merkle_sync.h" +#include "chat_feedback.h" #include "../utun_instance.h" #include "../transport_layer/etcp_api.h" @@ -537,12 +538,21 @@ static void cs_resume_db_sync(struct chat_sync* cs, const char* ch_id) { if (si) db_sync_instance_set_gated(si, 0); } +/* Подтверждение feedback не управляет готовностью участников или истории сообщений. */ +static void cs_feedback_done(uint64_t peer, const char* channel, int result, void* arg) { + (void)arg; + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: feedback sync ch=%s peer=%016llx result=%d", + CS_ID, channel, (unsigned long long)peer, result); +} + static void _on_member_sync_done(uint64_t peer, const char* ns, int result, void* arg) { struct chat_sync* cs = (struct chat_sync*)arg; if (result == MT_OK) { struct channel_cache* ch = cs ? cs_find(cs, ns) : NULL; if (ch) ch->synced = CS_SYNC_DONE; cs_resume_db_sync(cs, ns); + if (cs && merkle_sync_start(cs->inst, MT_DATA_FEEDBACK, peer, ns, cs_feedback_done, cs) < 0) + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "%s: feedback sync unavailable ch=%s peer=%016llx", CS_ID, ns, (unsigned long long)peer); } DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: member_sync %s ns=%s peer=%016llx", CS_ID, result == MT_OK ? "OK" : "FAIL", ns, (unsigned long long)peer); @@ -753,6 +763,10 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: member synchronization initialization failed", CS_ID); inst->chat_sync = NULL; u_free(cs); return -1; } + if (chat_feedback_init(inst) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: feedback initialization failed", CS_ID); + member_sync_destroy(inst); inst->chat_sync = NULL; u_free(cs); return -1; + } if (group_avatar_init(inst) < 0) { member_sync_destroy(inst); inst->chat_sync = NULL; u_free(cs); return -1; } diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index bc59c903..c2d74619 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -491,6 +491,10 @@ int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id) { sqlite3_exec(db, sql, NULL, NULL, NULL); snprintf(sql, sizeof(sql), "DROP TABLE IF EXISTS \"%s\"", msg_full); sqlite3_exec(db, sql, NULL, NULL, NULL); + snprintf(sql, sizeof(sql), "DROP TABLE IF EXISTS \"feedback_%s\"", msg_tbl); + if (sqlite3_exec(db, sql, NULL, NULL, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: delete channel=%s: %s", channel_id, sqlite3_errmsg(db)); return -1; + } /* 2. delete channels row (вместе с канальными privkey) */ sqlite3_stmt* st = NULL; From f6cc3b6e5353b9649008ec42892f0a54fceb64b7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:07:44 +0200 Subject: [PATCH 11/49] Make feedback roots depend on signed state and validate rebuilt keys --- src/chat/chat_feedback.c | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/chat/chat_feedback.c b/src/chat/chat_feedback.c index 37182e74..7c236fc3 100644 --- a/src/chat/chat_feedback.c +++ b/src/chat/chat_feedback.c @@ -133,9 +133,6 @@ static int fb_author_key(struct UTUN_INSTANCE* inst, const char* channel, uint64 int chat_feedback_ensure_channel(struct UTUN_INSTANCE* inst, const char* channel) { char table[40], sql[1024]; uint64_t id; if (fb_table(inst, channel, table, &id) < 0) return -1; - if (topo_node_sqlite_channel_get(inst->topo_sqlite_db, channel, NULL, 0, NULL, NULL, NULL, NULL) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: unknown channel=%s", channel); return -1; - } snprintf(sql, sizeof(sql), "CREATE TABLE IF NOT EXISTS %s(" "record_key BLOB PRIMARY KEY CHECK(length(record_key)=32)," "bucket INTEGER NOT NULL,message_id BLOB NOT NULL CHECK(length(message_id)=32)," @@ -189,6 +186,8 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid signature ch=%s node=%016llx kind=%u ver=%llu", channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver); return -1; } + char message_hex[65]; + for (unsigned i = 0; i < 32; i++) snprintf(message_hex + 2 * i, 3, "%02x", r->message_id[i]); struct chat_feedback_record old; int found = chat_feedback_get(inst, channel, r->message_id, r->node_id, r->kind, &old); if (found < 0) return -1; @@ -217,8 +216,8 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return fb_db_error(inst, channel, "put step"); 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 node=%016llx kind=%u ver=%llu previous=%llu bytes=%u", - channel, (unsigned long long)r->node_id, r->kind, (unsigned long long)r->ver, + 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, (unsigned long long)(found ? old.ver : 0), r->data_len); return 1; } @@ -313,7 +312,7 @@ static int fb_page(void* ctx, const char* channel, uint64_t prefix, int av, cons sqlite3_finalize(st); data[0] = count >> 8; data[1] = count & 255; *len = off; return result; } -/* Хеширует подписанные записи листа в том же порядке, что и страничный обход. */ +/* Хеширует подписанные тела: разные валидные подписи одного тела не меняют корень. */ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefix, EVP_MD_CTX* hash) { struct UTUN_INSTANCE* inst = ctx; char table[40], sql[256]; if (fb_table(inst, channel, table, NULL) < 0 || level != MT_MAX_LEVEL) { @@ -328,7 +327,7 @@ static int fb_hash(void* ctx, const char* channel, uint8_t level, uint64_t prefi struct chat_feedback_record r; uint8_t wire[FB_WIRE_MAX]; if (fb_read_row(st, &r) < 0) { count = -1; break; } int bytes = fb_encode(&r, wire); - if (bytes < 0 || EVP_DigestUpdate(hash, wire, bytes) != 1) { + if (bytes < 0 || EVP_DigestUpdate(hash, wire, bytes - 64) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: leaf digest failed ch=%s", channel); count = -1; break; } count++; @@ -397,7 +396,7 @@ int chat_feedback_init(struct UTUN_INSTANCE* inst) { char channel[21]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(channels, 0)); char table[40], sql[256]; if (chat_feedback_ensure_channel(inst, channel) < 0 || fb_table(inst, channel, table, NULL) < 0) { result = -1; break; } - snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature FROM %s", table); + snprintf(sql, sizeof(sql), "SELECT message_id,node_id,kind,ver,data,signature,record_key,bucket FROM %s", table); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) { result = fb_db_error(inst, channel, "init records"); break; @@ -413,6 +412,11 @@ int chat_feedback_init(struct UTUN_INSTANCE* inst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: rebuild failed ch=%s node=%016llx", channel, (unsigned long long)r.node_id); result = -1; break; } + if (sqlite3_column_bytes(st, 6) != 32 || memcmp(record_key.bytes, sqlite3_column_blob(st, 6), 32) + || (uint64_t)sqlite3_column_int64(st, 7) != (fb_read64(record_key.bytes) >> 39)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: corrupt local index ch=%s node=%016llx", + channel, (unsigned long long)r.node_id); result = -1; break; + } records++; } sqlite3_finalize(st); From 4f4e63bb0b09f4f9b9bd08503d2007f8b86769da Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:08:53 +0200 Subject: [PATCH 12/49] Test feedback signatures, versions, cancellation and atomic pages --- tests/Makefile.am | 4 + tests/test_chat_feedback.c | 158 +++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/test_chat_feedback.c diff --git a/tests/Makefile.am b/tests/Makefile.am index 82462da7..33dbe5e3 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -84,6 +84,7 @@ check_PROGRAMS = \ test_db_sync \ test_merkle_sync \ test_merkle_protocol \ + test_chat_feedback \ test_member_adapter \ test_chat_sync_stress \ test_chat_join \ @@ -488,6 +489,9 @@ test_db_sync_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_db_sync_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) test_merkle_sync_SOURCES = test_merkle_sync.c +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_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) diff --git a/tests/test_chat_feedback.c b/tests/test_chat_feedback.c new file mode 100644 index 00000000..4c620284 --- /dev/null +++ b/tests/test_chat_feedback.c @@ -0,0 +1,158 @@ +/* Реальные SQLite, подписи и Merkle-адаптер: версии, отмены, конфликты и rollback. */ +#include "../src/chat/chat_feedback.c" +#include + +static const uint8_t owner_private[32] = {9}; +static int events; + +/* Событие должно видеть опубликованную транзакцию. */ +static void on_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { + (void)data; (void)len; + if (type == CHAT_EVT_FEEDBACK_CHANGED) { assert(sqlite3_get_autocommit(inst->topo_sqlite_db)); events++; } +} + +static void public_key(const uint8_t priv[32], uint8_t pub[32]) { + EVP_PKEY* pk = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, priv, 32); assert(pk); + size_t len = 32; assert(EVP_PKEY_get_raw_public_key(pk, pub, &len) == 1); EVP_PKEY_free(pk); +} + +/* Экземпляры используют один uasync данного потока, но разные БД и идентичности. */ +static void setup(struct UTUN_INSTANCE* inst, struct UASYNC* ua) { + 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(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); + assert(topo_node_sqlite_channel_put(inst->topo_sqlite_db, "1002", "two", 1, x, NULL, ed, NULL, zero) == 0); + assert(member_sync_init(inst) == 0 && chat_feedback_init(inst) == 0); + chat_event_set_handler(inst, on_event); +} + +/* Настоящие подписи join и дерева приглашений связывают автора с каналом. */ +static void add_member(struct UTUN_INSTANCE* dst, const struct UTUN_INSTANCE* author, const char* channel) { + uint8_t x[32] = {3}, ed[32], join[64], tree[64], body[112]; public_key(owner_private, ed); + assert(member_sync_build_join_msg(x, ed, author->node_id, author->my_keys.public_key, 1, body, sizeof(body)) == 112); + assert(sc_ed25519_sign(author->my_ed25519_privkey, body, sizeof(body), join) == SC_OK); + assert(member_sync_sign_pubkey(owner_private, author->my_keys.public_key, tree) == 0); + assert(member_sync_put(dst, channel, author->node_id, author->my_keys.public_key, author->my_ed25519_pubkey, + join, 1, NULL, 0, "", NULL, NULL, 0, 0, tree) >= 0); +} + +static void root(struct UTUN_INSTANCE* inst, uint8_t out[32]) { + assert(merkle_sync_read_hash(inst, MT_DATA_FEEDBACK, "1001", 0, 0, out) == 0); +} + +static void sign_record(const struct UTUN_INSTANCE* author, uint64_t channel, struct chat_feedback_record* r) { + uint8_t body[FB_WIRE_MAX + 32]; int bytes = fb_signed_bytes(channel, r, body); assert(bytes > 0); + assert(sc_ed25519_sign(author->my_ed25519_privkey, body, bytes, r->signature) == SC_OK); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + 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); + for (int ch = 0; ch < 2; ch++) { + const char* channel = ch ? "1002" : "1001"; + add_member(&source, &source, channel); add_member(&target, &source, channel); + add_member(&source, &target, channel); add_member(&target, &target, channel); + } + uint8_t message[32], other_message[32], signature[64] = {7}; + assert(chat_message_id(1001, 123, signature, message) == 0); + assert(chat_message_id(1002, 123, signature, other_message) == 0 && memcmp(message, other_message, 32)); + assert(chat_message_id(1001, 123, signature, other_message) == 0 && !memcmp(message, other_message, 32)); + signature[63]++; + assert(chat_message_id(1001, 123, signature, other_message) == 0 && memcmp(message, other_message, 32)); + puts("PASS: message identity is deterministic and binds channel, timestamp and signature"); + + uint64_t ver; struct chat_feedback_record first, second, cancelled, actual; + uint8_t a[] = {1}, b[] = {1, 2}; + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 1 && ver == 1); + assert(chat_feedback_get(&source, "1001", message, source.node_id, CHAT_FEEDBACK_REACTION, &first) == 1); + int saved_events = events; + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 0 && ver == 1); + assert(events == saved_events); + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_REACTION, b, sizeof(b), &ver) == 1 && ver == 2); + assert(chat_feedback_get(&source, "1001", message, source.node_id, CHAT_FEEDBACK_REACTION, &second) == 1); + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, &ver) == 1 && ver == 3); + assert(chat_feedback_get(&source, "1001", message, source.node_id, CHAT_FEEDBACK_REACTION, &cancelled) == 1); + assert(cancelled.ver == 3 && !cancelled.data_len); + assert(chat_feedback_apply(&target, "1001", &cancelled) == 1); + assert(chat_feedback_apply(&target, "1001", &first) == 0 && chat_feedback_apply(&target, "1001", &second) == 0); + assert(chat_feedback_get(&target, "1001", message, source.node_id, CHAT_FEEDBACK_REACTION, &actual) == 1); + assert(actual.ver == 3 && !actual.data_len); + uint8_t expected[32], received[32]; root(&source, expected); root(&target, received); assert(!memcmp(expected, received, 32)); + puts("PASS: ver increases; duplicates are inert; old states cannot resurrect a cancellation"); + + struct chat_feedback_record forged = cancelled; forged.ver++; + root(&target, expected); saved_events = events; + assert(chat_feedback_apply(&target, "1001", &forged) < 0 && events == saved_events); + root(&target, received); assert(!memcmp(expected, received, 32)); + assert(chat_feedback_apply(&target, "1002", &cancelled) < 0); + forged = cancelled; forged.node_id = target.node_id; + assert(chat_feedback_apply(&target, "1001", &forged) < 0); + puts("PASS: signature binds version, channel and author; rejection preserves tree and events"); + + struct chat_feedback_record low = first, high = first; + low.ver = high.ver = 4; low.data[0] = 3; high.data[0] = 4; + sign_record(&source, 1001, &low); sign_record(&source, 1001, &high); + assert(chat_feedback_apply(&source, "1001", &low) == 1 && chat_feedback_apply(&source, "1001", &high) == 1); + assert(chat_feedback_apply(&target, "1001", &high) == 1 && chat_feedback_apply(&target, "1001", &low) == 0); + root(&source, expected); root(&target, received); assert(!memcmp(expected, received, 32)); + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_ANSWER, b, sizeof(b), &ver) == 1 && ver == 1); + puts("PASS: equal-version conflicts converge in both orders; answer and reaction have independent versions"); + + struct chat_feedback_record newer = high, answer = first; + newer.ver = 5; newer.data[0] = 5; sign_record(&source, 1001, &newer); + answer.kind = CHAT_FEEDBACK_ANSWER; answer.ver = 2; sign_record(&source, 1001, &answer); + uint8_t page[2 + 2 * FB_WIRE_MAX]; page[0] = 0; page[1] = 2; + int n = fb_encode(&newer, page + 2); assert(n > 0); + int m = fb_encode(&answer, page + 2 + n); assert(m > 0); + root(&target, expected); saved_events = events; + page[2 + n + m - 1] ^= 1; + assert(fb_apply_page(&target, "1001", source.node_id, page, 2 + n + m) < 0); + root(&target, received); assert(!memcmp(expected, received, 32) && events == saved_events); + page[2 + n + m - 1] ^= 1; + assert(sqlite3_exec(target.topo_sqlite_db, "CREATE TRIGGER fail_answer BEFORE INSERT ON feedback_1001" + " WHEN NEW.kind=2 BEGIN SELECT RAISE(ABORT,'injected feedback failure'); END", NULL, NULL, NULL) == SQLITE_OK); + assert(fb_apply_page(&target, "1001", source.node_id, page, 2 + n + m) < 0); + root(&target, received); assert(!memcmp(expected, received, 32) && events == saved_events); + assert(sqlite3_exec(target.topo_sqlite_db, "DROP TRIGGER fail_answer", NULL, NULL, NULL) == SQLITE_OK); + assert(fb_apply_page(&target, "1001", source.node_id, page, 2 + n + m - 1) < 0); + assert(fb_apply_page(&target, "1001", source.node_id, page, 2 + n + m) == 0 && events == saved_events + 2); + puts("PASS: malformed page, invalid signature and SQLite failure roll back the whole page"); + + merkle_key_t key, cursor = {{0}}, next; assert(fb_key(1001, &newer, &key) == 0); + size_t bytes = sizeof(page); int more; + assert(fb_page(&target, "1001", merkle_sync_level_prefix(fb_read64(key.bytes), 5), 0, + &cursor, page, &bytes, &next, &more) == 0 && !more); + assert(bytes >= 2 + FB_FIXED_SIZE && page[1] >= 1); + struct chat_feedback_record decoded; assert(fb_decode(page + 2, bytes - 2, &decoded) > 0); + assert(decoded.ver == 5 && decoded.data[0] == 5); + assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, &ver) == 1 && ver == 1); + assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 1 && ver == 2); + assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, &ver) == 1 && ver == 3); + root(&target, expected); + member_sync_destroy(&target); assert(member_sync_init(&target) == 0 && chat_feedback_init(&target) == 0); + root(&target, received); assert(!memcmp(expected, received, 32)); + assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 1 && ver == 4); + puts("PASS: wire roundtrip; persisted empty state and ver survive Merkle rebuild"); + + assert(topo_node_sqlite_channel_delete(target.topo_sqlite_db, "1001") == 0); + sqlite3_stmt* st; + assert(sqlite3_prepare_v2(target.topo_sqlite_db, "SELECT 1 FROM sqlite_master WHERE name='feedback_1001'", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_DONE); sqlite3_finalize(st); + assert(merkle_sync_read_hash(&target, MT_DATA_FEEDBACK, "1001", 0, 0, received) == 0); + uint8_t empty[32] = {0}; assert(!memcmp(received, empty, 32)); + puts("PASS: deleting a channel removes its feedback table and derived root"); + 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); + assert(u_get_allocated_count() == baseline); + puts("ALL PASS: signed feedback regressions, no tracked allocations leaked"); + return 0; +} From 6437bf780b32a68ca3cb35897bdd03c011fc4f59 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:09:48 +0200 Subject: [PATCH 13/49] Include feedback source in the core library source list --- src/Makefile.am | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index 4b18fa40..be3147c7 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -110,8 +110,6 @@ utun_CORE_SOURCES = \ chat/chat_msg.c \ chat/chat_feedback.c \ chat/chat_feedback.h \ - chat/chat_feedback.c \ - chat/chat_feedback.h \ chat/chat_whisper.c \ chat/chat_channel.c \ chat/chat_profile.c \ @@ -246,6 +244,8 @@ libutun_a_SOURCES = \ chat/chat_core.c \ chat/chat_member.c \ chat/chat_msg.c \ + chat/chat_feedback.c \ + chat/chat_feedback.h \ chat/chat_channel.c \ chat/chat_profile.c \ chat/group_avatar.c \ From 04ff80153150ec5a244dc0448cb862cd41fcbe69 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:11:25 +0200 Subject: [PATCH 14/49] Document Merkle v3 and signed feedback state contracts --- src/chat/merkle_sync_doc.md | 46 ++++++++++++++++++++++------ src/routing_layer/topo_node_sqlite.h | 2 +- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/chat/merkle_sync_doc.md b/src/chat/merkle_sync_doc.md index dbe02f62..bd287855 100644 --- a/src/chat/merkle_sync_doc.md +++ b/src/chat/merkle_sync_doc.md @@ -1,17 +1,23 @@ -# merkle_sync — протокол 2 +# merkle_sync — протокол 3 ## Разделение ответственности - merkle_tree: производный индекс SQLite, хеши листьев и родителей. - merkle_sync: обмен, backpressure и жизненный цикл сессий. -- member_sync: формат записей, подписи, версии, применение страниц. +- member_sync: формат участников, подписи, версии, применение страниц. +- chat_feedback: подписанные состояния реакций/ответов, ver, отмены и применение страниц. Движок не знает CHAT-групп. Namespace — каноническая десятичная строка uint64_t. Все операции выполняются в одном потоке uasync экземпляра. +Сессия определяется тройкой (peer, dataset, namespace). MEMBERS=0, FEEDBACK=1. +В индексе SQLite namespace хранится как `dataset:channel_id`; корни наборов независимы. ## Дерево и транзакции Корень имеет уровень 0. Уровни 1–5 разбивают старшие 25 бит ключа на группы по 5 бит. +Полный ключ курсора — 32 байта с лексикографическим порядком; ветвь задаётся первыми 8 байтами. +Для участника ключ — BE64(node_id) и 24 нулевых байта. Для feedback — SHA256 идентичности записи. +Совпадение первых 64 бит не объединяет записи и не пропускает их при пагинации. SHA256 листа: `0x4d || level:1 || item_hashes`, записи упорядочены по unsigned key. SHA256 родителя: `0x4d || level:1 || child_hash[0] || ... || child_hash[31]`. Хеш пустого поддерева — 32 нулевых байта; строка индекса для него отсутствует. @@ -24,19 +30,22 @@ SHA256 родителя: `0x4d || level:1 || child_hash[0] || ... || child_hash[ ## API -init принимает service и data_ops: update_bucket_hash, get_page, apply_items, +init принимает service и data_ops начальной модели MEMBERS. register добавляет другие наборы. +Каждая модель задаёт update_bucket_hash, get_page, apply_items, необязательные validate_peer и finish. update_bucket_hash добавляет хеши одного листа в переданный SHA256-контекст. get_page получает ёмкость буфера и курсор; возвращает размер, последний ключ и флаг продолжения. apply_items проверяет весь формат и применяет страницу вместе с индексом атомарно. finish проверяет отложенные зависимости модели перед подтверждением корня. -start подключает namespace к текущему соединению и запрашивает контрольную точку. +start подключает (dataset, namespace) к текущему соединению и запрашивает контрольную точку. Повторные start объединяются, обход не сбрасывается, каждый принятый callback вызывается один раз. После подключения изменения синхронизируются автоматически. Broadcast/send-back не требуются. MT_OK означает совпадение корней конкретного раунда, а не отсутствие будущих изменений. -cancel, cancel_ns, cancel_peer и destroy освобождают запросы без callback. +cancel отменяет только заданный dataset/peer/namespace. +cancel_ns отменяет все datasets канала, cancel_peer — все datasets пира. +Все cancel и destroy освобождают запросы без callback. DOWN/DELETE/REINIT завершают запросы ошибкой и освобождают сессию. После восстановления соединения интеграционный слой повторяет start. Callback может отменить сессию; уничтожение всего экземпляра следует отложить через call_soon. @@ -66,16 +75,16 @@ WAKE также перезапускает незавершённый раунд ## Wire -Заголовок: `service:1 | namespace:8 | type:1 | round:8 | request:4` (22 байта). +Заголовок: `service:1 | namespace:8 | type:1 | round:8 | request:4 | dataset:1` (23 байта). Все целые big-endian. Максимальный пакет вместе с заголовком — 16384 байта. Типы 0x10–0x19: WAKE, BEGIN, READY, QUERY, HASHES, PAGE, TURN, CHECK, DONE, ERROR. | Тип | Payload | |---|---| | WAKE, BEGIN, READY, TURN | пустой; request=0; у WAKE также round=0 | -| QUERY | level:1, prefix:8, after_valid:1, after:8 | +| QUERY | level:1, prefix:8, after_valid:1, after:32 | | HASHES | 32 хеша детей по 32 байта в порядке индексов | -| PAGE | more:1, next:8, model_data | +| PAGE | more:1, next:32, model_data | | CHECK, DONE | корень 32 байта; request=0 | | ERROR | положительный код ошибки 4 байта; request=0 | @@ -97,4 +106,23 @@ DEBUG — TX/RX, номера запросов, размеры, состояни test_merkle_sync проверяет индекс, односторонний start, двустороннее слияние, несколько узлов, параллельные изменения, большой лист с пагинацией и автоматическое обновление. test_merkle_protocol проверяет ошибочные пакеты/курсоры, сохранение соседних ветвей, -отмену waiter, ошибки apply/SQLite, backpressure, disconnect и повторное подключение. +отмену waiter, ошибки apply/SQLite, backpressure, disconnect и повторное подключение, +изоляцию datasets и пагинацию разных ключей с одинаковым 64-битным префиксом. +test_chat_feedback проверяет подписи, монотонный ver, отмены, старые записи, +конфликты одного ver, атомарность страниц, восстановление дерева и удаление канала. + +## Подписанные состояния feedback + +Таблица feedback_ хранит message_id, node_id, kind, ver, data и signature. +Идентичность записи — (message_id, node_id, kind); kind=1 — reaction, kind=2 — answer. +ver положителен, максимум INT64_MAX. Меньшая версия игнорируется. +При одинаковом ver выбирается лексикографически большее data; конфликт логируется. +Пустое data — сохраняемая отмена, её нельзя удалять TTL-очисткой. +Подпись покрывает domain `utun-chat-feedback-v1`, BE64(channel_id) и wire-тело без подписи. +Wire-запись: message_id:32, node_id:8, kind:1, ver:8, data_len:2, data, signature:64. +Страница: count:2 и count записей; все числа big-endian, data максимум 2048 байт. +Хеш листа включает канонические тела без signature: разные корректные подписи одного тела +не создают расхождения корней. Входящие и сохранённые подписи всё равно проверяются. +Членство проверяется через подписи дерева приглашений и join, привязывающий Ed25519 к узлу. +Слой feedback хранит подписанные байты независимо от наличия исходного сообщения; +семантику выбранных пунктов и эмодзи проверяет вызывающий модуль до учёта результата. diff --git a/src/routing_layer/topo_node_sqlite.h b/src/routing_layer/topo_node_sqlite.h index 92c5c4c1..41d33887 100644 --- a/src/routing_layer/topo_node_sqlite.h +++ b/src/routing_layer/topo_node_sqlite.h @@ -42,7 +42,7 @@ int topo_node_sqlite_channel_put(sqlite3* db, const char* channel_id, const uint8_t* signature); /* Полное локальное удаление канала: DROP peers_/msg_, DELETE channels, - * DELETE merkle_tree_hash WHERE namespace=. 0=ок. */ + * Удаляются feedback_ и merkle_tree_hash всех datasets этого канала. 0=ок. */ int topo_node_sqlite_channel_delete(sqlite3* db, const char* channel_id); /* Упрощённый плейсхолдер мембера от topo_group (source=1, не участвует в merkle-хеше). */ From 8676893d5968ef580561a5fd498a695564b5b28e Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 20:17:48 +0200 Subject: [PATCH 15/49] Record feedback implementation milestone and remaining work --- doc/tasks.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/doc/tasks.md b/doc/tasks.md index c70996f8..ff120485 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -3,6 +3,24 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача +[ ] **Голосования, опросы и реакции в чате** — 06.10.2026, копия `/home/user/utun3`, ветка `feature/chat-feedback`: + реализован базовый слой `src/chat/chat_feedback.c/h`: таблица каждого канала, + подписи участников, независимые ver для ответа и реакции, сохраняемая отмена, + детерминированный конфликт ver, атомарное применение страниц и восстановление индекса. + Merkle v3 разделяет MEMBERS/FEEDBACK, курсор записи — 32 байта. + `./check.sh`: 130 passed, 0 failed, 1 skipped; proxy/full, burst и load также PASS. + Осталось: включить производный message_id в чтение/API сообщений, определить + канонические данные набора эмодзи и ответа, формат неизменяемого poll-сообщения + (single/multiple, allow_custom_answer), проверку и подсчёт результатов при получении + исходного сообщения; подключить headless, desktop и Android интерфейсы. + +[ ] **tcp_proxy_full: проверить аномальные значения elapsed в отчёте** — 06.10.2026: + `/tmp/utun3-check.log` при успешной передаче показывает длительности вроде + `basic_1mb: 161949390ms` и `concurrent_20: -1612179507375916304ms`. + В `tests/tcp_proxy_full/run_test.sh` длительность считается через `date +%s%3N`; + проверить фактический формат времени и арифметику в окружении запуска. + Причина пока не установлена; результаты передачи и проверки байтов — PASS. + [ ] **rlottie: разбор предупреждений GCC 15 в импортированных исходниках** — 05.10.2026: при сборке `tools/chatgui/rlottie_src` GCC сообщает `-Wuninitialized` в `VArenaAlloc::reset()`: fFirstBlock/fFirstSize/fFirstHeapAllocationSize читаются From d40f58d79af7829df87a0d210332f61ce7b196e1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:12:53 +0200 Subject: [PATCH 16/49] Add message identities and plan the remaining feedback work --- doc/tasks.md | 8 ++++++++ lib/strbuf.c | 24 ++++++++++++++++++++++++ lib/strbuf.h | 3 +++ src/chat/db_sync.c | 12 +++++++++--- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index ff120485..ef273047 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -13,6 +13,14 @@ канонические данные набора эмодзи и ответа, формат неизменяемого poll-сообщения (single/multiple, allow_custom_answer), проверку и подсчёт результатов при получении исходного сообщения; подключить headless, desktop и Android интерфейсы. + План продолжения: + 1. Сохранить индексируемый message_id в msg_, вернуть его через API сообщений. + 2. Добавить общий каталог эмодзи и канонические кодеки poll/answer/reactions; + single/multiple и allow_custom_answer проверяются ядром. + 3. Добавить снимок результатов, список голосовавших и собственные состояния; + неизвестное сообщение откладывает учёт ответа, получение сообщения обновляет представление. + 4. Подключить единый командный API к CLI, desktop и Android; команды выполняются в uasync. + 5. Прогнать проверки кодеков и сетевые сценарии, собрать интерфейсы и выполнить check.sh. [ ] **tcp_proxy_full: проверить аномальные значения elapsed в отчёте** — 06.10.2026: `/tmp/utun3-check.log` при успешной передаче показывает длительности вроде diff --git a/lib/strbuf.c b/lib/strbuf.c index 1a066dba..52c27137 100644 --- a/lib/strbuf.c +++ b/lib/strbuf.c @@ -3,6 +3,7 @@ */ #include "strbuf.h" #include "mem.h" +#include "debug_config.h" #include #include #include @@ -69,6 +70,29 @@ void strbuf_addc(struct strbuf *sb, char c) { sb->buf[sb->len] = '\0'; } +/* Экранирует ASCII-управляющие байты; UTF-8 сохраняется без преобразования. */ +int strbuf_add_json_string(struct strbuf* sb, const char* text) { + if (!sb || strbuf_addf(sb, "\"") < 0) goto failed; + const unsigned char* p = (const unsigned char*)(text ? text : ""); + while (*p) { + int rc; + if (*p == '"' || *p == '\\') rc = strbuf_addf(sb, "\\%c", *p); + else if (*p < 32) rc = strbuf_addf(sb, "\\u%04x", *p); + else { + const unsigned char* start = p; + while (*p >= 32 && *p != '"' && *p != '\\') p++; + rc = strbuf_addf(sb, "%.*s", (int)(p - start), start); + if (rc < 0) goto failed; + continue; + } + if (rc < 0) goto failed; + p++; + } + if (strbuf_addf(sb, "\"") >= 0) return 0; +failed: + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "strbuf: JSON string allocation failed"); return -1; +} + void strbuf_adds(struct strbuf *sb, const char *s) { if (!s) return; size_t slen = strlen(s); diff --git a/lib/strbuf.h b/lib/strbuf.h index 48946e99..99ba569e 100644 --- a/lib/strbuf.h +++ b/lib/strbuf.h @@ -41,6 +41,9 @@ void strbuf_reset(struct strbuf *sb); int strbuf_addf(struct strbuf *sb, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +/* Добавить JSON-строку с кавычками и полным экранированием; NULL означает пустую строку. 0/-1. */ +int strbuf_add_json_string(struct strbuf* sb, const char* text); + void strbuf_addc(struct strbuf *sb, char c); void strbuf_adds(struct strbuf *sb, const char *s); diff --git a/src/chat/db_sync.c b/src/chat/db_sync.c index 447936a0..a94e3980 100644 --- a/src/chat/db_sync.c +++ b/src/chat/db_sync.c @@ -7,6 +7,7 @@ #include "../routing_layer/topo_group.h" #include "../routing_layer/topo_node_sqlite.h" #include "member_sync.h" +#include "chat_feedback.h" #include "../transport_layer/etcp_dump.h" #include "../transport_layer/secure_channel.h" #include "../../lib/debug_config.h" @@ -562,6 +563,9 @@ static int db_record_insert(struct DB_SYNC_INSTANCE* si, sqlite3_stmt* stmt; int rc; + uint8_t message_id[32]; + if (chat_message_id(si->group_id, ts, author_sig, message_id) < 0) return -1; + // Verify author signature (mandatory) if (db_verify_author_sig(si, ts, author_node_id, json, jlen, author_sig) != 0) { sqlite3_stmt* del; @@ -598,8 +602,8 @@ static int db_record_insert(struct DB_SYNC_INSTANCE* si, if (si_prep(si, &stmt, "INSERT INTO \"%s\"" " (timestamp,node_id,id,chain_hash,flags,data," - " author_signature,local_attrs,delivered_peers,delivery_chain)" - " VALUES (?,?,?,?,0,?,?,?,0,'')") != SQLITE_OK) + " author_signature,local_attrs,delivered_peers,delivery_chain,message_id)" + " VALUES (?,?,?,?,0,?,?,?,0,'',?)") != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "db_record_insert INSERT prep: %s", sqlite3_errmsg(db)); sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); return -1; } sqlite3_bind_int64(stmt, 1, (sqlite3_int64)ts); sqlite3_bind_int64(stmt, 2, (sqlite3_int64)author_node_id); @@ -610,6 +614,7 @@ static int db_record_insert(struct DB_SYNC_INSTANCE* si, sqlite3_bind_blob(stmt, 6, author_sig, DB_SIG_SIZE, SQLITE_STATIC); if (local_attrs && local_attrs[0]) sqlite3_bind_text(stmt, 7, local_attrs, -1, SQLITE_STATIC); else sqlite3_bind_text(stmt, 7, "", -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 8, message_id, sizeof(message_id), SQLITE_STATIC); rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "INSERT: %s", sqlite3_errmsg(db)); sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); return -1; } @@ -1707,7 +1712,7 @@ struct DB_SYNC_INSTANCE* db_sync_instance_add(struct UTUN_INSTANCE* inst, const // Create table — PK is (timestamp, node_id), author_signature NOT NULL { - char sql[512]; + char sql[768]; snprintf(sql, sizeof(sql), "CREATE TABLE IF NOT EXISTS \"%s\" (" " timestamp INTEGER NOT NULL," @@ -1717,6 +1722,7 @@ struct DB_SYNC_INSTANCE* db_sync_instance_add(struct UTUN_INSTANCE* inst, const " flags INTEGER NOT NULL DEFAULT 0," " data BLOB," " author_signature BLOB NOT NULL," + " message_id BLOB NOT NULL UNIQUE CHECK(length(message_id)=32)," " local_attrs TEXT DEFAULT ''," " delivered_peers INTEGER NOT NULL DEFAULT 0," " delivery_chain TEXT NOT NULL DEFAULT ''," From d0aa60fe1a5fef45839cf4b342f7d70463e517d0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:15:57 +0200 Subject: [PATCH 17/49] Define canonical poll, answer and reaction formats --- src/chat/chat_poll.h | 68 +++++++++++++++++ src/chat/chat_poll_codec.c | 151 +++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/chat/chat_poll.h create mode 100644 src/chat/chat_poll_codec.c diff --git a/src/chat/chat_poll.h b/src/chat/chat_poll.h new file mode 100644 index 00000000..fad014a8 --- /dev/null +++ b/src/chat/chat_poll.h @@ -0,0 +1,68 @@ +/* Общий формат опросов, ответов и реакций. Подписанное poll-сообщение неизменяемо. + * single допускает один пункт ИЛИ свой ответ; multiple — пункты и необязательный свой ответ. + * Пустой ответ/набор реакций кодируется нулевой длиной и сохраняется с новым ver. + * Все сетевые/DB API вызываются в uasync; кодеки не зависят от экземпляра. + * JSON-снимки и результаты команд выделяются через u_malloc, освобождаются u_free. */ +#ifndef CHAT_POLL_H +#define CHAT_POLL_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct UTUN_INSTANCE; + +#define CHAT_POLL_SINGLE 1 +#define CHAT_POLL_MULTIPLE 2 +#define CHAT_POLL_OPTIONS_MAX 32 +#define CHAT_POLL_QUESTION_MAX 512 +#define CHAT_POLL_OPTION_MAX 256 +#define CHAT_POLL_CUSTOM_MAX 1024 +#define CHAT_POLL_WIRE_MAX 2048 +#define CHAT_REACTION_COUNT 16 +#define CHAT_REACTION_SELECTED_MAX 8 + +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]; +}; + +struct chat_poll_answer { + uint8_t count, choices[CHAT_POLL_OPTIONS_MAX]; + char custom[CHAT_POLL_CUSTOM_MAX + 1]; +}; + +/* Кодеки возвращают длину/0, либо -1. Decode требует точное каноническое представление. */ +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); +int chat_answer_decode(const uint8_t* data, size_t len, struct chat_poll_answer* answer); +int chat_reactions_encode(const uint8_t* ids, size_t count, uint8_t* out, size_t capacity); +int chat_reactions_decode(const uint8_t* data, size_t len, uint8_t ids[CHAT_REACTION_SELECTED_MAX], size_t* count); +/* Постоянный общий каталог: существующие номера не менять. NULL при неверном номере. */ +const char* chat_reaction_emoji(unsigned id); +/* Структурная проверка data, независимая от наличия исходного сообщения. 0/-1. */ +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. */ +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; успех — снимок или каталог. + * vote: choices:[0..31], custom:string; пустые значения отменяют голос. + * react: emoji:0..15, on:boolean необязателен (без него переключить), clear:true снимает все. */ +int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result); +/* Команда из UI: req и следующая за ней NUL-строка выделены одним блоком через u_malloc. + * Результат возвращается событием CHAT_EVT_FEEDBACK_RESULT и включает request_id. */ +struct chat_feedback_request { struct UTUN_INSTANCE* inst; uint64_t request_id; }; +void chat_feedback_command_trampoline(void* arg); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/chat/chat_poll_codec.c b/src/chat/chat_poll_codec.c new file mode 100644 index 00000000..e617f266 --- /dev/null +++ b/src/chat/chat_poll_codec.c @@ -0,0 +1,151 @@ +#include "chat_poll.h" +#include "chat_feedback.h" +#include "../../lib/debug_config.h" +#include +#include + +static const char* const reaction_catalog[CHAT_REACTION_COUNT] = { + "👍", "👎", "❤️", "😂", "😮", "😢", "😡", "🎉", "🔥", "🤔", "💯", "👏", "🙏", "😍", "✨", "🤖" +}; + +const char* chat_reaction_emoji(unsigned id) { return id < CHAT_REACTION_COUNT ? reaction_catalog[id] : NULL; } + +/* Проверяет UTF-8, исключая NUL, неверные/избыточные кодировки и surrogate. */ +static int poll_utf8(const uint8_t* s, size_t len) { + for (size_t i = 0; i < len;) { + unsigned c = s[i++], value, bytes, minimum; + if (c < 128) { if (!c || (c < 32 && c != 9 && c != 10 && c != 13)) return -1; continue; } + if (c >= 0xc2 && c <= 0xdf) { value = c & 31; bytes = 1; minimum = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { value = c & 15; bytes = 2; minimum = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { value = c & 7; bytes = 3; minimum = 0x10000; } + else return -1; + if (bytes > len - i) return -1; + while (bytes--) { c = s[i++]; if ((c & 0xc0) != 0x80) return -1; value = (value << 6) | (c & 63); } + if (value < minimum || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return -1; + } + return 0; +} + +static int poll_error(const char* reason) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid data reason=%s", reason); return -1; +} + +/* Проверка строки ограничена ёмкостью структуры; нет strlen за её границей. */ +static int poll_string_size(const char* s, size_t capacity) { + const char* end = memchr(s, 0, capacity); + if (!end || poll_utf8((const uint8_t*)s, (size_t)(end - s)) < 0) return poll_error("text is not bounded UTF-8"); + return (int)(end - s); +} + +/* Текст на проводе — BE16 длины и байты UTF-8. */ +static int poll_put_string(const char* s, size_t capacity, uint8_t* out, size_t available) { + int len = poll_string_size(s, capacity); + if (len < 0) return -1; + if (!len || available < (size_t)len + 2) return poll_error("empty text or poll exceeds size limit"); + out[0] = (unsigned)len >> 8; out[1] = len & 255; memcpy(out + 2, s, len); + return len + 2; +} + +static int poll_get_string(const uint8_t* data, size_t len, char* out, size_t capacity) { + if (len < 2) return poll_error("truncated text length"); + size_t bytes = ((size_t)data[0] << 8) | data[1]; + if (!bytes || bytes >= capacity || bytes > len - 2 || poll_utf8(data + 2, bytes) < 0) + return poll_error("invalid text length or UTF-8"); + memcpy(out, data + 2, bytes); out[bytes] = 0; return (int)bytes + 2; +} + +/* Формат poll v1: version, mode, flags, option_count, question, options. */ +int chat_poll_encode(const struct chat_poll* poll, uint8_t* out, size_t capacity) { + if (!poll || !out || capacity < 6 || (poll->mode != CHAT_POLL_SINGLE && poll->mode != CHAT_POLL_MULTIPLE) + || poll->allow_custom_answer > 1 || poll->option_count < 2 || poll->option_count > CHAT_POLL_OPTIONS_MAX) + return poll_error("poll mode, flags or option count"); + if (capacity > CHAT_POLL_WIRE_MAX) capacity = CHAT_POLL_WIRE_MAX; + out[0] = 1; out[1] = poll->mode; out[2] = poll->allow_custom_answer; out[3] = poll->option_count; + int bytes = poll_put_string(poll->question, sizeof(poll->question), out + 4, capacity - 4); + if (bytes < 0) return -1; + size_t off = 4 + bytes; + for (unsigned i = 0; i < poll->option_count; i++) { + bytes = poll_put_string(poll->options[i], sizeof(poll->options[i]), out + off, capacity - off); + if (bytes < 0) return -1; + for (unsigned j = 0; j < i; j++) if (!strcmp(poll->options[i], poll->options[j])) return poll_error("duplicate option"); + off += bytes; + } + return (int)off; +} + +int chat_poll_decode(const uint8_t* data, size_t len, struct chat_poll* poll) { + if (!data || !poll || len < 6 || len > CHAT_POLL_WIRE_MAX || data[0] != 1 + || (data[1] != CHAT_POLL_SINGLE && data[1] != CHAT_POLL_MULTIPLE) || data[2] > 1 + || data[3] < 2 || data[3] > CHAT_POLL_OPTIONS_MAX) return poll_error("poll header"); + memset(poll, 0, sizeof(*poll)); poll->mode = data[1]; poll->allow_custom_answer = data[2]; poll->option_count = data[3]; + int bytes = poll_get_string(data + 4, len - 4, poll->question, sizeof(poll->question)); + if (bytes < 0) return -1; + size_t off = 4 + bytes; + for (unsigned i = 0; i < poll->option_count; i++) { + bytes = poll_get_string(data + off, len - off, poll->options[i], sizeof(poll->options[i])); + if (bytes < 0) return -1; + for (unsigned j = 0; j < i; j++) if (!strcmp(poll->options[i], poll->options[j])) return poll_error("duplicate option"); + off += bytes; + } + return off == len ? 0 : poll_error("trailing poll bytes"); +} + +static int poll_compare_id(const void* a, const void* b) { return (int)*(const uint8_t*)a - *(const uint8_t*)b; } + +/* Ответ: count, отсортированные номера, BE16 длины собственного текста, UTF-8. */ +int chat_answer_encode(const struct chat_poll_answer* answer, uint8_t* out, size_t capacity) { + if (!answer || !out || answer->count > CHAT_POLL_OPTIONS_MAX) return poll_error("answer count"); + int bytes = poll_string_size(answer->custom, sizeof(answer->custom)); + if (bytes < 0) return -1; + if (!answer->count && !bytes) return 0; + size_t len = 3 + answer->count + bytes; + if (len > capacity) return poll_error("answer capacity"); + out[0] = answer->count; memcpy(out + 1, answer->choices, answer->count); + qsort(out + 1, answer->count, 1, poll_compare_id); + for (unsigned i = 1; i <= answer->count; i++) + if (out[i] >= CHAT_POLL_OPTIONS_MAX || (i > 1 && out[i] == out[i - 1])) return poll_error("answer choices"); + out[1 + answer->count] = (unsigned)bytes >> 8; out[2 + answer->count] = bytes & 255; + memcpy(out + 3 + answer->count, answer->custom, bytes); return (int)len; +} + +int chat_answer_decode(const uint8_t* data, size_t len, struct chat_poll_answer* answer) { + if (!answer || (len && !data)) return poll_error("answer arguments"); + memset(answer, 0, sizeof(*answer)); if (!len) return 0; + if (len < 3 || data[0] > CHAT_POLL_OPTIONS_MAX || len < (size_t)data[0] + 3) return poll_error("answer header"); + size_t n = data[0], bytes = ((size_t)data[1 + n] << 8) | data[2 + n]; + if ((!n && !bytes) || bytes > CHAT_POLL_CUSTOM_MAX || len != 3 + n + bytes || poll_utf8(data + 3 + n, bytes) < 0) + return poll_error("answer text or trailing bytes"); + for (size_t i = 1; i <= n; i++) + if (data[i] >= CHAT_POLL_OPTIONS_MAX || (i > 1 && data[i] <= data[i - 1])) return poll_error("noncanonical choices"); + answer->count = n; memcpy(answer->choices, data + 1, n); memcpy(answer->custom, data + 3 + n, bytes); return 0; +} + +int chat_reactions_encode(const uint8_t* ids, size_t count, uint8_t* out, size_t capacity) { + if (!out || (count && !ids) || count > CHAT_REACTION_SELECTED_MAX) return poll_error("reaction count"); + if (!count) return 0; + if (capacity < count + 1) return poll_error("reaction capacity"); + out[0] = count; memcpy(out + 1, ids, count); qsort(out + 1, count, 1, poll_compare_id); + for (size_t i = 1; i <= count; i++) + if (out[i] >= CHAT_REACTION_COUNT || (i > 1 && out[i] == out[i - 1])) return poll_error("reaction ids"); + return (int)count + 1; +} + +int chat_reactions_decode(const uint8_t* data, size_t len, uint8_t ids[CHAT_REACTION_SELECTED_MAX], size_t* count) { + if (!count || !ids || (len && !data)) return poll_error("reaction arguments"); + *count = 0; if (!len) return 0; + if (!data[0] || data[0] > CHAT_REACTION_SELECTED_MAX || len != (size_t)data[0] + 1) return poll_error("reaction length"); + for (size_t i = 1; i < len; i++) + if (data[i] >= CHAT_REACTION_COUNT || (i > 1 && data[i] <= data[i - 1])) return poll_error("noncanonical reactions"); + *count = data[0]; memcpy(ids, data + 1, *count); return 0; +} + +int chat_feedback_validate_data(uint8_t kind, const uint8_t* data, size_t len) { + if (kind == CHAT_FEEDBACK_REACTION) { + uint8_t ids[CHAT_REACTION_SELECTED_MAX]; size_t count; + return chat_reactions_decode(data, len, ids, &count); + } + if (kind == CHAT_FEEDBACK_ANSWER) { + struct chat_poll_answer answer; return chat_answer_decode(data, len, &answer); + } + return poll_error("feedback kind"); +} From a4ca31f406f48d80c3bf202a40fd024663baf778 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:17:24 +0200 Subject: [PATCH 18/49] Return submitted message identity and encode JSON safely --- src/chat/chat_core.h | 7 +++-- src/chat/chat_msg.c | 73 +++++++++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index bd03647a..d39c70a5 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -66,13 +66,14 @@ struct chat_msg_submit { int height; /* видео: высота */ uint8_t* data; /* содержимое сообщения, заимствовано обычным submit */ uint32_t data_len; /* размер data в байтах */ - uint64_t timestamp; /* входное время; отправка назначает новый timestamp через db_sync */ + uint64_t timestamp; /* успешная отправка возвращает назначенное время */ + uint8_t message_id[32]; /* успешная обычная отправка возвращает общий ID */ uint64_t reply_to_ts; /* 0 = не ответ */ uint64_t reply_to_node_id; /* node_id автора исходного сообщения */ }; -/* Подписать, сохранить и разослать сообщение. req/data остаются у вызывающего. */ -void chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req); +/* Подписать, сохранить и разослать сообщение. req/data остаются у вызывающего. 0/-1. */ +int chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req); /* Отправка медиа-сообщения (вложение): src->dest (copy) или видео (transcode). */ void chat_core_submit_media_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req); diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index b531dd70..f5a5e8b3 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -6,6 +6,7 @@ #include "chat_core_priv.h" #include "chat_event.h" +#include "chat_feedback.h" #include "chat_setting.h" #include "chat_whisper.h" @@ -71,45 +72,53 @@ static int msg_get_prev_chain_hash(struct chat_core_ctx* cc, const char* tbl, ui sqlite3_finalize(st); return 0; } -void chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: submit before init", CC_ID); return; } - if (!req) return; - - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: SUBMIT ch=%s ct=%s len=%u", - CC_ID, req->channel_id, req->content_type, req->data_len); - +int chat_core_submit_message(struct UTUN_INSTANCE* inst, struct chat_msg_submit* req) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !req || (req->data_len && !req->data) || req->data_len > 4000) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: invalid message submission", CC_ID); return -1; + } + memset(req->message_id, 0, sizeof(req->message_id)); struct DB_SYNC_INSTANCE* si = si_find(inst, req->channel_id); - if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no db_sync instance for ch=%s", CC_ID, req->channel_id); return; } + if (!si) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: no history for ch=%s", CC_ID, req->channel_id); return -1; } - char json[4096]; - int off = snprintf(json, sizeof(json), - "{\"n\":%llu,\"ch\":\"%s\",\"ct\":\"%s\"", - (unsigned long long)cc->my_node_id, req->channel_id, req->content_type); + /* SQLite формирует JSON без обрезки и с экранированием текста/Unicode. */ + const char* query = req->reply_to_ts + ? "SELECT json_object('n',?1,'ch',?2,'ct',?3,'d',?4,'rt',?5,'rn',?6)" + : "SELECT json_object('n',?1,'ch',?2,'ct',?3,'d',?4)"; + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(cc->db, query, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message JSON: %s", CC_ID, sqlite3_errmsg(cc->db)); return -1; + } + sqlite3_bind_int64(st, 1, (sqlite3_int64)cc->my_node_id); + sqlite3_bind_text(st, 2, req->channel_id, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, req->content_type, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 4, req->data ? (const char*)req->data : "", req->data_len, SQLITE_STATIC); if (req->reply_to_ts) { - off += snprintf(json + off, sizeof(json) - off, - ",\"rt\":%llu,\"rn\":%llu", - (unsigned long long)req->reply_to_ts, (unsigned long long)req->reply_to_node_id); + sqlite3_bind_int64(st, 5, (sqlite3_int64)req->reply_to_ts); + sqlite3_bind_int64(st, 6, (sqlite3_int64)req->reply_to_node_id); } - off += snprintf(json + off, sizeof(json) - off, - ",\"d\":\"%.*s\"}", - (int)req->data_len, (const char*)req->data); - + int rc = sqlite3_step(st), bytes = rc == SQLITE_ROW ? sqlite3_column_bytes(st, 0) : 0; + char json[4001]; + if (rc != SQLITE_ROW || bytes <= 0 || bytes >= (int)sizeof(json)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message JSON rejected ch=%s bytes=%d rc=%d: %s", + CC_ID, req->channel_id, bytes, rc, sqlite3_errmsg(cc->db)); sqlite3_finalize(st); return -1; + } + memcpy(json, sqlite3_column_text(st, 0), bytes); json[bytes] = 0; sqlite3_finalize(st); uint64_t ts = db_sync_next_timestamp(si); - uint8_t sig_msg[8192]; size_t soff = 0; - memcpy(sig_msg + soff, &ts, 8); soff += 8; - size_t jl = strlen(json); memcpy(sig_msg + soff, json, jl); soff += jl; - uint8_t sig[64]; - if (sc_ed25519_sign(inst->my_ed25519_privkey, sig_msg, soff, sig) != SC_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: Ed25519 sign failed", CC_ID); - return; + uint8_t signed_body[sizeof(json) + 8], signature[64], message_id[32]; + memcpy(signed_body, &ts, 8); memcpy(signed_body + 8, json, bytes); + if (sc_ed25519_sign(inst->my_ed25519_privkey, signed_body, bytes + 8, signature) != SC_OK + || chat_message_id(db_sync_instance_group_id(si), ts, signature, message_id) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message signing failed ch=%s", CC_ID, req->channel_id); return -1; } - - int ret = db_sync_insert_signed(si, json, strlen(json), sig, 64, ts, NULL); - if (ret != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: db_sync_insert_signed failed ret=%d", CC_ID, ret); - return; + int result = db_sync_insert_signed(si, json, bytes, signature, 64, ts, NULL); + if (result != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: message insert failed ch=%s result=%d", CC_ID, req->channel_id, result); return -1; } + req->timestamp = ts; memcpy(req->message_id, message_id, sizeof(message_id)); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: message saved ch=%s ct=%s ts=%llu bytes=%d", + CC_ID, req->channel_id, req->content_type, (unsigned long long)ts, bytes); + return 0; } void chat_core_submit_trampoline(void* arg) { From 0af35630d1788610a7822bddbcf03c0ea17ca786 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:17:24 +0200 Subject: [PATCH 19/49] Expose full message data and stable identities in listings --- src/chat/chat_core.c | 68 +++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/src/chat/chat_core.c b/src/chat/chat_core.c index ffd4a61c..d62c3783 100644 --- a/src/chat/chat_core.c +++ b/src/chat/chat_core.c @@ -378,48 +378,40 @@ int chat_core_get_channels_json(struct UTUN_INSTANCE* inst, char* buf, size_t bu 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 = CC(inst); - if (!cc || !cc->initialized || !ch_id || !buf || !out_len) return -1; - char tbl[80]; msg_table_name(ch_id, tbl, sizeof(tbl)); - char* w = buf; char* end = buf + buf_size; - *w++ = '['; const char* sep = ""; - char sql[256]; + 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; + } + char table[80], sql[768]; msg_table_name(ch_id, table, sizeof(table)); snprintf(sql, sizeof(sql), - "SELECT m.id, m.timestamp, m.node_id, CAST(m.data AS TEXT), n.name, m.local_attrs " - "FROM \"%s\" m LEFT JOIN nodes n ON m.node_id=n.node_id " - "ORDER BY m.timestamp DESC LIMIT ? OFFSET ?", tbl); + "SELECT json_object('id',m.id,'message_id',lower(hex(m.message_id)),'ts',m.timestamp," + "'author_id',printf('0x%%016llx',m.node_id),'author_name',coalesce(n.name,'')," + "'content_type',CASE WHEN json_valid(CAST(m.data AS TEXT)) THEN json_extract(CAST(m.data AS TEXT),'$.ct') ELSE 'text' END," + "'data',coalesce(CAST(m.data AS TEXT),''),'local_attrs',coalesce(m.local_attrs,''))" + " FROM \"%s\" m LEFT JOIN nodes n ON m.node_id=n.node_id" + " ORDER BY m.timestamp DESC,m.author_signature DESC LIMIT ? OFFSET ?", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(cc->db, sql, -1, &st, NULL) != SQLITE_OK) { *out_len = 0; return -1; } - sqlite3_bind_int(st, 1, count > 0 ? count : 100); - sqlite3_bind_int(st, 2, offset); - while (sqlite3_step(st) == SQLITE_ROW) { - int64_t id = sqlite3_column_int64(st, 0); - int64_t ts = sqlite3_column_int64(st, 1); - uint64_t author_id = (uint64_t)sqlite3_column_int64(st, 2); - const char* data = (const char*)sqlite3_column_text(st, 3); - const char* author_name = (const char*)sqlite3_column_text(st, 4); - const char* la = (const char*)sqlite3_column_text(st, 5); - if (!data) data = ""; - if (!la) la = ""; - const char* ct = "text"; - char ctbuf[32] = {0}; - const char* ctpos = strstr(data, "\"ct\":\""); - if (ctpos) { ctpos += 6; int ci = 0; while (*ctpos && *ctpos != '"' && ci < 31) ctbuf[ci++] = *ctpos++; ct = ctbuf; } - char esc_data[4096], esc_aname[256], esc_la[256]; - json_escape(data, esc_data, sizeof(esc_data)); - json_escape(author_name ? author_name : "", esc_aname, sizeof(esc_aname)); - json_escape(la, esc_la, sizeof(esc_la)); - w += snprintf(w, (size_t)(end - w), - "%s{\"id\":%lld,\"ts\":%lld,\"author_id\":\"0x%016llx\",\"author_name\":\"%s\"," - "\"content_type\":\"%s\",\"data\":\"%.2000s\",\"local_attrs\":\"%s\"}", - sep, (long long)id, (long long)ts, (unsigned long long)author_id, esc_aname, ct, esc_data, esc_la); - sep = ","; + 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) { + 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; + } + if (off > 1) 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); - *w++ = ']'; - *w = '\0'; - *out_len = (size_t)(w - buf); - return 0; + if (result < 0) { buf[0] = 0; return -1; } + buf[off++] = ']'; buf[off] = 0; *out_len = off; return 0; } int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, char* buf, size_t buf_size, size_t* out_len) { From e6db9fef60ae3b00d3e14172ed5dfbbc16b3bc39 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:23:37 +0200 Subject: [PATCH 20/49] Validate feedback data and provide poll command snapshots --- src/chat/chat_event.h | 1 + src/chat/chat_feedback.c | 3 + src/chat/chat_poll.c | 364 +++++++++++++++++++++++++++++++++++++++ src/chat/chat_poll.h | 2 + 4 files changed, 370 insertions(+) create mode 100644 src/chat/chat_poll.c diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 7299ab3e..68927afe 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -75,6 +75,7 @@ extern "C" { #define CHAT_EVT_AVATAR_RESULT 51 /* request_id/group:8LE, failed:1; результат публикации ссылки профиля */ #define CHAT_EVT_GROUP_AVATAR_RESULT 52 /* request_id/group:8LE, failed:1; результат публикации аватарки группы */ #define CHAT_EVT_FEEDBACK_CHANGED 53 /* [channel_len:1][channel][message_id:32][kind:1] — состояние после commit */ +#define CHAT_EVT_FEEDBACK_RESULT 54 /* [request_id:8LE][success:1][JSON] — результат команды интерфейса */ typedef void (*chat_event_handler_fn)(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len); diff --git a/src/chat/chat_feedback.c b/src/chat/chat_feedback.c index 7c236fc3..f056fdb8 100644 --- a/src/chat/chat_feedback.c +++ b/src/chat/chat_feedback.c @@ -1,4 +1,5 @@ #include "chat_feedback.h" +#include "chat_poll.h" #include "merkle_sync.h" #include "member_sync.h" #include "chat_event.h" @@ -62,6 +63,7 @@ static int fb_encode(const struct chat_feedback_record* r, uint8_t* wire) { || (r->kind != CHAT_FEEDBACK_REACTION && r->kind != CHAT_FEEDBACK_ANSWER) || r->data_len > CHAT_FEEDBACK_DATA_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "feedback: invalid record fields"); return -1; } + if (chat_feedback_validate_data(r->kind, r->data, r->data_len) < 0) return -1; memcpy(wire, r->message_id, 32); fb_write64(wire + 32, r->node_id); wire[40] = r->kind; fb_write64(wire + 41, r->ver); wire[49] = r->data_len >> 8; wire[50] = r->data_len & 255; memcpy(wire + 51, r->data, r->data_len); memcpy(wire + 51 + r->data_len, r->signature, 64); @@ -225,6 +227,7 @@ static int fb_apply_row(struct UTUN_INSTANCE* inst, const char* channel, const s static void fb_committed(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_feedback_record* r) { uint8_t event[54]; size_t len = strlen(channel); event[0] = len; memcpy(event + 1, channel, len); memcpy(event + 1 + len, r->message_id, 32); event[33 + len] = r->kind; + chat_feedback_check_message(inst, channel, r->message_id); chat_event_post(inst, CHAT_EVT_FEEDBACK_CHANGED, event, (int)len + 34); } diff --git a/src/chat/chat_poll.c b/src/chat/chat_poll.c new file mode 100644 index 00000000..0135028e --- /dev/null +++ b/src/chat/chat_poll.c @@ -0,0 +1,364 @@ +#include "chat_poll.h" +#include "chat_feedback.h" +#include "chat_core_priv.h" +#include "chat_event.h" +#include "../utun_instance.h" +#include "../transport_layer/secure_channel.h" +#include "../../lib/strbuf.h" +#include "../../lib/mem.h" +#include +#include +#include + +/* Канал — канонический положительный 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; + for (const char* p = channel; *p; p++) if (*p < '0' || *p > '9') goto invalid; + errno = 0; char* end; uint64_t n = strtoull(channel, &end, 10); + if (errno || *end || n > INT64_MAX) goto invalid; + if (id) *id = n; + return 0; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid channel=%s", channel ? channel : "-"); return -1; +} + +static int poll_db_error(struct UTUN_INSTANCE* inst, const char* operation) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: %s: %s", operation, sqlite3_errmsg(inst->topo_sqlite_db)); return -1; +} + +/* Возвращает 1 для отсутствующего сообщения, 0 для найденного, -1 для ошибки. + * is_poll отдельно отличает обычное сообщение от опроса. БД содержит проверенную подпись. */ +static int poll_target(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], + struct chat_poll* poll, int* is_poll) { + if (poll_channel(inst, channel, NULL) < 0 || !message_id) return -1; + *is_poll = 0; + if (!inst->chat_core) return 1; + char sql[512]; + snprintf(sql, sizeof(sql), "SELECT CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ct') END," + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.d') END," + "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; + if (step == SQLITE_DONE) result = 1; + else if (step != SQLITE_ROW) result = poll_db_error(inst, "target read"); + else { + const char* type = (const char*)sqlite3_column_text(st, 0); + if (type && !strcmp(type, "poll")) { + const char* body = (const char*)sqlite3_column_text(st, 1); + const char* ch = (const char*)sqlite3_column_text(st, 2); + int len = sqlite3_column_bytes(st, 1); + if (!body || !ch || strcmp(ch, channel) || len < 4 || len > 4 + 4 * ((CHAT_POLL_WIRE_MAX + 2) / 3) + || memcmp(body, "b64:", 4)) result = -1; + else { + uint8_t wire[CHAT_POLL_WIRE_MAX]; char canonical[4 * ((CHAT_POLL_WIRE_MAX + 2) / 3)]; + size_t bytes = b64_decode(body + 4, len - 4, wire, sizeof(wire)); + size_t encoded = b64_encode(wire, bytes, canonical, sizeof(canonical)); + if (encoded != (size_t)len - 4 || memcmp(canonical, body + 4, encoded) + || chat_poll_decode(wire, bytes, poll) < 0) result = -1; + else *is_poll = 1; + } + if (result < 0) DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid signed poll ch=%s", channel); + } + } + sqlite3_finalize(st); return result; +} + +/* Семантика ответа не влияет на сохранение подписанных байтов и Merkle-корень. */ +static int poll_answer_allowed(const struct chat_poll* poll, const struct chat_poll_answer* answer) { + if (!answer->count && !answer->custom[0]) return 1; + if (answer->custom[0] && !poll->allow_custom_answer) return 0; + if (poll->mode == CHAT_POLL_SINGLE && answer->count + (answer->custom[0] != 0) != 1) return 0; + for (unsigned i = 0; i < answer->count; i++) if (answer->choices[i] >= poll->option_count) return 0; + return 1; +} + +int chat_poll_create(struct UTUN_INSTANCE* inst, const char* channel, const struct chat_poll* poll, uint8_t out_id[32]) { + if (poll_channel(inst, channel, NULL) < 0 || !out_id) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid create arguments"); return -1; + } + uint8_t wire[CHAT_POLL_WIRE_MAX]; char body[4 + 4 * ((CHAT_POLL_WIRE_MAX + 2) / 3) + 1]; + int bytes = chat_poll_encode(poll, wire, sizeof(wire)); if (bytes < 0) return -1; + memcpy(body, "b64:", 4); size_t len = 4 + b64_encode(wire, bytes, body + 4, sizeof(body) - 5); body[len] = 0; + struct chat_msg_submit req = { .inst = inst, .data = (uint8_t*)body, .data_len = len }; + snprintf(req.channel_id, sizeof(req.channel_id), "%s", channel); snprintf(req.content_type, sizeof(req.content_type), "poll"); + if (chat_core_submit_message(inst, &req) < 0) return -1; + memcpy(out_id, req.message_id, 32); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "poll: published ch=%s mode=%u options=%u custom=%u bytes=%d", + channel, poll->mode, poll->option_count, poll->allow_custom_answer, bytes); return 0; +} + +/* Формат текста JSON и вывод выбора используются одинаково для mine и списка ответов. */ +static int poll_json_answer(struct strbuf* out, const struct chat_poll_answer* answer) { + if (strbuf_addf(out, "{\"choices\":[") < 0) return -1; + for (unsigned i = 0; i < answer->count; i++) + if (strbuf_addf(out, "%s%u", i ? "," : "", answer->choices[i]) < 0) return -1; + if (strbuf_addf(out, "],\"custom\":") < 0 || strbuf_add_json_string(out, answer->custom) < 0) return -1; + return strbuf_addf(out, "}") < 0 ? -1 : 0; +} + +/* Результаты производны от последних допустимых состояний каждого автора. + * Чужие неверные ответы сохраняются для сходимости, но не участвуют в подсчёте. */ +int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32], char** json) { + if (json) *json = NULL; + if (!json || poll_channel(inst, channel, NULL) < 0 || !message_id) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid snapshot arguments"); return -1; + } + struct chat_poll poll; int is_poll, target = poll_target(inst, channel, message_id, &poll, &is_poll); + if (target < 0) return -1; + unsigned counts[CHAT_REACTION_COUNT] = {0}, votes[CHAT_POLL_OPTIONS_MAX] = {0}, voters = 0, custom_count = 0; + uint8_t mine[CHAT_REACTION_COUNT] = {0}; uint64_t reaction_ver = 0, answer_ver = 0; + struct chat_poll_answer own_answer = {0}; + struct strbuf replies = strbuf_new(), out = strbuf_new(); + sqlite3_stmt* st = NULL; int result = -1, step; + 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); + 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); + const uint8_t* data = sqlite3_column_blob(st, 3); size_t len = sqlite3_column_bytes(st, 3); + int kind = sqlite3_column_int(st, 1); + if (kind == CHAT_FEEDBACK_REACTION) { + uint8_t ids[CHAT_REACTION_SELECTED_MAX]; size_t count; + if (chat_reactions_decode(data, len, ids, &count) < 0) goto done; + if (author == inst->node_id) reaction_ver = ver; + if (!target) for (size_t i = 0; i < count; i++) { counts[ids[i]]++; if (author == inst->node_id) mine[ids[i]] = 1; } + } else if (kind == CHAT_FEEDBACK_ANSWER) { + struct chat_poll_answer answer; + if (chat_answer_decode(data, len, &answer) < 0) goto done; + if (author == inst->node_id) answer_ver = ver; + if (target || !is_poll || !poll_answer_allowed(&poll, &answer)) continue; + if (author == inst->node_id) own_answer = answer; + if (!answer.count && !answer.custom[0]) continue; + for (unsigned i = 0; i < answer.count; i++) votes[answer.choices[i]]++; + if (answer.custom[0]) custom_count++; + if (strbuf_addf(&replies, "%s{\"node_id\":\"0x%016llx\",\"ver\":%llu,\"answer\":", + voters ? "," : "", (unsigned long long)author, (unsigned long long)ver) < 0 + || poll_json_answer(&replies, &answer) < 0 || strbuf_addf(&replies, "}") < 0) goto done; + voters++; + } + } + if (step != SQLITE_DONE) { poll_db_error(inst, "snapshot read"); 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\":[", + channel, hex, target ? "true" : "false", (unsigned long long)reaction_ver) < 0) goto done; + unsigned shown = 0; + for (unsigned i = 0; i < CHAT_REACTION_COUNT; i++) if (counts[i]) { + if (strbuf_addf(&out, "%s{\"id\":%u,\"emoji\":", shown++ ? "," : "", i) < 0 + || strbuf_add_json_string(&out, chat_reaction_emoji(i)) < 0 + || strbuf_addf(&out, ",\"count\":%u,\"mine\":%s}", counts[i], mine[i] ? "true" : "false") < 0) goto done; + } + if (strbuf_addf(&out, "],\"poll\":") < 0) goto done; + if (is_poll) { + if (strbuf_addf(&out, "{\"mode\":\"%s\",\"allow_custom_answer\":%s,\"question\":", + poll.mode == CHAT_POLL_SINGLE ? "single" : "multiple", poll.allow_custom_answer ? "true" : "false") < 0 + || strbuf_add_json_string(&out, poll.question) < 0 || strbuf_addf(&out, ",\"options\":[") < 0) goto done; + for (unsigned i = 0; i < poll.option_count; i++) { + if (strbuf_addf(&out, "%s{\"id\":%u,\"text\":", i ? "," : "", i) < 0 + || strbuf_add_json_string(&out, poll.options[i]) < 0 + || strbuf_addf(&out, ",\"count\":%u,\"percent\":%.2f}", votes[i], voters ? 100.0 * votes[i] / voters : 0.0) < 0) goto done; + } + if (strbuf_addf(&out, "],\"voters\":%u,\"custom_count\":%u,\"ver\":%llu,\"mine\":", + voters, custom_count, (unsigned long long)answer_ver) < 0 + || poll_json_answer(&out, &own_answer) < 0 + || strbuf_addf(&out, ",\"answers\":%s}", strbuf_str(&replies)) < 0) goto done; + } else if (strbuf_addf(&out, "null") < 0) goto done; + if (strbuf_addf(&out, "}") < 0) goto done; + *json = strbuf_detach(&out); result = 0; +done: + if (st) sqlite3_finalize(st); + strbuf_free(&replies); strbuf_free(&out); + if (result < 0) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: snapshot failed ch=%s", channel); + return result; +} + +/* Сохраняем диагностику семантики при новом состоянии/получении исходного сообщения. */ +void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32]) { + char* snapshot = NULL; + if (chat_feedback_snapshot(inst, channel, message_id, &snapshot) == 0) { + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluated ch=%s state=%s", channel, snapshot); u_free(snapshot); + } +} + +/* Читает поле 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; +} + +/* Требуем полную строку, не разрешая тихое обрезание и встроенный NUL. */ +static int poll_field_text(struct UTUN_INSTANCE* inst, const char* json, const char* path, char* out, size_t capacity, int optional) { + sqlite3_stmt* st = poll_field(inst, json, path); if (!st) return -1; + const char* type = (const char*)sqlite3_column_text(st, 0), *text = (const char*)sqlite3_column_text(st, 1); + size_t bytes = sqlite3_column_bytes(st, 1); int result = -1; + if (!type && optional) { out[0] = 0; result = 0; } + 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); + return result; +} + +/* Выбор пунктов/эмодзи: только массив целых номеров, без преобразования строк/дробей. */ +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); + 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; + } + 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); + if (count == (int)capacity || !element || strcmp(element, "integer") || n < 0 || n > 255) { + count = -1; DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: invalid array element field=%s", path); break; + } + ids[count++] = (uint8_t)n; + } + sqlite3_finalize(st); + if (count >= 0 && step != SQLITE_DONE) { poll_db_error(inst, "command array read"); return -1; } + return count; +} + +static int poll_field_bool(struct UTUN_INSTANCE* inst, const char* json, const char* path, int default_value) { + 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); + return value; +} + +/* Ошибка доступна интерфейсу, детали оставляем в логе ядра. */ +static int poll_command_error(const char* reason, char** result) { + struct strbuf out = strbuf_new(); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: command rejected reason=%s", reason); + if (strbuf_addf(&out, "{\"error\":") >= 0 && strbuf_add_json_string(&out, reason) == 0 && strbuf_addf(&out, "}") >= 0) + *result = strbuf_detach(&out); + strbuf_free(&out); return -1; +} + +/* Общий командный API исключает отдельные правила голосования в каждом интерфейсе. */ +int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char** result) { + if (result) *result = NULL; + if (!inst || !inst->topo_sqlite_db || !command || !result) { + 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 (!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); + if (!strcmp(cmd, "reaction_catalog")) { + struct strbuf out = strbuf_new(); int failed = strbuf_addf(&out, "{\"catalog\":[") < 0; + for (unsigned i = 0; i < CHAT_REACTION_COUNT && !failed; i++) { + failed = strbuf_addf(&out, "%s{\"id\":%u,\"emoji\":", i ? "," : "", i) < 0 + || strbuf_add_json_string(&out, chat_reaction_emoji(i)) < 0 || strbuf_addf(&out, "}") < 0; + } + if (!failed && strbuf_addf(&out, "]}") >= 0) *result = strbuf_detach(&out); + strbuf_free(&out); return *result ? 0 : poll_command_error("Out of memory", result); + } + if (poll_field_text(inst, command, "$.ch", channel, sizeof(channel), 0) < 0 || poll_channel(inst, channel, NULL) < 0) + return poll_command_error("Invalid channel", result); + if (!strcmp(cmd, "poll_create")) { + struct chat_poll poll = {0}; char mode[16]; + if (poll_field_text(inst, command, "$.question", poll.question, sizeof(poll.question), 0) < 0 + || poll_field_text(inst, command, "$.mode", mode, sizeof(mode), 0) < 0) return poll_command_error("Invalid poll text or mode", result); + poll.mode = !strcmp(mode, "single") ? CHAT_POLL_SINGLE : !strcmp(mode, "multiple") ? CHAT_POLL_MULTIPLE : 0; + int custom = poll_field_bool(inst, command, "$.allow_custom_answer", 0); + 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); + 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); + } + 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); + int len = sqlite3_column_bytes(st, 1); + if (poll.option_count == CHAT_POLL_OPTIONS_MAX || !value_type || strcmp(value_type, "text") || !text + || 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) + return poll_command_error("Invalid poll or publication failed", result); + return chat_feedback_snapshot(inst, channel, message_id, 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); + if (!strcmp(cmd, "feedback")) return chat_feedback_snapshot(inst, channel, message_id, result); + struct chat_poll poll; int is_poll, target = poll_target(inst, channel, message_id, &poll, &is_poll); + if (target != 0) return poll_command_error("Message is not available", result); + uint8_t data[CHAT_FEEDBACK_DATA_MAX]; int bytes, kind; + if (!strcmp(cmd, "vote")) { + if (!is_poll) return poll_command_error("Message is not a poll", result); + struct chat_poll_answer answer = {0}; + int n = poll_field_ids(inst, command, "$.choices", answer.choices, sizeof(answer.choices)); + if (n < 0 || poll_field_text(inst, command, "$.custom", answer.custom, sizeof(answer.custom), 1) < 0) + return poll_command_error("Invalid answer", result); + answer.count = n; bytes = chat_answer_encode(&answer, data, sizeof(data)); kind = CHAT_FEEDBACK_ANSWER; + if (!poll_answer_allowed(&poll, &answer)) return poll_command_error("Answer violates poll rules", result); + } else if (!strcmp(cmd, "react")) { + uint8_t ids[CHAT_REACTION_SELECTED_MAX + 1]; size_t count = 0; + int clear = poll_field_bool(inst, command, "$.clear", 0); if (clear < 0) return poll_command_error("Invalid clear flag", result); + if (!clear) { + struct chat_feedback_record old; + int found = chat_feedback_get(inst, channel, message_id, inst->node_id, CHAT_FEEDBACK_REACTION, &old); + if (found < 0 || (found && chat_reactions_decode(old.data, old.data_len, ids, &count) < 0)) + return poll_command_error("Cannot read reactions", result); + sqlite3_stmt* st = poll_field(inst, command, "$.emoji"); if (!st) return poll_command_error("Invalid emoji", result); + const char* type = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 emoji = sqlite3_column_int64(st, 1); + ok = type && !strcmp(type, "integer") && emoji >= 0 && emoji < CHAT_REACTION_COUNT; sqlite3_finalize(st); + if (!ok) return poll_command_error("Invalid emoji", result); + size_t at = 0; while (at < count && ids[at] != emoji) at++; + int on = poll_field_bool(inst, command, "$.on", at == count); + if (on < 0) return poll_command_error("Invalid reaction state", result); + if (on && at == count) { + if (count == CHAT_REACTION_SELECTED_MAX) return poll_command_error("Too many reactions", result); + ids[count++] = emoji; + } else if (!on && at < count) { memmove(ids + at, ids + at + 1, count - at - 1); count--; } + } + 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) + return poll_command_error("Cannot save state", result); + return chat_feedback_snapshot(inst, channel, message_id, result); +} + +/* Ответ UI асинхронен: успех/ошибка и исходный request_id всегда идут вместе. */ +void chat_feedback_command_trampoline(void* arg) { + struct chat_feedback_request* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: missing command request"); return; } + char* json = NULL; int rc = chat_feedback_command(req->inst, (const char*)(req + 1), &json); + if (!json) json = u_strdup("{\"error\":\"Interaction failed\"}"); + size_t bytes = json ? strlen(json) : 0; uint8_t* event = u_malloc(bytes + 9); + if (event) { + memcpy(event, &req->request_id, 8); event[8] = rc == 0; + if (bytes) memcpy(event + 9, json, bytes); + chat_event_post(req->inst, CHAT_EVT_FEEDBACK_RESULT, event, bytes + 9); u_free(event); + } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll: command result allocation failed"); + u_free(json); u_free(req); +} diff --git a/src/chat/chat_poll.h b/src/chat/chat_poll.h index fad014a8..15aab678 100644 --- a/src/chat/chat_poll.h +++ b/src/chat/chat_poll.h @@ -57,6 +57,8 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons * vote: choices:[0..31], custom:string; пустые значения отменяют голос. * react: emoji:0..15, on:boolean необязателен (без него переключить), 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; }; From 865e4cda5936d844f240f0e876ca94da514a4233 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:23:37 +0200 Subject: [PATCH 21/49] Log delayed answer validation and keep JSON percentages locale independent --- src/chat/chat_poll.c | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/chat/chat_poll.c b/src/chat/chat_poll.c index 0135028e..cdee79f7 100644 --- a/src/chat/chat_poll.c +++ b/src/chat/chat_poll.c @@ -159,9 +159,10 @@ int chat_feedback_snapshot(struct UTUN_INSTANCE* inst, const char* channel, cons poll.mode == CHAT_POLL_SINGLE ? "single" : "multiple", poll.allow_custom_answer ? "true" : "false") < 0 || strbuf_add_json_string(&out, poll.question) < 0 || strbuf_addf(&out, ",\"options\":[") < 0) goto done; for (unsigned i = 0; i < poll.option_count; i++) { + unsigned percent = voters ? (unsigned)((10000ULL * votes[i] + voters / 2) / voters) : 0; if (strbuf_addf(&out, "%s{\"id\":%u,\"text\":", i ? "," : "", i) < 0 || strbuf_add_json_string(&out, poll.options[i]) < 0 - || strbuf_addf(&out, ",\"count\":%u,\"percent\":%.2f}", votes[i], voters ? 100.0 * votes[i] / voters : 0.0) < 0) goto done; + || strbuf_addf(&out, ",\"count\":%u,\"percent\":%u.%02u}", votes[i], percent / 100, percent % 100) < 0) goto done; } if (strbuf_addf(&out, "],\"voters\":%u,\"custom_count\":%u,\"ver\":%llu,\"mine\":", voters, custom_count, (unsigned long long)answer_ver) < 0 @@ -179,10 +180,29 @@ done: /* Сохраняем диагностику семантики при новом состоянии/получении исходного сообщения. */ void chat_feedback_check_message(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message_id[32]) { - char* snapshot = NULL; - if (chat_feedback_snapshot(inst, channel, message_id, &snapshot) == 0) { - DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluated ch=%s state=%s", channel, snapshot); u_free(snapshot); + struct chat_poll poll; int is_poll, target = poll_target(inst, channel, message_id, &poll, &is_poll); + if (target < 0) return; + if (target) { + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluation deferred ch=%s; source message missing", channel); return; } + 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; + while ((step = sqlite3_step(st)) == SQLITE_ROW) { + struct chat_poll_answer answer; + if (chat_answer_decode(sqlite3_column_blob(st, 2), sqlite3_column_bytes(st, 2), &answer) < 0 + || !is_poll || !poll_answer_allowed(&poll, &answer)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "poll: answer excluded ch=%s node=%016llx ver=%llu poll=%d mode=%u options=%u custom=%u", + channel, (unsigned long long)sqlite3_column_int64(st, 0), (unsigned long long)sqlite3_column_int64(st, 1), + is_poll, is_poll ? poll.mode : 0, is_poll ? poll.option_count : 0, is_poll ? poll.allow_custom_answer : 0); + rejected++; + } else accepted++; + } + sqlite3_finalize(st); + if (step != SQLITE_DONE) { poll_db_error(inst, "evaluate read"); return; } + if (accepted || rejected) DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "poll: evaluated ch=%s valid=%u invalid=%u", channel, accepted, rejected); } /* Читает поле JSON с его типом. Отсутствующее поле имеет type=NULL. */ From 60b487054960f385c180c0f852d650e6e6f73f5e Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:24:13 +0200 Subject: [PATCH 22/49] Pass stable identities to insertion callbacks and bound signed bodies --- src/chat/db_sync.c | 9 ++++++++- src/chat/db_sync.h | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/chat/db_sync.c b/src/chat/db_sync.c index a94e3980..8a533995 100644 --- a/src/chat/db_sync.c +++ b/src/chat/db_sync.c @@ -531,6 +531,9 @@ static int db_verify_author_sig(struct DB_SYNC_INSTANCE* si, const char* json, size_t jlen, const uint8_t* sig) { + if (!sig || jlen > DB_SYNC_MESSAGE_MAX || (jlen && !json)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "history: invalid signed body tbl=%s bytes=%zu", SI_TBL(si), jlen); return -1; + } uint8_t pubkey[32]; if (db_get_ed25519_pubkey(si->db_sync, author_node_id, pubkey) != 0) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, @@ -638,7 +641,11 @@ static int db_record_insert_cascade(struct DB_SYNC_INSTANCE* si, int ret = db_record_insert(si, rid, rts, rauthor, rdata, rdlen, rsig, local_attrs); if (ret != 0) return ret; - if (si->on_insert) si->on_insert(si, rts, rdata, rdlen, rauthor, initial_sync, si->on_insert_arg); + if (si->on_insert) { + uint8_t message_id[32]; + if (chat_message_id(si->group_id, rts, rsig, message_id) < 0) return -1; + si->on_insert(si, message_id, rts, rdata, rdlen, rauthor, initial_sync, si->on_insert_arg); + } uint8_t pbuf[4096]; uint32_t poff = 1; pbuf[0] = DB_MSG_PUSH; memcpy(pbuf + poff, &rid, 8); poff += 8; diff --git a/src/chat/db_sync.h b/src/chat/db_sync.h index 544af5d2..b8bb0dee 100644 --- a/src/chat/db_sync.h +++ b/src/chat/db_sync.h @@ -30,6 +30,9 @@ struct DB_SYNC_INSTANCE; #define DB_MSG_ERROR 0x08 #define DB_MSG_REQUEST_SYNC 0x09 +/* PUSH имеет фиксированный буфер 4096 байт; 4000 оставляет место заголовку и подписи. */ +#define DB_SYNC_MESSAGE_MAX 4000 + // SEND_DATA want_from sentinel #define DB_WANT_FROM_NONE 0xFFFFFFFF // no request for peer's data @@ -102,6 +105,7 @@ int db_sync_select(struct DB_SYNC_INSTANCE* si, uint32_t offset, uint32_t l // initial_sync = 1 если запись пришла в рамках bulk-обмена SEND_DATA (initial/re-sync), // 0 для live-PUSH и локальных вставок. typedef void (*db_sync_insert_cb)(struct DB_SYNC_INSTANCE* si, + const uint8_t message_id[32], uint64_t record_timestamp, const char* json_data, size_t len, uint64_t author_node_id, int initial_sync, void* arg); From 17444b8c8a2e8700ee5ca2b908022b055b2e42a8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:24:14 +0200 Subject: [PATCH 23/49] Evaluate delayed answers after their source message arrives --- src/chat/chat_core_priv.h | 3 ++- src/chat/chat_msg.c | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/chat/chat_core_priv.h b/src/chat/chat_core_priv.h index ac67a75b..1183cb32 100644 --- a/src/chat/chat_core_priv.h +++ b/src/chat/chat_core_priv.h @@ -115,7 +115,8 @@ void si_register(struct UTUN_INSTANCE* inst, struct DB_SYNC_INSTANCE* si, const /* ── db_sync callback (chat_msg.c) ── */ -void on_msg_inserted(struct DB_SYNC_INSTANCE* si, uint64_t record_ts, const char* data, size_t len, uint64_t author, int initial_sync, void* arg); +void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], uint64_t record_ts, + const char* data, size_t len, uint64_t author, int initial_sync, void* arg); /* ── применить настройку group_autoconnect ко всем CHAT-группам (chat_channel.c) ── */ diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index f5a5e8b3..fa099c06 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -7,6 +7,7 @@ #include "chat_core_priv.h" #include "chat_event.h" #include "chat_feedback.h" +#include "chat_poll.h" #include "chat_setting.h" #include "chat_whisper.h" @@ -849,11 +850,13 @@ static int md_auto_download(struct UTUN_INSTANCE* inst, /* ─── db_sync callback ─── */ -void on_msg_inserted(struct DB_SYNC_INSTANCE* si, uint64_t record_ts, const char* data, size_t len, uint64_t author, int initial_sync, void* arg) { +void on_msg_inserted(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], uint64_t record_ts, + const char* data, size_t len, uint64_t author, int initial_sync, void* arg) { struct UTUN_INSTANCE* inst = arg; struct chat_core_ctx* cc = CC(inst); if (!cc) return; char ch_id[32]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)db_sync_instance_group_id(si)); + 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); chat_event_post(inst, CHAT_EVT_MSG_RECEIVED, evt, 1 + cl + 8); From 48d7f2b21b7fe967ceb897079e5feb7ec8947942 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:24:53 +0200 Subject: [PATCH 24/49] Build poll modules and adapt media insertion callbacks --- src/Makefile.am | 6 ++++++ tests/test_media_delivery_chat.c | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index be3147c7..8d1c19ff 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -110,6 +110,9 @@ utun_CORE_SOURCES = \ chat/chat_msg.c \ chat/chat_feedback.c \ chat/chat_feedback.h \ + chat/chat_poll.c \ + chat/chat_poll_codec.c \ + chat/chat_poll.h \ chat/chat_whisper.c \ chat/chat_channel.c \ chat/chat_profile.c \ @@ -246,6 +249,9 @@ libutun_a_SOURCES = \ chat/chat_msg.c \ chat/chat_feedback.c \ chat/chat_feedback.h \ + chat/chat_poll.c \ + chat/chat_poll_codec.c \ + chat/chat_poll.h \ chat/chat_channel.c \ chat/chat_profile.c \ chat/group_avatar.c \ diff --git a/tests/test_media_delivery_chat.c b/tests/test_media_delivery_chat.c index 232ee2e7..c2af285c 100644 --- a/tests/test_media_delivery_chat.c +++ b/tests/test_media_delivery_chat.c @@ -282,9 +282,9 @@ static int parse_media_body(const char* body, struct media_index_result* out) { /* ── insert-callback: storage-узлы запускают auto-download ── */ -static void storage_on_insert(struct DB_SYNC_INSTANCE* si, uint64_t ts, +static void storage_on_insert(struct DB_SYNC_INSTANCE* si, const uint8_t message_id[32], uint64_t ts, const char* data, size_t len, uint64_t author, int initial_sync, void* arg) { - (void)si; (void)ts; (void)initial_sync; + (void)si; (void)message_id; (void)ts; (void)initial_sync; int idx = (int)(intptr_t)arg; struct UTUN_INSTANCE* inst = g_inst[idx]; if (author == inst->node_id) return; From ef476bf4e2c2cb1bfae4dd85b7eb3723959cdeec Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:24:53 +0200 Subject: [PATCH 25/49] Use canonical reaction and answer payloads in feedback tests --- tests/test_chat_feedback.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_chat_feedback.c b/tests/test_chat_feedback.c index 4c620284..9e87fffc 100644 --- a/tests/test_chat_feedback.c +++ b/tests/test_chat_feedback.c @@ -70,7 +70,7 @@ int main(void) { puts("PASS: message identity is deterministic and binds channel, timestamp and signature"); uint64_t ver; struct chat_feedback_record first, second, cancelled, actual; - uint8_t a[] = {1}, b[] = {1, 2}; + uint8_t a[] = {1, 0}, b[] = {2, 0, 1}, answer_data[] = {1, 0, 0, 0}; assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 1 && ver == 1); assert(chat_feedback_get(&source, "1001", message, source.node_id, CHAT_FEEDBACK_REACTION, &first) == 1); int saved_events = events; @@ -98,17 +98,18 @@ int main(void) { puts("PASS: signature binds version, channel and author; rejection preserves tree and events"); struct chat_feedback_record low = first, high = first; - low.ver = high.ver = 4; low.data[0] = 3; high.data[0] = 4; + low.ver = high.ver = 4; low.data[1] = 3; high.data[1] = 4; sign_record(&source, 1001, &low); sign_record(&source, 1001, &high); assert(chat_feedback_apply(&source, "1001", &low) == 1 && chat_feedback_apply(&source, "1001", &high) == 1); assert(chat_feedback_apply(&target, "1001", &high) == 1 && chat_feedback_apply(&target, "1001", &low) == 0); root(&source, expected); root(&target, received); assert(!memcmp(expected, received, 32)); - assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_ANSWER, b, sizeof(b), &ver) == 1 && ver == 1); + assert(chat_feedback_set(&source, "1001", message, CHAT_FEEDBACK_ANSWER, answer_data, sizeof(answer_data), &ver) == 1 && ver == 1); puts("PASS: equal-version conflicts converge in both orders; answer and reaction have independent versions"); struct chat_feedback_record newer = high, answer = first; - newer.ver = 5; newer.data[0] = 5; sign_record(&source, 1001, &newer); - answer.kind = CHAT_FEEDBACK_ANSWER; answer.ver = 2; sign_record(&source, 1001, &answer); + newer.ver = 5; newer.data[1] = 5; sign_record(&source, 1001, &newer); + answer.kind = CHAT_FEEDBACK_ANSWER; answer.ver = 2; answer.data_len = sizeof(answer_data); + memcpy(answer.data, answer_data, sizeof(answer_data)); sign_record(&source, 1001, &answer); uint8_t page[2 + 2 * FB_WIRE_MAX]; page[0] = 0; page[1] = 2; int n = fb_encode(&newer, page + 2); assert(n > 0); int m = fb_encode(&answer, page + 2 + n); assert(m > 0); @@ -132,7 +133,7 @@ int main(void) { &cursor, page, &bytes, &next, &more) == 0 && !more); assert(bytes >= 2 + FB_FIXED_SIZE && page[1] >= 1); struct chat_feedback_record decoded; assert(fb_decode(page + 2, bytes - 2, &decoded) > 0); - assert(decoded.ver == 5 && decoded.data[0] == 5); + assert(decoded.ver == 5 && decoded.data[1] == 5); assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, &ver) == 1 && ver == 1); assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, a, sizeof(a), &ver) == 1 && ver == 2); assert(chat_feedback_set(&target, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, &ver) == 1 && ver == 3); From f99a8c1da2351c78f18cba79b4c95789db605a25 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:26:37 +0200 Subject: [PATCH 26/49] Expose polls and reactions through headless control safely --- src/chat/chat_headless_control.c | 46 +++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index 96d79554..088f9995 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -13,6 +13,7 @@ #include "chat_core.h" #include "chat_core_priv.h" #include "chat_event.h" +#include "chat_poll.h" #include "chat_sync.h" #include "chat_join.h" #include "invite_link.h" @@ -30,6 +31,8 @@ #include "../../lib/ll_queue.h" #include "../../lib/u_async.h" #include "../../lib/mem.h" +#include "../../lib/strbuf.h" +#include "../../lib/json_flat.h" #include "../../lib/socket_compat.h" #include "../../lib/debug_config.h" #include "../../lib/platform_compat.h" @@ -167,11 +170,20 @@ static void cli_send_str(struct headless_client* cli, const char* s) { } static void send_response(struct headless_client* cli, int id, const char* ok_data, const char* error) { - char buf[4096]; int len; - if (error) len = snprintf(buf, sizeof(buf), "{\"id\":%d,\"ok\":false,\"error\":\"%s\"}\n", id, error); - else if (ok_data) len = snprintf(buf, sizeof(buf), "{\"id\":%d,\"ok\":true,\"data\":%s}\n", id, ok_data); - else len = snprintf(buf, sizeof(buf), "{\"id\":%d,\"ok\":true}\n", id); - if (len > 0) cli_send(cli, buf, (size_t)len); + struct strbuf out = strbuf_new(); int rc; + if (error) { + rc = strbuf_addf(&out, "{\"id\":%d,\"ok\":false,\"error\":", id); + if (rc >= 0) rc = strbuf_add_json_string(&out, error); + if (rc >= 0) rc = strbuf_addf(&out, "}\n"); + } else rc = strbuf_addf(&out, "{\"id\":%d,\"ok\":true,\"data\":%s}\n", id, ok_data ? ok_data : "null"); + if (rc < 0 || out.len + (size_t)cli->send_len > SEND_BUF_SIZE) { + DEBUG_ERROR(DEBUG_CATEGORY_HEADLESS, "headless: response rejected id=%d bytes=%zu queued=%d rc=%d", + id, out.len, cli->send_len, rc); + char failure[160]; int bytes = snprintf(failure, sizeof(failure), + "{\"id\":%d,\"ok\":false,\"error\":\"Response too large or out of memory; request fewer records\"}\n", id); + cli_send(cli, failure, bytes); + } else cli_send(cli, strbuf_str(&out), out.len); + strbuf_free(&out); } /* ── Broadcast event ── */ @@ -191,7 +203,12 @@ static void hc_on_chat_event(struct UTUN_INSTANCE* inst, int type, const uint8_t if (!hc) return; if (type == 10) return; /* skip status text */ char json[1024]; json[0] = '\0'; - if (type == 1 && data && len >= 10) { /* MSG_RECEIVED */ + if (type == CHAT_EVT_FEEDBACK_CHANGED && data && len >= 34 && len == data[0] + 34) { + uint8_t cl = data[0]; char message_id[65]; + for (unsigned i = 0; i < 32; i++) snprintf(message_id + i * 2, 3, "%02x", data[1 + cl + i]); + snprintf(json, sizeof(json), "{\"event\":\"feedback\",\"ch\":\"%.*s\",\"message_id\":\"%s\",\"kind\":%u}", + (int)cl, data + 1, message_id, data[33 + cl]); + } else if (type == 1 && data && len >= 10) { /* MSG_RECEIVED */ uint8_t cl = data[0]; uint64_t author; memcpy(&author, data + 1 + cl, 8); snprintf(json, sizeof(json), "{\"event\":\"msg\",\"ch\":\"%.*s\",\"author_id\":\"0x%016llx\"}", (int)cl, data + 1, (unsigned long long)author); @@ -794,6 +811,19 @@ static void hc_handle_radio_subscribers(struct headless_client* cli, int id, con typedef void (*hc_cmd_fn)(struct headless_client* cli, int id, const char* json); +/* Опросы и реакции используют общий командный API ядра. */ +static void hc_handle_feedback(struct headless_client* cli, int id, const char* json) { + char* result = NULL; int rc = chat_feedback_command(cli->hc->inst, json, &result); + if (rc == 0) send_response(cli, id, result, NULL); + else { + char error[256] = "Interaction failed"; + if (result && json_flat_get(result, "error", error, sizeof(error)) < 0) + DEBUG_ERROR(DEBUG_CATEGORY_HEADLESS, "headless: invalid interaction error JSON"); + send_response(cli, id, NULL, error); + } + u_free(result); +} + static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "call_start", "call_accept", "call_decline", "call_hangup", "radio_on", "radio_off", "radio_subscribers", "quit", NULL }; static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_radio_on, hc_handle_radio_off, hc_handle_radio_subscribers, hc_handle_quit }; @@ -806,6 +836,10 @@ static void hc_handle_command(struct headless_client* cli, const char* json) { size_t clen = (size_t)(ce - cs); if (clen == 0 || clen > 31) { send_response(cli, id, NULL, "invalid cmd"); return; } char cmd[32]; memcpy(cmd, cs, clen); cmd[clen] = '\0'; + if (!strcmp(cmd, "poll_create") || !strcmp(cmd, "vote") || !strcmp(cmd, "react") + || !strcmp(cmd, "feedback") || !strcmp(cmd, "reaction_catalog")) { + hc_handle_feedback(cli, id, json); return; + } for (int i = 0; cmd_names[i]; i++) { if (strcmp(cmd, cmd_names[i]) == 0) { cmd_handlers[i](cli, id, json); return; } } From da2c13dcf188cc8320f26b1fc6170261926dc1f4 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:26:37 +0200 Subject: [PATCH 27/49] Add CLI poll creation, voting and emoji reaction commands --- src/chat/chat_poll.c | 8 +++++++- tools/chatcli | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/chat/chat_poll.c b/src/chat/chat_poll.c index cdee79f7..dce0e833 100644 --- a/src/chat/chat_poll.c +++ b/src/chat/chat_poll.c @@ -351,7 +351,13 @@ int chat_feedback_command(struct UTUN_INSTANCE* inst, const char* command, char* return poll_command_error("Cannot read reactions", result); sqlite3_stmt* st = poll_field(inst, command, "$.emoji"); if (!st) return poll_command_error("Invalid emoji", result); const char* type = (const char*)sqlite3_column_text(st, 0); sqlite3_int64 emoji = sqlite3_column_int64(st, 1); - ok = type && !strcmp(type, "integer") && emoji >= 0 && emoji < CHAT_REACTION_COUNT; sqlite3_finalize(st); + 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++) + 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); if (!ok) return poll_command_error("Invalid emoji", result); size_t at = 0; while (at < count && ids[at] != emoji) at++; int on = poll_field_bool(inst, command, "$.on", at == count); diff --git a/tools/chatcli b/tools/chatcli index 510d7814..4511110e 100755 --- a/tools/chatcli +++ b/tools/chatcli @@ -27,7 +27,7 @@ # dm_send send DM text message # listen interactive event listener -import sys, os, json, socket, struct +import sys, os, json, socket, struct, argparse DEFAULT_HOST = os.environ.get("CHATCLI_HOST", "127.0.0.1") DEFAULT_PORT = int(os.environ.get("CHATCLI_PORT", "9999")) @@ -148,6 +148,41 @@ def cmd_dm_messages(conv_id, count=None): def cmd_dm_send(conv_id, text): _req("dm_send", conv_id=conv_id, data=text) +def cmd_interaction(cmd, args): + parser = argparse.ArgumentParser(prog="chatcli " + cmd) + if cmd == "reactions": + parser.parse_args(args) + _req("reaction_catalog") + return + parser.add_argument("ch") + if cmd == "poll": + parser.add_argument("question") + parser.add_argument("options", nargs="+") + parser.add_argument("--multiple", action="store_true") + parser.add_argument("--custom", action="store_true") + else: + parser.add_argument("message_id") + if cmd == "vote": + parser.add_argument("choices", nargs="*", type=int, help="option IDs from feedback (start at 0)") + parser.add_argument("--custom", default="") + elif cmd == "react": + parser.add_argument("emoji", nargs="?") + parser.add_argument("--off", action="store_true") + parser.add_argument("--clear", action="store_true") + values = parser.parse_args(args) + if cmd == "poll": + _req("poll_create", ch=values.ch, question=values.question, options=values.options, + mode="multiple" if values.multiple else "single", allow_custom_answer=values.custom) + elif cmd == "vote": + _req("vote", ch=values.ch, message_id=values.message_id, choices=values.choices, custom=values.custom) + elif cmd == "react": + if not values.clear and values.emoji is None: + parser.error("specify an emoji or --clear") + emoji = int(values.emoji) if values.emoji and values.emoji.isdigit() else values.emoji + _req("react", ch=values.ch, message_id=values.message_id, emoji=emoji, on=not values.off, clear=values.clear) + else: + _req("feedback", ch=values.ch, message_id=values.message_id) + def cmd_listen(): print(f"Listening on {DEFAULT_HOST}:{DEFAULT_PORT} (Ctrl+C to quit)") s = _connect() @@ -201,6 +236,7 @@ def main(): cmd = args[0] try: if cmd == "ping": cmd_ping() + elif cmd in ("poll", "vote", "react", "feedback", "reactions"): cmd_interaction(cmd, args[1:]) elif cmd == "status": cmd_status() elif cmd == "channels": cmd_channels() elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members "),)) From e068fe5d3a4b82c9f1de4f29f441ad958a4fe932 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:31:20 +0200 Subject: [PATCH 28/49] Test poll codecs, voting rules and delayed source messages --- tests/Makefile.am | 5 ++ tests/test_chat_poll.c | 147 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/test_chat_poll.c diff --git a/tests/Makefile.am b/tests/Makefile.am index 33dbe5e3..dda3fcb9 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -757,3 +757,8 @@ test_http_proxy_LDADD = $(COMMON_LIBS) check_PROGRAMS += test_tcp_io_flush test_tcp_io_flush_SOURCES = test_tcp_io_flush.c test_tcp_io_flush_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +check_PROGRAMS += test_chat_poll + +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) diff --git a/tests/test_chat_poll.c b/tests/test_chat_poll.c new file mode 100644 index 00000000..c0a7c267 --- /dev/null +++ b/tests/test_chat_poll.c @@ -0,0 +1,147 @@ +/* Кодеки и пользовательский API поверх настоящего ядра, подписей и SQLite. */ +#include "../src/chat/chat_feedback.c" +#include "../src/chat/chat_poll.h" +#include "../src/chat/chat_core.h" +#include "../src/config_parser.h" +#include + +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); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + assert(sqlite3_step(st) == SQLITE_ROW); sqlite3_int64 value = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return value; +} + +static void json_string(sqlite3* db, const char* json, const char* path, char* out, size_t size) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_type(st, 0) == SQLITE_TEXT); + const char* text = (const char*)sqlite3_column_text(st, 0); assert(strlen(text) < size); + memcpy(out, text, strlen(text) + 1); sqlite3_finalize(st); +} + +static char* command(struct UTUN_INSTANCE* inst, const char* text, int expected) { + char* result = NULL; assert(chat_feedback_command(inst, text, &result) == expected && result); + assert(json_number(inst->topo_sqlite_db, "{\"valid\":1}", "$.valid") == 1); return result; +} + +/* Проверяем границы, каноничность и невозможность интерпретации битого UTF-8. */ +static void codecs(void) { + struct chat_poll poll = { .mode = CHAT_POLL_SINGLE, .allow_custom_answer = 1, .option_count = 2 }, decoded; + strcpy(poll.question, "Вопрос \"с кавычками\"\nи переносом"); strcpy(poll.options[0], "Да"); strcpy(poll.options[1], "Нет"); + uint8_t wire[CHAT_POLL_WIRE_MAX]; int n = chat_poll_encode(&poll, wire, sizeof(wire)); assert(n > 0); + assert(chat_poll_decode(wire, n, &decoded) == 0 && !memcmp(&poll, &decoded, sizeof(poll))); + for (int i = 0; i < n; i++) assert(chat_poll_decode(wire, i, &decoded) < 0); + uint8_t saved = wire[2]; wire[2] = 2; assert(chat_poll_decode(wire, n, &decoded) < 0); wire[2] = saved; + wire[n] = 0; assert(chat_poll_decode(wire, n + 1, &decoded) < 0); + strcpy(poll.options[1], poll.options[0]); assert(chat_poll_encode(&poll, wire, sizeof(wire)) < 0); + poll.options[1][0] = (char)0xc0; poll.options[1][1] = (char)0x80; poll.options[1][2] = 0; + assert(chat_poll_encode(&poll, wire, sizeof(wire)) < 0); + struct chat_poll_answer answer = { .count = 2, .choices = {3, 1} }, parsed; + strcpy(answer.custom, "Свой ответ"); + n = chat_answer_encode(&answer, wire, sizeof(wire)); assert(n > 0); + assert(chat_answer_decode(wire, n, &parsed) == 0 && parsed.choices[0] == 1 && parsed.choices[1] == 3); + wire[1] = 3; wire[2] = 1; assert(chat_answer_decode(wire, n, &parsed) < 0); + answer.choices[1] = 3; assert(chat_answer_encode(&answer, wire, sizeof(wire)) < 0); + memset(&answer, 0, sizeof(answer)); assert(chat_answer_encode(&answer, wire, sizeof(wire)) == 0); + assert(chat_answer_decode(NULL, 0, &parsed) == 0 && !parsed.count); + uint8_t empty[] = {0, 0, 0}; assert(chat_answer_decode(empty, sizeof(empty), &parsed) < 0); + uint8_t ids[] = {5, 0}, read[CHAT_REACTION_SELECTED_MAX]; size_t count; + n = chat_reactions_encode(ids, 2, wire, sizeof(wire)); assert(n == 3); + assert(chat_reactions_decode(wire, n, read, &count) == 0 && count == 2 && read[0] == 0 && read[1] == 5); + wire[2] = 0; assert(chat_reactions_decode(wire, n, read, &count) < 0); + assert(chat_reactions_encode(NULL, 0, wire, sizeof(wire)) == 0); + puts("PASS: canonical codecs, truncation, duplicate choices and UTF-8 rejection"); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); codecs(); + struct UASYNC* ua = uasync_create(); assert(ua); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, + "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" + "[routing]\nmy_subnet=192.168.42.0/24\n[server: udp]\naddr=127.0.0.1:0\ntype=public\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"); + assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); + chat_core_create_channel_auto(inst, "Poll tests"); + sqlite3* db = inst->topo_sqlite_db; sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW); char channel[21]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + char request[4096], id[65]; uint8_t message_id[32]; + snprintf(request, sizeof(request), + "{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"Куда? \\\"Да\\\"\\nНовая строка\"," + "\"mode\":\"single\",\"options\":[\"Домой\",\"В парк\"],\"allow_custom_answer\":false}", channel); + char* result = command(inst, request, 0); json_string(db, result, "$.message_id", id, sizeof(id)); + assert(sc_hex_to_binary(id, message_id, 32) == 0); + assert(json_number(db, result, "$.poll.voters") == 0); + char question[1024]; json_string(db, result, "$.poll.question", question, sizeof(question)); + assert(!strcmp(question, "Куда? \"Да\"\nНовая строка")); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[0]}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.voters") == 1); + assert(json_number(db, result, "$.poll.options[0].count") == 1 && json_number(db, result, "$.poll.ver") == 1); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[0,1]}", channel, id); + result = command(inst, request, -1); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"custom\":\"Свой вариант\"}", channel, id); + result = command(inst, request, -1); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[1]}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.options[0].count") == 0); + assert(json_number(db, result, "$.poll.options[1].count") == 1 && json_number(db, result, "$.poll.ver") == 2); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\"}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.voters") == 0); + assert(json_number(db, result, "$.poll.ver") == 3); u_free(result); + puts("PASS: single-choice rules, disabled custom answer, replacement and withdrawal"); + snprintf(request, sizeof(request), "{\"cmd\":\"react\",\"ch\":\"%s\",\"message_id\":\"%s\",\"emoji\":0}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.reactions[0].count") == 1); u_free(result); + result = command(inst, request, 0); assert(json_number(db, result, "$.reaction_ver") == 2); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"Выбор\",\"mode\":\"multiple\"," + "\"options\":[\"Первый\",\"Второй\"],\"allow_custom_answer\":true}", channel); + result = command(inst, request, 0); json_string(db, result, "$.message_id", id, sizeof(id)); u_free(result); + assert(sc_hex_to_binary(id, message_id, 32) == 0); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[1,0],\"custom\":\"Мой \\\"ответ\\\"\"}", channel, id); + result = command(inst, request, 0); + assert(json_number(db, result, "$.poll.voters") == 1 && json_number(db, result, "$.poll.custom_count") == 1); + assert(json_number(db, result, "$.poll.options[0].percent") == 100 && json_number(db, result, "$.poll.options[1].percent") == 100); + u_free(result); + /* Исходное сообщение временно отсутствует: ответ остаётся в Merkle, но не считается. */ + char sql[512]; snprintf(sql, sizeof(sql), "CREATE TEMP TABLE poll_backup AS SELECT * FROM msg_%s", channel); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK); + snprintf(sql, sizeof(sql), "DELETE FROM msg_%s WHERE message_id=?", channel); + assert(sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); assert(sqlite3_step(st) == SQLITE_DONE); sqlite3_finalize(st); + struct chat_feedback_record answer; + assert(chat_feedback_get(inst, channel, message_id, inst->node_id, CHAT_FEEDBACK_ANSWER, &answer) == 1); + answer.ver++; uint8_t body[FB_WIRE_MAX + 32]; int bytes = fb_signed_bytes(strtoull(channel, NULL, 10), &answer, body); + assert(bytes > 0 && sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, answer.signature) == SC_OK); + assert(chat_feedback_apply(inst, channel, &answer) == 1); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.pending") == 1); + u_free(result); + snprintf(sql, sizeof(sql), "INSERT OR IGNORE INTO msg_%s SELECT * FROM poll_backup", channel); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK); chat_feedback_check_message(inst, channel, message_id); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.pending") == 0); + assert(json_number(db, result, "$.poll.voters") == 1 && json_number(db, result, "$.poll.ver") == 2); u_free(result); + /* Неверный подписанный ответ синхронизируется, но исключается из результатов. */ + struct chat_poll_answer wrong = { .count = 1, .choices = {7} }; + answer.data_len = chat_answer_encode(&wrong, answer.data, sizeof(answer.data)); answer.ver++; + bytes = fb_signed_bytes(strtoull(channel, NULL, 10), &answer, body); + assert(sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, answer.signature) == SC_OK); + assert(chat_feedback_apply(inst, channel, &answer) == 1); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.poll.voters") == 0); + u_free(result); + char listing[16384]; size_t len; + assert(chat_core_get_messages_json(inst, channel, 20, 0, listing, sizeof(listing), &len) == 0 && strstr(listing, id)); + char tiny[8]; assert(chat_core_get_messages_json(inst, channel, 20, 0, tiny, sizeof(tiny), &len) < 0 && !len); + result = command(inst, "{\"cmd\":\"reaction_catalog\"}", 0); + assert(json_number(db, result, "$.catalog[0].id") == 0); u_free(result); + puts("PASS: multi-choice custom answers, delayed source, invalid signed answer and complete message listing"); + utun_instance_destroy(inst); uasync_poll(ua, 0); + assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); + uasync_destroy(ua, 0); + puts("ALL PASS: poll API and codecs"); + return 0; +} From 0a05a2095e4ba8dccd4e1776e7b9f56681d8de7c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:34:38 +0200 Subject: [PATCH 29/49] Read stable message identities and decode escaped text in desktop --- tools/chatgui/db/db_manager.cpp | 20 +++++++++----------- tools/chatgui/db/db_manager.h | 1 + 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/tools/chatgui/db/db_manager.cpp b/tools/chatgui/db/db_manager.cpp index 88bdf588..49fd748c 100644 --- a/tools/chatgui/db/db_manager.cpp +++ b/tools/chatgui/db/db_manager.cpp @@ -6,18 +6,13 @@ #include #include #include +#include +#include /* ── helpers ── */ static QByteArray parseJsonField(const QByteArray& json, const char* key) { - QByteArray pat = "\"" + QByteArray(key) + "\":\""; - int idx = json.indexOf(pat); - if (idx < 0) return {}; - idx += pat.size(); - int end = idx; - while (end < json.size() && !(json[end] == '"' && (end == idx || json[end - 1] != '\\'))) end++; - if (end > idx) return QByteArray(json.constData() + idx, end - idx); - return {}; + return QJsonDocument::fromJson(json).object().value(QString::fromLatin1(key)).toString().toUtf8(); } QString DbManager::sanitizeTableName(const QString& chId) { @@ -239,7 +234,7 @@ QString DbManager::getChannelLastMessage(const QString& channelId) const { QList DbManager::getMessages(const QString& chId, int limit) const { QList list; QString sql = QStringLiteral( - "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs" + "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs, message_id" " FROM \"%1\" ORDER BY timestamp DESC, author_signature DESC LIMIT ?").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; @@ -259,6 +254,7 @@ QList DbManager::getMessages(const QString& chId, int limit) const { m.datahash = 0; m.chainHash = colBlob(stmt, 4); m.signature = colBlob(stmt, 5); + m.messageId = colBlob(stmt, 7); m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6)); m.isOutgoing = (m.authorNodeId == myId); m.isRead = true; @@ -271,7 +267,7 @@ QList DbManager::getMessages(const QString& chId, int limit) const { QList DbManager::getMessagesSince(const QString& chId, qint64 sinceId) const { QList list; QString sql = QStringLiteral( - "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs" + "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs, message_id" " FROM \"%1\" WHERE id > ? ORDER BY id ASC").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return list; @@ -289,6 +285,7 @@ QList DbManager::getMessagesSince(const QString& chId, qint64 sinceI m.datahash = 0; m.chainHash = colBlob(stmt, 4); m.signature = colBlob(stmt, 5); + m.messageId = colBlob(stmt, 7); m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6)); m.isOutgoing = (m.authorNodeId == myId); m.isRead = true; @@ -301,7 +298,7 @@ QList DbManager::getMessagesSince(const QString& chId, qint64 sinceI MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const { MessageRow m; QString sql = QStringLiteral( - "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs" + "SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs, message_id" " FROM \"%1\" WHERE id = ?").arg(msgTableName(chId)); sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData()); if (!stmt) return m; @@ -317,6 +314,7 @@ MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const { m.datahash = 0; m.chainHash = colBlob(stmt, 4); m.signature = colBlob(stmt, 5); + m.messageId = colBlob(stmt, 7); m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6)); m.isOutgoing = (m.authorNodeId == m_myNodeId); m.isRead = true; diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index 5153e7f7..8bb2b09e 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -42,6 +42,7 @@ struct MessageRow { uint64_t datahash = 0; QByteArray chainHash; QByteArray signature; + QByteArray messageId; // общий ID подписанного сообщения, 32 байта QByteArray localAttrs; bool isOutgoing = false; bool isRead = false; From c1048686d1c62e83c067d1febe659ca15074ffdf Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:34:38 +0200 Subject: [PATCH 30/49] Route feedback state and command results to the desktop thread --- tools/chatgui/transport/gui_bridge.h | 3 +++ tools/chatgui/transport/gui_bridge_impl.cpp | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index b5a75cfc..06e8fc5c 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -243,6 +243,9 @@ void gui_bridge_set_member_files_response_cb(gui_dm_messages_fn cb); void gui_bridge_set_member_files_transfer_cb(gui_dm_messages_fn cb); void gui_bridge_set_avatar_result_cb(gui_dm_messages_fn cb); void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb); +/* Опросы/реакции: адресное изменение и коррелированный ответ команды, в GUI-потоке. */ +void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb); +void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb); /* CHAT_EVT_ATTACHMENT_PREPARATION: target + job UUID + state. */ void gui_bridge_set_attachment_preparation_cb(gui_dm_messages_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 21dbf69f..3c8560c3 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -70,6 +70,8 @@ static gui_dm_messages_fn g_member_files_response_cb = nullptr; static gui_dm_messages_fn g_member_files_transfer_cb = nullptr; static gui_dm_messages_fn g_avatar_result_cb = nullptr; static gui_dm_messages_fn g_group_avatar_result_cb = nullptr; +static gui_dm_messages_fn g_feedback_changed_cb = nullptr; +static gui_dm_messages_fn g_feedback_result_cb = nullptr; static gui_network_changed_fn g_network_changed_cb = nullptr; static struct UASYNC* g_ua = nullptr; static struct UTUN_INSTANCE* g_inst = nullptr; @@ -84,6 +86,12 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_FEEDBACK_CHANGED: + if (g_feedback_changed_cb) g_feedback_changed_cb(d, dlen); + break; + case CHAT_EVT_FEEDBACK_RESULT: + if (g_feedback_result_cb) g_feedback_result_cb(d, dlen); + break; case CHAT_EVT_GROUP_AVATAR_RESULT: if (g_group_avatar_result_cb) g_group_avatar_result_cb(d, dlen); break; @@ -581,6 +589,8 @@ void gui_bridge_set_member_files_response_cb(gui_dm_messages_fn cb) { g_member_f void gui_bridge_set_member_files_transfer_cb(gui_dm_messages_fn cb) { g_member_files_transfer_cb = cb; } void gui_bridge_set_avatar_result_cb(gui_dm_messages_fn cb) { g_avatar_result_cb = cb; } void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb) { g_group_avatar_result_cb = cb; } +void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb) { g_feedback_changed_cb = cb; } +void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb) { g_feedback_result_cb = cb; } void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) { g_dm_conv_updated_cb = cb; From ebc96712a23fe90a4fc3494674c8c89dbbd6d8b2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:36:43 +0200 Subject: [PATCH 31/49] Track feedback by message identity in desktop UI --- tools/chatgui/src/mainwindow.cpp | 8 ++++++++ tools/chatgui/src/messagedelegate.h | 2 ++ tools/chatgui/src/messagelist.h | 17 +++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index f4e6ea76..7df2170f 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -184,6 +184,8 @@ MainWindow::~MainWindow() { delete AvatarManager::instance(); delete GroupAvatarManager::instance(); gui_bridge_set_group_avatar_result_cb(nullptr); + gui_bridge_set_feedback_changed_cb(nullptr); + gui_bridge_set_feedback_result_cb(nullptr); gui_bridge_set_avatar_result_cb(nullptr); if (m_fileBrowser) delete m_fileBrowser.data(); gui_bridge_set_member_files_response_cb(nullptr); @@ -418,6 +420,12 @@ void MainWindow::setupBridgeCallbacks() { if (GroupAvatarManager::instance()) GroupAvatarManager::instance()->onResult(data, length); }); gui_bridge_set_msg_received_cb(onMsgReceivedCallback); + gui_bridge_set_feedback_changed_cb([](const uint8_t* data, int length) { + if (s_mainWindow && s_mainWindow->m_messageList) s_mainWindow->m_messageList->onFeedbackChanged(data, length); + }); + gui_bridge_set_feedback_result_cb([](const uint8_t* data, int length) { + if (s_mainWindow && s_mainWindow->m_messageList) s_mainWindow->m_messageList->onFeedbackResult(data, length); + }); gui_bridge_set_channel_updated_cb(onChannelUpdatedCallback); gui_bridge_set_member_list_cb(onMemberListCallback); gui_bridge_set_member_updated_cb(onMemberUpdatedCallback); diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 764f9108..b58267e4 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -44,6 +44,8 @@ enum MessageDataRole { MsgVideoDurationMsRole = Qt::UserRole + 35, MsgDeliveryStateRole = Qt::UserRole + 36, MsgInviteLinkRole = Qt::UserRole + 37, /* полная ссылка; MsgTextRole содержит локализованный заголовок и ссылку */ + MsgMessageKeyRole = Qt::UserRole + 38, + MsgRawTimestampRole = Qt::UserRole + 39, }; class MessageDelegate : public QStyledItemDelegate { diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index beb0ca01..65cf75ff 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include class ChatView; class InputBar; @@ -18,6 +20,8 @@ class AudioRecorder; class QLabel; class QPushButton; class RadioPanel; +class QDialog; +struct MessageRow; class MessageList : public QWidget { Q_OBJECT @@ -37,6 +41,8 @@ public: void onDmCallRoute(const uint8_t* data, int len); void clearDmCallRoute(); void onAttachmentPreparation(const uint8_t* data, int len); + void onFeedbackChanged(const uint8_t* data, int len); + void onFeedbackResult(const uint8_t* data, int len); void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */ signals: @@ -73,6 +79,17 @@ private: bool m_radioOn = false; QThreadPool m_thumbnailPool; QSet m_thumbnailJobs; + QPushButton* m_pollBtn = nullptr; + QHash m_feedback; + QPointer m_pollDialog; + QString m_pollDialogMessage, m_pendingPoll; + quint64 m_feedbackSequence = 0; + + void sendFeedback(QJsonObject command, bool userAction = true); + void setupFeedback(QStandardItem* item, const MessageRow& message, const QString& channel); + void createPoll(); + void showPoll(const QString& messageId); + void applyFeedback(const QJsonObject& state); QString authorName(quint64 nodeId) const; void restoreScrollPosition(const QString& channelId); From cf869770fb733a0dfea3acd4f7ee6123b30a382a Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:39:13 +0200 Subject: [PATCH 32/49] Add desktop poll creation, voting and live result dialogs --- tools/chatgui/CMakeLists.txt | 1 + tools/chatgui/src/messagefeedback.cpp | 194 ++++++++++++++++++++++++++ tools/chatgui/src/messagelist.cpp | 19 ++- 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tools/chatgui/src/messagefeedback.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 4e30fe4b..f543e779 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -144,6 +144,7 @@ add_executable(vibechat src/messagedelegate.cpp src/message_links.cpp src/messagelist.cpp + src/messagefeedback.cpp src/accountlist.cpp src/accountdelegate.cpp src/flagpainter.cpp diff --git a/tools/chatgui/src/messagefeedback.cpp b/tools/chatgui/src/messagefeedback.cpp new file mode 100644 index 00000000..dce87940 --- /dev/null +++ b/tools/chatgui/src/messagefeedback.cpp @@ -0,0 +1,194 @@ +#include "messagelist.h" +#include "messagedelegate.h" +#include "chatview.h" +#include "debug_ui.h" +#include "../db/db_manager.h" +#include "../transport/gui_bridge.h" +#include "chat/chat_poll.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "../../../lib/mem.h" +} + +static QString feedbackText(const char* source) { return QCoreApplication::translate("ChatGui", source); } + +/* Итог для баббла и открытого диалога; строковые поля показываем как обычный текст. */ +static QString pollSummary(const QJsonObject& poll) { + QString text = poll.value("question").toString(); + for (const auto& value : poll.value("options").toArray()) { + const auto option = value.toObject(); + text += QStringLiteral("\n%1 — %2 (%3%)").arg(option.value("text").toString()) + .arg(option.value("count").toInt()).arg(option.value("percent").toDouble(), 0, 'f', 1); + } + text += QStringLiteral("\n%1: %2").arg(feedbackText("Participants")).arg(poll.value("voters").toInt()); + return text; +} + +/* Команды владеют только C-данными; QObject остаются в GUI-потоке. */ +void MessageList::sendFeedback(QJsonObject command, bool userAction) { + if (!gui_bridge_get_inst()) { GUI_ERROR("feedback: core unavailable"); return; } + if (!command.contains("ch")) command["ch"] = m_currentChannelId; + QByteArray json = QJsonDocument(command).toJson(QJsonDocument::Compact); + auto* req = static_cast(u_malloc(sizeof(chat_feedback_request) + json.size() + 1)); + if (!req) { GUI_ERROR("feedback: command allocation failed bytes=%d", json.size()); return; } + req->inst = gui_bridge_get_inst(); req->request_id = userAction ? ++m_feedbackSequence : 0; + memcpy(req + 1, json.constData(), json.size()); reinterpret_cast(req + 1)[json.size()] = 0; + gui_bridge_post_uasync_fn(chat_feedback_command_trampoline, req); +} + +void MessageList::setupFeedback(QStandardItem* item, const MessageRow& message, const QString& channel) { + item->setData(QString::fromLatin1(message.messageId.toHex()), MsgMessageKeyRole); + item->setData(message.timestamp, MsgRawTimestampRole); item->setData(channel, MsgChannelIdRole); + if (message.contentType == "poll") { + struct chat_poll poll; + QByteArray wire = QByteArray::fromBase64(message.data.mid(4), QByteArray::Base64UrlEncoding); + if (chat_poll_decode(reinterpret_cast(wire.constData()), wire.size(), &poll) == 0) + item->setData(QString::fromUtf8(poll.question), MsgTextRole); + else item->setData(feedbackText("Invalid poll"), MsgTextRole); + } + if (message.messageId.size() == 32) sendFeedback({{"cmd", "feedback"}, {"ch", channel}, + {"message_id", QString::fromLatin1(message.messageId.toHex())}}, false); +} + +void MessageList::onFeedbackChanged(const uint8_t* data, int len) { + if (!data || len < 34 || len != data[0] + 34) { GUI_ERROR("feedback: invalid change event bytes=%d", len); return; } + QString channel = QString::fromUtf8(reinterpret_cast(data + 1), data[0]); + if (channel != m_currentChannelId) return; + QString id = QString::fromLatin1(QByteArray(reinterpret_cast(data + 1 + data[0]), 32).toHex()); + sendFeedback({{"cmd", "feedback"}, {"message_id", id}}, false); +} + +void MessageList::onFeedbackResult(const uint8_t* data, int len) { + if (!data || len < 9) { GUI_ERROR("feedback: invalid result event bytes=%d", len); return; } + quint64 request; memcpy(&request, data, 8); + QJsonObject state = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data + 9), len - 9)).object(); + if (!data[8]) { + GUI_ERROR("feedback: command failed request=%llu reason=%s", (unsigned long long)request, qPrintable(state.value("error").toString())); + if (request) { m_pendingPoll.clear(); QMessageBox::warning(this, feedbackText("Polls and reactions"), state.value("error").toString()); } + return; + } + if (state.value("ch").toString() != m_currentChannelId) return; + applyFeedback(state); +} + +void MessageList::applyFeedback(const QJsonObject& state) { + QString id = state.value("message_id").toString(); m_feedback[id] = state; + QVariantList reactions; + for (const auto& value : state.value("reactions").toArray()) reactions.append(value.toObject().toVariantMap()); + auto poll = state.value("poll").toObject(); + for (int row = 0; row < m_model->rowCount(); row++) { + auto* item = m_model->item(row); + if (item->data(MsgMessageKeyRole).toString() != id) continue; + item->setData(reactions, MsgReactionsRole); + if (!poll.isEmpty()) item->setData(pollSummary(poll), MsgTextRole); + } + if (m_pollDialog && m_pollDialogMessage == id) { + auto* results = m_pollDialog->findChild("pollResults"); + if (results) { + QString text = pollSummary(poll); + const auto options = poll.value("options").toArray(); + for (const auto& value : poll.value("answers").toArray()) { + auto row = value.toObject(), answer = row.value("answer").toObject(); QStringList parts; + for (const auto& choice : answer.value("choices").toArray()) + if (choice.toInt() >= 0 && choice.toInt() < options.size()) parts << options[choice.toInt()].toObject().value("text").toString(); + if (!answer.value("custom").toString().isEmpty()) parts << answer.value("custom").toString(); + text += QStringLiteral("\n%1: %2").arg(authorName(row.value("node_id").toString().toULongLong(nullptr, 0)), parts.join(", ")); + } + results->setPlainText(text); + } + } + if (m_pendingPoll == id && !poll.isEmpty()) { m_pendingPoll.clear(); showPoll(id); } +} + +void MessageList::createPoll() { + if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return; + QDialog dialog(this); dialog.setWindowTitle(feedbackText("Create poll")); auto* layout = new QVBoxLayout(&dialog); + auto* question = new QPlainTextEdit(&dialog); question->setPlaceholderText(feedbackText("Question")); question->setMaximumHeight(70); + auto* options = new QPlainTextEdit(&dialog); options->setPlaceholderText(feedbackText("One option per line")); + auto* mode = new QComboBox(&dialog); mode->addItem(feedbackText("Single choice")); mode->addItem(feedbackText("Multiple choices")); + auto* custom = new QCheckBox(feedbackText("Allow a custom answer"), &dialog); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + layout->addWidget(question); layout->addWidget(options); layout->addWidget(mode); layout->addWidget(custom); layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&, this]() { + const QString title = question->toPlainText().trimmed(); QJsonArray values; QSet unique; + int bytes = 6 + title.toUtf8().size(); bool valid = !title.isEmpty() && title.toUtf8().size() <= CHAT_POLL_QUESTION_MAX; + for (const auto& line : options->toPlainText().split('\n')) { + QString text = line.trimmed(); if (text.isEmpty()) continue; + if (unique.contains(text) || text.toUtf8().size() > CHAT_POLL_OPTION_MAX) valid = false; + unique.insert(text); values.append(text); bytes += 2 + text.toUtf8().size(); + } + if (!valid || values.size() < 2 || values.size() > CHAT_POLL_OPTIONS_MAX || bytes > CHAT_POLL_WIRE_MAX) { + QMessageBox::warning(&dialog, feedbackText("Create poll"), feedbackText("Enter a question and distinct options within the size limit.")); return; + } + sendFeedback({{"cmd", "poll_create"}, {"question", title}, {"options", values}, + {"mode", mode->currentIndex() ? "multiple" : "single"}, {"allow_custom_answer", custom->isChecked()}}); + dialog.accept(); + }); + dialog.resize(420, 360); dialog.exec(); +} + +void MessageList::showPoll(const QString& messageId) { + if (m_pollDialog) { m_pollDialog->raise(); return; } + auto state = m_feedback.value(messageId), poll = state.value("poll").toObject(); + if (poll.isEmpty()) { m_pendingPoll = messageId; sendFeedback({{"cmd", "feedback"}, {"message_id", messageId}}); return; } + QDialog dialog(this); dialog.setWindowTitle(feedbackText("Vote / results")); + m_pollDialog = &dialog; m_pollDialogMessage = messageId; auto* layout = new QVBoxLayout(&dialog); + auto* title = new QLabel(poll.value("question").toString(), &dialog); title->setTextFormat(Qt::PlainText); title->setWordWrap(true); + layout->addWidget(title); + const bool multiple = poll.value("mode").toString() == "multiple"; + auto* group = new QButtonGroup(&dialog); group->setExclusive(!multiple); + QList choices; QSet selected; + for (const auto& value : poll.value("mine").toObject().value("choices").toArray()) selected.insert(value.toInt()); + auto values = poll.value("options").toArray(); + for (int i = 0; i < values.size(); i++) { + QString text = values[i].toObject().value("text").toString(); + QAbstractButton* choice = multiple ? static_cast(new QCheckBox(text, &dialog)) + : static_cast(new QRadioButton(text, &dialog)); + group->addButton(choice, i); choice->setChecked(selected.contains(i)); layout->addWidget(choice); choices.append(choice); + } + auto* own = new QPlainTextEdit(&dialog); own->setMaximumHeight(80); own->setPlaceholderText(feedbackText("Your answer")); + own->setPlainText(poll.value("mine").toObject().value("custom").toString()); + QRadioButton* other = nullptr; + if (poll.value("allow_custom_answer").toBool()) { + if (!multiple) { + other = new QRadioButton(feedbackText("Your answer"), &dialog); group->addButton(other); + layout->addWidget(other); connect(other, &QRadioButton::toggled, own, &QWidget::setEnabled); + other->setChecked(!own->toPlainText().isEmpty()); own->setEnabled(other->isChecked()); + } + layout->addWidget(own); + } else own->hide(); + auto* results = new QPlainTextEdit(&dialog); results->setObjectName("pollResults"); results->setReadOnly(true); + layout->addWidget(results); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, &dialog); + auto* withdraw = buttons->addButton(feedbackText("Withdraw vote"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + connect(withdraw, &QPushButton::clicked, &dialog, [&, this]() { + sendFeedback({{"cmd", "vote"}, {"message_id", messageId}, {"choices", QJsonArray{}}, {"custom", ""}}); dialog.accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&, this]() { + QJsonArray ids; for (int i = 0; i < choices.size(); i++) if (choices[i]->isChecked()) ids.append(i); + QString text = poll.value("allow_custom_answer").toBool() && (multiple || (other && other->isChecked())) + ? own->toPlainText().trimmed() : QString(); + if (text.toUtf8().size() > CHAT_POLL_CUSTOM_MAX || (other && other->isChecked() && text.isEmpty())) { + QMessageBox::warning(&dialog, feedbackText("Your answer"), feedbackText("Enter a shorter nonempty answer.")); return; + } + sendFeedback({{"cmd", "vote"}, {"message_id", messageId}, {"choices", ids}, {"custom", text}}); dialog.accept(); + }); + applyFeedback(state); dialog.resize(450, 560); dialog.exec(); m_pollDialog = nullptr; m_pollDialogMessage.clear(); +} diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 302d3871..6cebe3e2 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -41,6 +41,7 @@ static std::vector> s_icons; extern "C" { #include "../transport/gui_bridge.h" #include "chat/chat_core.h" +#include "chat/chat_poll.h" #include "dm/dm_core.h" #include "dm/dm_media.h" #include "video/video.h" @@ -374,6 +375,9 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;"); m_channelTitle->setText(QCoreApplication::translate("ChatGui", "Channel")); hl->addWidget(m_channelTitle, 1); + m_pollBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Create poll"), header); + hl->addWidget(m_pollBtn); + connect(m_pollBtn, &QPushButton::clicked, this, &MessageList::createPoll); m_talkingLabel = new QLabel(header); m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;"); @@ -663,7 +667,9 @@ void MessageList::loadChannel(const QString& channelId) { m_model->clear(); m_avatarCache.clear(); + if (m_currentChannelId != channelId) { m_feedback.clear(); m_pendingPoll.clear(); } m_currentChannelId = channelId; + m_pollBtn->setVisible(!isDmChannel(channelId)); clearDmCallRoute(); m_callBtn->setVisible(isDmChannel(channelId)); m_inviteBtn->setVisible(isDmChannel(channelId)); @@ -739,6 +745,7 @@ void MessageList::loadChannel(const QString& channelId) { setVoiceMessageRoles(item, m.data, m.contentType, m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs); m_model->appendRow(item); + setupFeedback(item, m, channelId); } if (sameChannel) { @@ -780,12 +787,9 @@ void MessageList::refresh() { if (!m_db || !m_db->isOpen()) return; - qint64 maxId = 0; - for (int r = 0; r < m_model->rowCount(); r++) - if (m_model->item(r)->data(MsgIdRole).toLongLong() > maxId) - maxId = m_model->item(r)->data(MsgIdRole).toLongLong(); - - auto newMsgs = m_db->getMessagesSince(m_currentChannelId, maxId); + QSet present; + for (int row = 0; row < m_model->rowCount(); row++) present.insert(m_model->item(row)->data(MsgMessageKeyRole).toString()); + auto newMsgs = m_db->getMessages(m_currentChannelId, 200); if (newMsgs.isEmpty()) return; m_localNicks = m_db->getLocalNicknames(m_currentChannelId); @@ -794,6 +798,7 @@ void MessageList::refresh() { bool wasAtBottom = sb && (sb->value() >= sb->maximum()); for (const auto& m : newMsgs) { + if (present.contains(QString::fromLatin1(m.messageId.toHex()))) continue; AccountRow acc = m_db->getAccount(m.authorNodeId); QString author = authorName(m.authorNodeId); QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter; @@ -815,10 +820,12 @@ void MessageList::refresh() { setVoiceMessageRoles(item, m.data, m.contentType, m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); m_model->appendRow(item); + setupFeedback(item, m, m_currentChannelId); while (m_model->rowCount() > 200) m_model->removeRow(0); } + m_model->setSortRole(MsgRawTimestampRole); m_model->sort(0); if (wasAtBottom) m_view->scrollToBottom(); m_readDebounce->start(); } From 39e94fdfd38ac3aea47a96f6de057b6d5e766a47 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:39:14 +0200 Subject: [PATCH 33/49] Add message reaction menus and distinguish own reactions --- tools/chatgui/src/messagedelegate.cpp | 1 + tools/chatgui/src/messagelist.cpp | 29 ++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index ed2955a3..26a0eb9c 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/tools/chatgui/src/messagedelegate.cpp @@ -1573,6 +1573,7 @@ void MessageDelegate::drawStatusBar(QPainter *p, const Layout &L, } QFont countFont = opt.font; + countFont.setBold(rm.value("mine").toBool()); countFont.setPointSize(countFont.pointSize() - 2); p->setFont(countFont); p->setPen(QColor(0x80, 0x80, 0x80)); diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 6cebe3e2..d6dcc9ac 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -1054,21 +1054,40 @@ void MessageList::onMessageContextMenu(const QPoint& pos) { bool isImage = ct.startsWith("image/"); bool isDmFile = isDmChannel(idx.data(MsgChannelIdRole).toString()) && ct == "application/octet-stream"; bool isDm = isDmChannel(idx.data(MsgChannelIdRole).toString()); - if (!isVideo && !isImage && !isDmFile) return; QString filePath = idx.data(MsgVoiceFileRole).toString(); QString displayName = idx.data(MsgFileDisplayNameRole).toString(); bool downloaded = !filePath.isEmpty() && QFile::exists(filePath); QMenu menu(m_view); - QAction* saveAct = menu.addAction(QCoreApplication::translate("ChatGui", "Save to file")); - saveAct->setEnabled(downloaded); + QString messageId = idx.data(MsgMessageKeyRole).toString(); + if (!isDm && messageId.size() == 64) { + if (ct == "poll") connect(menu.addAction(QCoreApplication::translate("ChatGui", "Vote / results")), &QAction::triggered, + this, [this, messageId]() { showPoll(messageId); }); + auto* reactions = menu.addMenu(QCoreApplication::translate("ChatGui", "React")); + QSet mine; + for (const auto& value : m_feedback.value(messageId).value("reactions").toArray()) + if (value.toObject().value("mine").toBool()) mine.insert(value.toObject().value("id").toInt()); + for (unsigned i = 0; i < CHAT_REACTION_COUNT; i++) { + auto* action = reactions->addAction(QString::fromUtf8(chat_reaction_emoji(i))); + action->setCheckable(true); action->setChecked(mine.contains(i)); + connect(action, &QAction::triggered, this, [this, messageId, i]() { + sendFeedback({{"cmd", "react"}, {"message_id", messageId}, {"emoji", (int)i}}); + }); + } + connect(menu.addAction(QCoreApplication::translate("ChatGui", "Clear reactions")), &QAction::triggered, + this, [this, messageId]() { sendFeedback({{"cmd", "react"}, {"message_id", messageId}, {"clear", true}}); }); + } + QAction* saveAct = nullptr; + if (isVideo || isImage || isDmFile) { + saveAct = menu.addAction(QCoreApplication::translate("ChatGui", "Save to file")); saveAct->setEnabled(downloaded); + } QAction* dlAct = nullptr; - if (!downloaded && !isDm) dlAct = menu.addAction(QCoreApplication::translate("ChatGui", "Download")); + if ((isVideo || isImage) && !downloaded && !isDm) dlAct = menu.addAction(QCoreApplication::translate("ChatGui", "Download")); QAction* chosen = menu.exec(m_view->viewport()->mapToGlobal(pos)); - if (chosen == saveAct) { + if (saveAct && chosen == saveAct) { QString base = displayName; if (base.isEmpty()) base = QFileInfo(filePath).fileName(); QString dest = QFileDialog::getSaveFileName(this, QCoreApplication::translate("ChatGui", "Save file"), base); From 2c31e3fe03e4e5711b712f296a4ec225f410f43a Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:43:21 +0200 Subject: [PATCH 34/49] Expose UTF-8 feedback commands and full message identities on Android --- .../main/java/com/utun/chat/data/NativeLib.kt | 2 + .../jni_bridge/android_jni_bridge.c | 172 +++++++++--------- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index c493adff..60728ec0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -97,6 +97,7 @@ object NativeLib { fun connectMemberNode(channelId: String, nodeId: Long) { nativeConnectMemberNode(channelId, nodeId) } fun getChannels(): String = nativeGetChannels() ?: "[]" fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]" + fun feedbackCommand(json: String, requestId: Long = 0): Boolean = nativeFeedbackCommand(json.toByteArray(Charsets.UTF_8), requestId) fun clearDatabase(): Boolean = nativeClearDatabase() fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) } @@ -237,6 +238,7 @@ object NativeLib { private external fun nativeConnectMemberNode(channelId: String, nodeId: Long) private external fun nativeGetChannels(): String? private external fun nativeGetMessages(channelId: String, limit: Int): String? + private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean private external fun nativeSetDebugLevel(category: String, level: String) private external fun nativeSetChatSetting(name: String, value: String) private external fun nativeGetChatSettingInt(name: String, def: Int): Int diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 274adf50..d426e342 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -27,6 +27,8 @@ #include "../../../lib/mem.h" #include "../../../lib/sqlite3.h" #include "../../../src/chat/chat_core.h" +#include "../../../src/chat/chat_poll.h" +#include "../../../lib/strbuf.h" #include "../../../src/chat/chat_member.h" #include "../../../src/chat/group_avatar.h" #include "../../../src/member_files/member_files.h" @@ -499,104 +501,79 @@ char* utun_bridge_get_channels_json(void) { } char* utun_bridge_get_messages_json(const char* channel_id, int limit) { - struct UTUN_INSTANCE* inst = instance_lite_get_instance(); - sqlite3* db = chat_core_get_db(inst); + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); sqlite3* db = chat_core_get_db(inst); if (!chat_core_is_initialized(inst) || !db || !channel_id || !channel_id[0]) return u_strdup("[]"); - uint64_t my_id = inst->node_id; - - char tbl_msg[80]; - msg_table_name(channel_id, tbl_msg, sizeof(tbl_msg)); - - char sql[512]; + char table[80], sql[1024]; msg_table_name(channel_id, table, sizeof(table)); snprintf(sql, sizeof(sql), - "SELECT id, node_id, data, timestamp, local_attrs FROM (" - "SELECT id, node_id, data, timestamp, local_attrs, author_signature FROM \"%s\"" - " ORDER BY timestamp DESC, author_signature DESC LIMIT %d" - ") ORDER BY timestamp ASC, author_signature ASC", tbl_msg, limit); - + "SELECT id,node_id,timestamp,local_attrs,message_id," + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.ct') END," + "CASE WHEN json_valid(CAST(data AS TEXT)) THEN json_extract(CAST(data AS TEXT),'$.d') END" + " FROM (SELECT * FROM \"%s\" ORDER BY timestamp DESC,author_signature DESC LIMIT ?)" + " ORDER BY timestamp,author_signature", table); sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return u_strdup("[]"); - - size_t cap = 4096, pos = 1; - char* json = u_malloc(cap); - if (!json) { sqlite3_finalize(st); return u_strdup("[]"); } - json[0] = '['; - - int first = 1; - while (sqlite3_step(st) == SQLITE_ROW) { - int64_t msg_id = sqlite3_column_int64(st, 0); - int64_t node_id = sqlite3_column_int64(st, 1); - int64_t ts = sqlite3_column_int64(st, 3); - const uint8_t* dptr = sqlite3_column_blob(st, 2); - int dlen = sqlite3_column_bytes(st, 2); - const char* lattr = (const char*)sqlite3_column_text(st, 4); - - char txt[2048] = "", ct[64] = "text/plain"; - if (dptr && dlen > 0) { - const char* ds = strstr((const char*)dptr, "\"d\":\""); - if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 2000) tl = 2000; memcpy(txt, ds, tl); txt[tl] = '\0'; } } - const char* cts = strstr((const char*)dptr, "\"ct\":\""); - if (cts) { cts += 6; char* cte = strchr((char*)cts, '"'); if (cte) { size_t tlc = (size_t)(cte - cts); if (tlc < sizeof(ct)) { memcpy(ct, cts, tlc); ct[tlc] = '\0'; } } } + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: messages query ch=%s: %s", channel_id, sqlite3_errmsg(db)); return u_strdup("[]"); + } + sqlite3_bind_int(st, 1, limit > 0 ? limit : 50); + struct strbuf out = strbuf_new(); int step, result = strbuf_addf(&out, "["); + unsigned count = 0; + while (result >= 0 && (step = sqlite3_step(st)) == SQLITE_ROW) { + int64_t local_id = sqlite3_column_int64(st, 0), node_id = sqlite3_column_int64(st, 1), ts = sqlite3_column_int64(st, 2); + const char* attrs = (const char*)sqlite3_column_text(st, 3); + const uint8_t* id = sqlite3_column_blob(st, 4); + const char* type = (const char*)sqlite3_column_text(st, 5), *body = (const char*)sqlite3_column_text(st, 6); + if (sqlite3_column_bytes(st, 4) != 32 || sqlite3_column_bytes(st, 6) > DB_SYNC_MESSAGE_MAX) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: corrupt message ch=%s id=%lld", channel_id, (long long)local_id); result = -1; break; } - /* base64-decode display prefix for media messages if marked with '*' (new format) */ - if (strcmp(ct, "text/plain") != 0 && txt[0] == '*') { - char* pipe = strchr(txt, '|'); + char message_id[65], text[DB_SYNC_MESSAGE_MAX + 1]; + for (unsigned i = 0; i < 32; i++) snprintf(message_id + 2 * i, 3, "%02x", id[i]); + snprintf(text, sizeof(text), "%s", body ? body : ""); if (!type) type = "text/plain"; + /* У вложений '*' кодирует отображаемое имя; poll использует отдельный b64: формат. */ + if (strcmp(type, "text/plain") && text[0] == '*') { + char* pipe = strchr(text, '|'); if (pipe) { - size_t prefix_len = (size_t)(pipe - txt); /* includes '*' */ - uint8_t dec[1024]; int declen = (int)b64_decode(txt + 1, prefix_len - 1, dec, sizeof(dec)); - if (declen > 0 && declen < 2000) { - /* shift suffix left, place decoded at start */ - size_t suffix_len = strlen(pipe); /* includes '|' */ - memmove(txt + declen, pipe, suffix_len + 1); - memcpy(txt, dec, (size_t)declen); - } + uint8_t decoded[1024]; size_t bytes = b64_decode(text + 1, pipe - text - 1, decoded, sizeof(decoded)); + size_t suffix = strlen(pipe); + if (bytes && bytes + suffix < sizeof(text)) { memmove(text + bytes, pipe, suffix + 1); memcpy(text, decoded, bytes); } } } - char* esc_txt = json_escape_alloc(txt); - char* esc_ct = json_escape_alloc(ct); - int is_out = (node_id == (int64_t)my_id) ? 1 : 0; - const char* sep = first ? "" : ","; - first = 0; - - char author_name[128]; - bridge_get_node_name(db, (uint64_t)node_id, channel_id, author_name, sizeof(author_name)); - if (!author_name[0]) - snprintf(author_name, sizeof(author_name), "0x%016llx", (unsigned long long)node_id); - - /* build filePath for voice/attachment messages */ - char file_path[1024] = ""; - if (lattr) { - const char* fps = strstr(lattr, "\"fp\":\""); - if (fps) { fps += 6; - char* fpe = strchr((char*)fps, '"'); - if (fpe) { size_t fpl = (size_t)(fpe - fps); - snprintf(file_path, sizeof(file_path), "%s/media/%s/%.*s", - g_db_path, channel_id, (int)fpl, fps); + char author[128], file_path[1024] = ""; + bridge_get_node_name(db, (uint64_t)node_id, channel_id, author, sizeof(author)); + if (!author[0]) snprintf(author, sizeof(author), "0x%016llx", (unsigned long long)node_id); + if (attrs) { + sqlite3_stmt* file = NULL; + if (sqlite3_prepare_v2(db, "SELECT CASE WHEN json_valid(?1) THEN json_extract(?1,'$.fp') END", -1, &file, NULL) == SQLITE_OK) { + sqlite3_bind_text(file, 1, attrs, -1, SQLITE_STATIC); + if (sqlite3_step(file) == SQLITE_ROW && sqlite3_column_type(file, 0) == SQLITE_TEXT) { + int bytes = snprintf(file_path, sizeof(file_path), "%s/media/%s/%s", + g_db_path, channel_id, sqlite3_column_text(file, 0)); + if (bytes >= (int)sizeof(file_path)) { + file_path[0] = 0; DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "bridge: media path too long ch=%s", channel_id); + } } - } + sqlite3_finalize(file); + } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: local attributes query: %s", sqlite3_errmsg(db)); } - char* esc_fp = file_path[0] ? json_escape_alloc(file_path) : NULL; - char* esc_la = lattr && lattr[0] ? json_escape_alloc(lattr) : NULL; - - size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"authorNodeId\":%lld,\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", - sep, (long long)msg_id, author_name, (long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, - esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "", - esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); - while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } - if (pos + needed + 2 <= cap) - pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"authorNodeId\":%lld,\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", - sep, (long long)msg_id, author_name, (long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, - esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "", - esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); - if (esc_la) u_free(esc_la); - if (esc_fp) u_free(esc_fp); - u_free(esc_ct); - u_free(esc_txt); + result = strbuf_addf(&out, "%s{\"id\":%lld,\"messageId\":\"%s\",\"authorNodeId\":%lld,\"author\":", + count++ ? "," : "", (long long)local_id, message_id, (long long)node_id); + if (result >= 0) result = strbuf_add_json_string(&out, author); + if (result >= 0) result = strbuf_addf(&out, ",\"ts\":%lld,\"isOutgoing\":%s,\"text\":", + (long long)ts, node_id == (int64_t)inst->node_id ? "true" : "false"); + if (result >= 0) result = strbuf_add_json_string(&out, text); + if (result >= 0) result = strbuf_addf(&out, ",\"contentType\":"); + if (result >= 0) result = strbuf_add_json_string(&out, type); + if (result >= 0) result = strbuf_addf(&out, ",\"filePath\":"); + if (result >= 0) result = strbuf_add_json_string(&out, file_path); + if (result >= 0) result = strbuf_addf(&out, ",\"localAttrs\":"); + if (result >= 0) result = strbuf_add_json_string(&out, attrs); + if (result >= 0) result = strbuf_addf(&out, "}"); } sqlite3_finalize(st); - - json[pos++] = ']'; json[pos] = '\0'; - return json; + if (result < 0 || step != SQLITE_DONE || strbuf_addf(&out, "]") < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: messages snapshot failed ch=%s: %s", channel_id, sqlite3_errmsg(db)); + strbuf_free(&out); return u_strdup("[]"); + } + return strbuf_detach(&out); } uint64_t utun_bridge_get_my_node_id(void) { @@ -1952,6 +1929,27 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( return result; } +/* UTF-8 JSON приходит байтами, чтобы emoji не превращались в JNI modified UTF-8. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFeedbackCommand( + JNIEnv* env, jobject thiz, jbyteArray json, jlong request_id) { + (void)thiz; + struct UASYNC* ua = instance_lite_get_uasync(); + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + if (!json || !inst || !uasync_is_running(ua)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: feedback core unavailable"); return JNI_FALSE; + } + jsize len = (*env)->GetArrayLength(env, json); + if (len <= 0 || len > 16384) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: feedback command size=%d", len); return JNI_FALSE; + } + struct chat_feedback_request* req = u_malloc(sizeof(*req) + len + 1); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: feedback allocation failed"); return JNI_FALSE; } + req->inst = inst; req->request_id = (uint64_t)request_id; + (*env)->GetByteArrayRegion(env, json, 0, len, (jbyte*)(req + 1)); + if ((*env)->ExceptionCheck(env)) { u_free(req); DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: feedback JNI copy failed"); return JNI_FALSE; } + ((char*)(req + 1))[len] = 0; uasync_post(ua, chat_feedback_command_trampoline, req); return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmStart( JNIEnv* env, jobject thiz, jlong nodeId, jstring sourceChId, jstring peerName) { (void)thiz; From 8f8c8c267f372c25799beb2220bea4f55ba6b0ed Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:45:59 +0200 Subject: [PATCH 35/49] Add typed poll and reaction states to Android message models --- .../java/com/utun/chat/data/ChatRepository.kt | 4 +- .../main/java/com/utun/chat/data/Feedback.kt | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 889a5178..0b7e8b87 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -67,7 +67,8 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val imageWidth: Int = 0, val imageHeight: Int = 0, val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0, val downloadState: String = "", /* "", "downloading", "downloaded", "error" */ - val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0) + val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0, + val messageId: String = "", val feedback: FeedbackState? = null) data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false, @@ -382,6 +383,7 @@ class ChatRepository { list.add(Message( id = obj.getLong("id"), + messageId = obj.optString("messageId"), author = obj.getString("author"), authorNodeId = obj.optLong("authorNodeId"), text = displayText, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt new file mode 100644 index 00000000..c903917d --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt @@ -0,0 +1,43 @@ +package com.utun.chat.data + +import org.json.JSONArray +import org.json.JSONObject + +data class ReactionState(val id: Int, val emoji: String, val count: Int = 0, val mine: Boolean = false) +data class PollOption(val id: Int, val text: String, val count: Int, val percent: Double) +data class PollResponse(val nodeId: Long, val choices: List, val custom: String) +data class PollState(val question: String, val multiple: Boolean, val allowCustom: Boolean, + val options: List, val voters: Int, val ver: Long, + val myChoices: List, val myCustom: String, val answers: List) +data class FeedbackState(val messageId: String, val pending: Boolean, val reactions: List, val poll: PollState?) + +private fun JSONArray?.choices(): List = if (this == null) emptyList() else (0 until length()).map { getInt(it) } + +/** Формат и правила задаёт ядро; интерфейс разбирает готовый снимок. */ +fun parseFeedback(json: JSONObject): FeedbackState { + val id = json.getString("message_id") + require(id.length == 64) { "Invalid message ID" } + val reactions = json.getJSONArray("reactions").let { values -> + (0 until values.length()).map { i -> values.getJSONObject(i).let { + ReactionState(it.getInt("id"), it.getString("emoji"), it.getInt("count"), it.getBoolean("mine")) + } } + } + val poll = json.optJSONObject("poll")?.let { value -> + val options = value.getJSONArray("options").let { values -> + (0 until values.length()).map { i -> values.getJSONObject(i).let { + PollOption(it.getInt("id"), it.getString("text"), it.getInt("count"), it.getDouble("percent")) + } } + } + val mine = value.getJSONObject("mine") + val answers = value.getJSONArray("answers").let { values -> + (0 until values.length()).map { i -> values.getJSONObject(i).let { row -> + val answer = row.getJSONObject("answer") + PollResponse(row.getString("node_id").removePrefix("0x").toLong(16), + answer.optJSONArray("choices").choices(), answer.optString("custom")) + } } + } + PollState(value.getString("question"), value.getString("mode") == "multiple", value.getBoolean("allow_custom_answer"), + options, value.getInt("voters"), value.getLong("ver"), mine.optJSONArray("choices").choices(), mine.optString("custom"), answers) + } + return FeedbackState(id, json.optBoolean("pending"), reactions, poll) +} From 0a615913ebfd572930dd99c8ac4ecfc7d5b057f1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:45:59 +0200 Subject: [PATCH 36/49] Drive Android feedback commands and live updates through the core thread --- .../com/utun/chat/viewmodel/ChatViewModel.kt | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 77f2f5db..6a597ead 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import org.json.JSONObject +import org.json.JSONArray private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") @@ -111,6 +113,12 @@ class ChatViewModel : ViewModel() { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages + private val feedbackCache = mutableMapOf() + private val _reactionCatalog = MutableStateFlow>(emptyList()) + val reactionCatalog: StateFlow> = _reactionCatalog + private val _interactionFailed = MutableStateFlow(false) + val interactionFailed: StateFlow = _interactionFailed + private var nextInteractionId = 0L private val _currentChannel = MutableStateFlow(null) val currentChannel: StateFlow = _currentChannel @@ -284,6 +292,35 @@ class ChatViewModel : ViewModel() { _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { + 53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */ + if (data == null || data.size < 34) return + val size = data[0].toInt() and 255 + if (data.size != size + 34) { LogManager.addLog("ERROR", "POLL", "invalid change event"); return } + val channel = String(data, 1, size, Charsets.UTF_8) + if (_currentChannel.value?.id != channel) return + val id = data.copyOfRange(size + 1, size + 33).joinToString("") { "%02x".format(it.toInt() and 255) } + sendInteraction(JSONObject().put("cmd", "feedback").put("ch", channel).put("message_id", id), false) + } + 54 -> { /* FEEDBACK_RESULT: request_id:8LE, success, UTF-8 JSON. */ + if (data == null || data.size < 9) return + try { + val id = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + val result = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8)) + if (data[8].toInt() == 0) { + LogManager.addLog("ERROR", "POLL", "command failed: ${result.optString("error")}") + if (id != 0L) _interactionFailed.value = true + } else if (result.has("catalog")) { + val values = result.getJSONArray("catalog") + _reactionCatalog.value = (0 until values.length()).map { index -> values.getJSONObject(index).let { + 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 } } + } + } catch (e: Exception) { LogManager.addLog("ERROR", "POLL", "invalid result: ${e.message}"); _interactionFailed.value = true } + } 9 -> { /* DB_READY */ if (dbReady) return repo = ChatRepository() @@ -937,13 +974,45 @@ class ChatViewModel : ViewModel() { fun refreshMessages(chId: String) { val r = repo ?: return viewModelScope.launch { - _messages.value = withContext(Dispatchers.IO) { r.getMessages(chId) } + val rows = withContext(Dispatchers.IO) { r.getMessages(chId) } + if (_currentChannel.value?.id != chId) return@launch + _messages.value = rows.map { it.copy(feedback = feedbackCache[it.messageId]) } + if (_reactionCatalog.value.isEmpty()) sendInteraction(JSONObject().put("cmd", "reaction_catalog"), false) + for (row in rows) if (row.messageId.length == 64) + sendInteraction(JSONObject().put("cmd", "feedback").put("ch", chId).put("message_id", row.messageId), false) } } + private fun sendInteraction(command: JSONObject, userAction: Boolean = true) { + if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return) + if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) { + LogManager.addLog("ERROR", "POLL", "cannot queue command ${command.optString("cmd")}") + if (userAction) _interactionFailed.value = true + } + } + + fun createPoll(question: String, options: List, multiple: Boolean, custom: Boolean) { + sendInteraction(JSONObject().put("cmd", "poll_create").put("question", question) + .put("options", JSONArray(options)).put("mode", if (multiple) "multiple" else "single").put("allow_custom_answer", custom)) + } + + fun vote(message: Message, choices: List, custom: String) { + sendInteraction(JSONObject().put("cmd", "vote").put("message_id", message.messageId) + .put("choices", JSONArray(choices)).put("custom", custom)) + } + + fun react(message: Message, emoji: Int?) { + val command = JSONObject().put("cmd", "react").put("message_id", message.messageId) + if (emoji == null) command.put("clear", true) else command.put("emoji", emoji) + sendInteraction(command) + } + + fun dismissInteractionError() { _interactionFailed.value = false } + fun selectChannel(channel: Channel) { val r = repo ?: return if (_currentChannel.value?.id != channel.id) { + feedbackCache.clear() memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null } _currentChannel.value = channel From 44e0b7849e94b14c8879c45dd663bdceac70e16c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:14:29 +0200 Subject: [PATCH 37/49] Add Android poll voting, results and message reaction components --- .../ui/components/ConversationMessages.kt | 14 +- .../com/utun/chat/ui/components/PollUi.kt | 141 ++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index 415e3c71..1ed6ad74 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -37,7 +37,10 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit, onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit, onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}, - onInviteLink: (String) -> Unit = {}) { + onInviteLink: (String) -> Unit = {}, reactionCatalog: List = emptyList(), + onVote: (Message, List, String) -> Unit = { _, _, _ -> }, + onReact: (Message, Int?) -> Unit = { _, _ -> }, + authorName: (Long) -> String = { "0x%016x".format(it) }) { val context = LocalContext.current var openFailed by remember { mutableStateOf(false) } val onLink: (String) -> Unit = { link -> @@ -77,13 +80,18 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st } LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) { item { Spacer(Modifier.height(8.dp)) } - items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message -> + items(messages.reversed(), key = { "${target.nativeId}:${it.messageId.ifEmpty { "${it.isOutgoing}:${it.id}" }}" }) { message -> val group = (target as? ConversationTarget.Channel)?.id Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp) Box(Modifier.weight(1f)) { - MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink) + Column { + if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName) + else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink) + if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) + MessageReactions(message, reactionCatalog, onReact) + } } if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author, modifier = Modifier.padding(end = 8.dp, bottom = 4.dp), size = 32.dp) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt new file mode 100644 index 00000000..25a69fca --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt @@ -0,0 +1,141 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.R +import com.utun.chat.data.* + +@Composable +fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List, Boolean, Boolean) -> Unit) { + var question by rememberSaveable { mutableStateOf("") } + var variants by rememberSaveable { mutableStateOf("") } + var multiple by rememberSaveable { mutableStateOf(false) } + var custom by rememberSaveable { mutableStateOf(false) } + val options = variants.lines().map { it.trim() }.filter { it.isNotEmpty() } + val bytes = 6 + question.trim().toByteArray().size + options.sumOf { 2 + it.toByteArray().size } + val valid = question.isNotBlank() && question.trim().toByteArray().size <= 512 && options.size in 2..32 && + options.distinct().size == options.size && options.all { it.toByteArray().size <= 256 } && bytes <= 2048 + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.poll_create)) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(question, { question = it }, label = { Text(stringResource(R.string.poll_question)) }) + OutlinedTextField(variants, { variants = it }, label = { Text(stringResource(R.string.poll_options_hint)) }, minLines = 3) + Row(Modifier.clickable { multiple = false }) { + RadioButton(!multiple, onClick = { multiple = false }); Text(stringResource(R.string.poll_single)) + } + Row(Modifier.clickable { multiple = true }) { + RadioButton(multiple, onClick = { multiple = true }); Text(stringResource(R.string.poll_multiple)) + } + Row(Modifier.clickable { custom = !custom }) { + Checkbox(custom, { custom = it }); Text(stringResource(R.string.poll_allow_custom)) + } + if (!valid) Text(stringResource(R.string.poll_invalid_input), style = MaterialTheme.typography.bodySmall) + } }, + confirmButton = { TextButton(enabled = valid, onClick = { onCreate(question.trim(), options, multiple, custom); onDismiss() }) { + Text(stringResource(R.string.poll_create)) + } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }) +} + +@Composable +fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> Unit, authorName: (Long) -> String) { + var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) } + val poll = message.feedback?.poll + Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium) + if (poll == null) Text(stringResource(R.string.poll_pending)) + else { + Text(poll.question, style = MaterialTheme.typography.titleMedium) + Text(stringResource(if (poll.multiple) R.string.poll_multiple else R.string.poll_single), + style = MaterialTheme.typography.labelSmall) + for (option in poll.options) { + Text(option.text) + LinearProgressIndicator(progress = { (option.percent / 100).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth()) + Text(stringResource(R.string.poll_option_result, option.count, option.percent), + style = MaterialTheme.typography.labelSmall) + } + Text(stringResource(R.string.poll_voters, poll.voters)) + TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) } + } + } + } + if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName) +} + +@Composable +private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> Unit, + onVote: (Message, List, String) -> Unit, authorName: (Long) -> String) { + var selected by rememberSaveable(message.messageId) { mutableStateOf(ArrayList(poll.myChoices)) } + var custom by rememberSaveable(message.messageId) { mutableStateOf(poll.myCustom) } + var customSelected by rememberSaveable(message.messageId) { mutableStateOf(!poll.multiple && custom.isNotEmpty()) } + val valid = custom.toByteArray().size <= 1024 && (!customSelected || custom.isNotBlank()) + AlertDialog(onDismissRequest = onDismiss, title = { Text(poll.question) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (option in poll.options) { + val choose = { + customSelected = false + selected = if (!poll.multiple) arrayListOf(option.id) + else ArrayList(if (option.id in selected) selected.filter { it != option.id } else selected + option.id) + } + Row(Modifier.fillMaxWidth().clickable(onClick = choose)) { + if (poll.multiple) Checkbox(option.id in selected, onCheckedChange = { choose() }) + else RadioButton(!customSelected && option.id in selected, onClick = choose) + Text(option.text) + } + } + if (poll.allowCustom) { + if (!poll.multiple) Row(Modifier.clickable { customSelected = true; selected = arrayListOf() }) { + RadioButton(customSelected, onClick = { customSelected = true; selected = arrayListOf() }) + Text(stringResource(R.string.poll_own_answer)) + } + OutlinedTextField(custom, { custom = it }, enabled = poll.multiple || customSelected, isError = !valid, + label = { Text(stringResource(R.string.poll_own_answer)) }) + if (!valid) Text(stringResource(R.string.poll_answer_error), color = MaterialTheme.colorScheme.error) + } + Text(stringResource(R.string.poll_voters, poll.voters)) + for (answer in poll.answers) { + val parts = answer.choices.mapNotNull { id -> poll.options.find { it.id == id }?.text } + + listOfNotNull(answer.custom.takeIf { it.isNotEmpty() }) + Text("${authorName(answer.nodeId)}: ${parts.joinToString(", ")}", style = MaterialTheme.typography.bodySmall) + } + } }, + confirmButton = { TextButton(enabled = valid, onClick = { + onVote(message, selected, if (poll.allowCustom && (poll.multiple || customSelected)) custom.trim() else ""); onDismiss() + }) { Text(stringResource(R.string.poll_save)) } }, + dismissButton = { Row { + TextButton(onClick = { onVote(message, emptyList(), ""); onDismiss() }) { Text(stringResource(R.string.poll_withdraw)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } + } }) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MessageReactions(message: Message, catalog: List, onReact: (Message, Int?) -> Unit) { + var picker by rememberSaveable(message.messageId) { mutableStateOf(false) } + FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (reaction in message.feedback?.reactions.orEmpty()) { + FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) }, + label = { Text("${reaction.emoji} ${reaction.count}") }) + } + TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) } + } + if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) }, + text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (reaction in catalog) TextButton(onClick = { onReact(message, reaction.id); picker = false }) { Text(reaction.emoji) } + } }, + confirmButton = { TextButton(onClick = { onReact(message, null); picker = false }) { + Text(stringResource(R.string.poll_clear_reactions)) + } }, + dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } }) +} From 1d05ff34c6a52b22392afb6b52325ab5f23a9eeb Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:15:04 +0200 Subject: [PATCH 38/49] Connect Android poll screens and localize interaction controls --- .../com/utun/chat/ui/screens/ChatScreen.kt | 13 ++++++++++++- .../app/src/main/res/values-ru/strings.xml | 18 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 5fdd693a..75d67a0a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.drawscope.Stroke @@ -51,6 +52,7 @@ import com.utun.chat.data.Message import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.RadioOverlayService import com.utun.chat.ui.components.ConversationMessages +import com.utun.chat.ui.components.CreatePollDialog import com.utun.chat.ui.components.ConversationComposer import com.utun.chat.ui.components.PortableRadioIcon import com.utun.chat.ui.components.GroupAvatar @@ -92,6 +94,13 @@ fun ChatScreen( /* звук сообщений канала */ val soundOn by viewModel.soundOn.collectAsState() + val reactionCatalog by viewModel.reactionCatalog.collectAsState() + val interactionFailed by viewModel.interactionFailed.collectAsState() + var createPoll by rememberSaveable { mutableStateOf(false) } + if (createPoll) CreatePollDialog({ createPoll = false }, viewModel::createPoll) + if (interactionFailed) AlertDialog(onDismissRequest = viewModel::dismissInteractionError, + text = { Text(stringResource(R.string.poll_interaction_failed)) }, + confirmButton = { TextButton(onClick = viewModel::dismissInteractionError) { Text(stringResource(R.string.close)) } }) /* save dialog for downloaded files */ var pendingSaveFilePath by remember { mutableStateOf(null) } @@ -143,6 +152,7 @@ fun ChatScreen( Box { IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.options)) } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.poll_create)) }, onClick = { menu = false; createPoll = true }) DropdownMenuItem(text = { Text(stringResource(R.string.invite)) }, leadingIcon = { Icon(Icons.Filled.Link, null) }, onClick = { menu = false; onInvite() }) DropdownMenuItem(text = { Text(stringResource(R.string.sound)) }, @@ -185,7 +195,8 @@ fun ChatScreen( onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) }, onDownloadFile = { viewModel.downloadAttachment(it) }, onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } }, - onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink + onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink, + reactionCatalog = reactionCatalog, onVote = viewModel::vote, onReact = viewModel::react, authorName = viewModel::radioName ) if (radioActive && !overlayShown) { RadioPttButton( diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 34648990..a77fb13b 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -363,4 +363,22 @@ Выбор аватарки Перемещайте и масштабируйте фото, чтобы выбрать квадрат. Нет аватарки + Создать опрос + Вопрос + Каждый вариант на отдельной строке + Один вариант + Несколько вариантов + Разрешить свой ответ + Свой ответ + Голосование / результаты + Отменить голос + Сохранить ответ + Участников: %1$d + %1$d голосов · %2$.1f%% + Загрузка опроса… + Укажите вопрос и 2–32 разных варианта допустимой длины. + Укажите более короткий непустой ответ. + Реакция + Снять все реакции + Не удалось сохранить опрос, голос или реакцию. Проверьте введённые данные и повторите. diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index bd469ebd..2d722b0f 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -361,4 +361,22 @@ Crop avatar Move and zoom the photo to choose a square. No avatar + Create poll + Question + One option per line + Single choice + Multiple choices + Allow a custom answer + Your answer + Vote / results + Withdraw vote + Save answer + Participants: %1$d + %1$d votes · %2$.1f%% + Loading poll… + Enter a question and 2–32 distinct options within the size limit. + Enter a shorter nonempty answer. + React + Clear reactions + Could not save the poll, vote or reaction. Check your input and try again. From 8dc7595e46e696d344b20a9e5b4e95a7c1fc5729 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:15:44 +0200 Subject: [PATCH 39/49] Preserve Android feedback state and supplementary Unicode characters --- .../com/utun/chat/viewmodel/ChatViewModel.kt | 1 + .../jni_bridge/android_jni_bridge.c | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 6a597ead..d6fefbdb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -1249,6 +1249,7 @@ class ChatViewModel : ViewModel() { r.sendMessage(ch.id, text) viewModelScope.launch { _messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) } + .map { it.copy(feedback = feedbackCache[it.messageId]) } refreshChannels() } } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index d426e342..685ae281 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1923,7 +1923,21 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( (void)thiz; const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); char* json = utun_bridge_get_messages_json(ch, (int)limit); - jstring result = (*env)->NewStringUTF(env, json); + /* String(byte[], UTF-8) корректно восстанавливает четырёхбайтовые emoji. */ + jclass strings = (*env)->FindClass(env, "java/lang/String"); + jmethodID constructor = strings ? (*env)->GetMethodID(env, strings, "", "([BLjava/lang/String;)V") : NULL; + size_t bytes = json ? strlen(json) : 0; + jbyteArray data = (*env)->NewByteArray(env, (jsize)bytes); + jstring charset = (*env)->NewStringUTF(env, "UTF-8"); + jstring result = NULL; + if (constructor && data && charset) { + (*env)->SetByteArrayRegion(env, data, 0, (jsize)bytes, (const jbyte*)json); + if (!(*env)->ExceptionCheck(env)) result = (jstring)(*env)->NewObject(env, strings, constructor, data, charset); + } + if (!result) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "bridge: UTF-8 message result allocation failed"); + if (data) (*env)->DeleteLocalRef(env, data); + if (charset) (*env)->DeleteLocalRef(env, charset); + if (strings) (*env)->DeleteLocalRef(env, strings); u_free(json); (*env)->ReleaseStringUTFChars(env, channelId, ch); return result; From 6686dd1815d07bcaebf2e0ed78a07a3d33e0d75c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:21:13 +0200 Subject: [PATCH 40/49] Translate desktop poll and reaction controls --- tools/chatgui/translations/chatgui_en.ts | 21 +++++++++++++++++++++ tools/chatgui/translations/chatgui_ru.ts | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 0431c402..bcb617ce 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2122,5 +2122,26 @@ Directories: %5 Select an existing, readable file.Select an existing, readable file. Cannot attach an empty file.Cannot attach an empty file. Cannot create the attachment directory.Cannot create the attachment directory. + Create pollCreate poll + QuestionQuestion + One option per lineOne option per line + Single choiceSingle choice + Multiple choicesMultiple choices + Allow a custom answerAllow a custom answer + Your answerYour answer + Vote / resultsVote / results + Withdraw voteWithdraw vote + ParticipantsParticipants + ReactReact + Clear reactionsClear reactions + Invalid pollInvalid poll + Enter a question and distinct options within the size limit.Enter a question and distinct options within the size limit. + Enter a shorter nonempty answer.Enter a shorter nonempty answer. + Polls and reactionsPolls and reactions + Answer violates poll rulesAnswer violates poll rules + Too many reactionsToo many reactions + Message is not availableMessage is not available + Invalid poll or publication failedInvalid poll or publication failed + Cannot save stateCannot save state diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index f7fcfdb6..2211bea1 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2122,5 +2122,26 @@ Directories: %5 Select an existing, readable file.Выберите существующий файл, доступный для чтения. Cannot attach an empty file.Нельзя прикрепить пустой файл. Cannot create the attachment directory.Не удалось создать каталог для вложения. + Create pollСоздать опрос + QuestionВопрос + One option per lineКаждый вариант на отдельной строке + Single choiceОдин вариант + Multiple choicesНесколько вариантов + Allow a custom answerРазрешить свой ответ + Your answerСвой ответ + Vote / resultsГолосование / результаты + Withdraw voteОтменить голос + ParticipantsУчастников + ReactРеакция + Clear reactionsСнять все реакции + Invalid pollНекорректный опрос + Enter a question and distinct options within the size limit.Укажите вопрос и разные варианты допустимой длины. + Enter a shorter nonempty answer.Укажите более короткий непустой ответ. + Polls and reactionsОпросы и реакции + Answer violates poll rulesОтвет не соответствует правилам опроса + Too many reactionsВыбрано слишком много реакций + Message is not availableСообщение ещё не получено + Invalid poll or publication failedНекорректный опрос или ошибка публикации + Cannot save stateНе удалось сохранить ответ или реакцию From 4966600d528ed9b1b69a12c8b0fb892b9eab7b99 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:21:13 +0200 Subject: [PATCH 41/49] Localize desktop interaction errors --- tools/chatgui/src/messagefeedback.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/chatgui/src/messagefeedback.cpp b/tools/chatgui/src/messagefeedback.cpp index dce87940..a0f64d69 100644 --- a/tools/chatgui/src/messagefeedback.cpp +++ b/tools/chatgui/src/messagefeedback.cpp @@ -78,7 +78,10 @@ void MessageList::onFeedbackResult(const uint8_t* data, int len) { QJsonObject state = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data + 9), len - 9)).object(); if (!data[8]) { GUI_ERROR("feedback: command failed request=%llu reason=%s", (unsigned long long)request, qPrintable(state.value("error").toString())); - if (request) { m_pendingPoll.clear(); QMessageBox::warning(this, feedbackText("Polls and reactions"), state.value("error").toString()); } + if (request) { + m_pendingPoll.clear(); QByteArray reason = state.value("error").toString().toUtf8(); + QMessageBox::warning(this, feedbackText("Polls and reactions"), feedbackText(reason.constData())); + } return; } if (state.value("ch").toString() != m_currentChannelId) return; From e86de44259f236f8f412663a76bac504355014c2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:30:52 +0200 Subject: [PATCH 42/49] Test poll synchronization through three nodes and chat restart --- tests/Makefile.am | 5 + tests/test_chat_poll_network.c | 164 +++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/test_chat_poll_network.c diff --git a/tests/Makefile.am b/tests/Makefile.am index dda3fcb9..65c524f1 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -762,3 +762,8 @@ check_PROGRAMS += test_chat_poll 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) + +check_PROGRAMS += test_chat_poll_network +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 +test_chat_poll_network_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) diff --git a/tests/test_chat_poll_network.c b/tests/test_chat_poll_network.c new file mode 100644 index 00000000..79565545 --- /dev/null +++ b/tests/test_chat_poll_network.c @@ -0,0 +1,164 @@ +/* Три ядра и UDP: A ↔ B ↔ C, голосование/реакции и возврат C после остановки чата. */ +#include "utun_instance.h" +#include "chat/chat_core.h" +#include "chat/chat_poll.h" +#include "chat/chat_feedback.h" +#include "chat/member_sync.h" +#include "chat/merkle_sync.h" +#include "chat/db_sync.h" +#include "routing_layer/topo_group.h" +#include "routing_layer/topo_node_sqlite.h" +#include "transport_layer/node_conn_direct.h" +#include "transport_layer/etcp_connections.h" +#include "../lib/platform_compat.h" +#include "../lib/socket_compat.h" +#include "../lib/debug_config.h" +#include +#include +#include +#include + +static struct UASYNC* ua; +static struct UTUN_INSTANCE* nodes[3]; +static char channel[21], message_hex[65]; +static uint8_t message_id[32]; + +/* Порт выбирается ОС, затем сразу используется новым ядром. */ +static unsigned free_port(void) { + socket_t fd = socket(AF_INET, SOCK_DGRAM, 0); assert(fd != SOCKET_INVALID); + struct sockaddr_in address = {0}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + assert(bind(fd, (struct sockaddr*)&address, sizeof(address)) == 0); socklen_t size = sizeof(address); + assert(getsockname(fd, (struct sockaddr*)&address, &size) == 0); unsigned port = ntohs(address.sin_port); + socket_close_wrapper(fd); return port; +} + +static void hex(const uint8_t* data, size_t len, char* out) { + for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", data[i]); +} + +static struct UTUN_INSTANCE* create_node(unsigned port) { + struct SC_MYKEYS keys; assert(sc_generate_keypair(&keys) == SC_OK); + char private_key[65], public_key[65], config[1024]; hex(keys.private_key, 32, private_key); hex(keys.public_key, 32, public_key); + snprintf(config, sizeof(config), + "[global]\nmy_private_key=%s\nmy_public_key=%s\nkeepalive_adaptive=0\n" + "[server: udp]\naddr=127.0.0.1:%u\ntype=public\n[allowed_keys]\nallow_all=1\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", private_key, public_key, port); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); + assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); return inst; +} + +/* Все три заранее принятых участника имеют настоящие подписи root/join. */ +static void seed_members(uint8_t channel_x[32], uint8_t channel_ed[32], uint8_t owner_private[32]) { + for (int dst = 0; dst < 3; dst++) for (int author = 0; author < 3; author++) { + uint8_t body[112], join[64], tree[64]; + assert(member_sync_build_join_msg(channel_x, channel_ed, nodes[author]->node_id, + nodes[author]->my_keys.public_key, 1, body, sizeof(body)) == 112); + assert(sc_ed25519_sign(nodes[author]->my_ed25519_privkey, body, sizeof(body), join) == SC_OK); + assert(member_sync_sign_pubkey(owner_private, nodes[author]->my_keys.public_key, tree) == 0); + assert(member_sync_put(nodes[dst], channel, nodes[author]->node_id, nodes[author]->my_keys.public_key, + nodes[author]->my_ed25519_pubkey, join, 1, NULL, 0, "{}", NULL, NULL, 0, 0, tree) >= 0); + } +} + +/* Поднимаем транспорт только соседних пар; C получает данные A через B. */ +static struct NODE_CONN_DIRECT* connect_node(int from, int to, unsigned port) { + struct TOPO_ADDR4 address = { .addr = {127, 0, 0, 1}, .port = port, .protocol = TOPO_PROTO_UDP }; + struct TOPO_NODE node = { .node_id = nodes[to]->node_id, .v4_addrs = &address }; + memcpy(node.public_key, nodes[to]->my_keys.public_key, 32); struct NODE_CONN_DIRECT* handle = NULL; + assert(node_conn_direct_open_node(nodes[from], node.node_id, NULL, NULL, &handle, &node, NULL) != NCD_ERR); return handle; +} + +static int wait_ready(struct TOPO_PEER_REQUEST* handle) { + for (int i = 0; i < 3000; i++) { + if (topo_group_peer_phase(handle) == TOPO_PEER_READY) return 1; + uasync_poll(ua, 5); + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll network: group readiness timeout phase=%d", topo_group_peer_phase(handle)); return 0; +} + +static sqlite3_int64 field(struct UTUN_INSTANCE* inst, const char* json, const char* path) { + sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + assert(sqlite3_step(st) == SQLITE_ROW); sqlite3_int64 n = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return n; +} + +static int wait_result(int index, int voters, int option_one, int reactions) { + for (int i = 0; i < 3000; i++) { + char* result = NULL; + if (chat_feedback_snapshot(nodes[index], channel, message_id, &result) == 0) { + int ok = !field(nodes[index], result, "$.pending") && field(nodes[index], result, "$.poll.voters") == voters + && field(nodes[index], result, "$.poll.options[1].count") == option_one + && field(nodes[index], result, "$.reactions[0].count") == reactions; + u_free(result); if (ok) return 1; + } + uasync_poll(ua, 5); + } + char* result = NULL; chat_feedback_snapshot(nodes[index], channel, message_id, &result); + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll network: result timeout node=%d expected voters=%d option1=%d reactions=%d actual=%s", + index, voters, option_one, reactions, result ? result : "missing"); u_free(result); return 0; +} + +static void run_command(int index, const char* command, const char* tail) { + char request[1024]; snprintf(request, sizeof(request), "{\"cmd\":\"%s\",\"ch\":\"%s\",\"message_id\":\"%s\"%s}", + command, channel, message_hex, tail); + char* result = NULL; assert(chat_feedback_command(nodes[index], request, &result) == 0); u_free(result); +} + +static void subscribe(int a, int b) { + assert(member_sync_start(nodes[a], nodes[b]->node_id, channel, NULL, NULL) == 0); + assert(merkle_sync_start(nodes[a], MT_DATA_FEEDBACK, nodes[b]->node_id, channel, NULL, NULL) == 0); + db_sync_instance_set_gated(db_sync_instance_find(nodes[a], strtoull(channel, NULL, 10)), 0); + db_sync_instance_set_gated(db_sync_instance_find(nodes[b], strtoull(channel, NULL, 10)), 0); + db_sync_reinitiate(db_sync_instance_find(nodes[a], strtoull(channel, NULL, 10)), nodes[b]->node_id); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_MEMBER_SYNC, DEBUG_LEVEL_INFO); + ua = uasync_create(); assert(ua); unsigned ports[3]; + for (int i = 0; i < 3; i++) { ports[i] = free_port(); nodes[i] = create_node(ports[i]); } + chat_core_create_channel_auto(nodes[0], "Poll network"); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(nodes[0]->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW); snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st); + uint8_t x[32], ed[32], signature[64], owner_private[32]; + assert(topo_node_sqlite_channel_get(nodes[0]->topo_sqlite_db, channel, NULL, 0, NULL, x, ed, signature) == 0); + assert(topo_node_sqlite_channel_get_priv(nodes[0]->topo_sqlite_db, channel, owner_private) == 0); + for (int i = 1; i < 3; i++) { + assert(topo_node_sqlite_channel_put(nodes[i]->topo_sqlite_db, channel, "Poll network", nodes[0]->node_id, + x, NULL, ed, NULL, signature) == 0); chat_core_ensure_channel_ready(nodes[i], channel); + } + seed_members(x, ed, owner_private); + struct NODE_CONN_DIRECT* ab = connect_node(0, 1, ports[1]), *bc = connect_node(1, 2, ports[2]); + struct TOPO_PEER_REQUEST *groups[3] = {0}; uint64_t gid = strtoull(channel, NULL, 10); + assert(topo_group_peer_open(topo_groups_find(nodes[0]->topo_groups, gid), nodes[1]->node_id, &groups[0]) == 0); + assert(topo_group_peer_open(topo_groups_find(nodes[1]->topo_groups, gid), nodes[2]->node_id, &groups[1]) == 0); + assert(topo_group_peer_open(topo_groups_find(nodes[2]->topo_groups, gid), nodes[1]->node_id, &groups[2]) == 0); + for (int i = 0; i < 3; i++) assert(wait_ready(groups[i])); + subscribe(0, 1); subscribe(1, 2); + struct chat_poll poll = { .mode = CHAT_POLL_MULTIPLE, .allow_custom_answer = 1, .option_count = 2 }; + strcpy(poll.question, "Сетевой опрос"); strcpy(poll.options[0], "Первый"); strcpy(poll.options[1], "Второй"); + assert(chat_poll_create(nodes[0], channel, &poll, message_id) == 0); hex(message_id, 32, message_hex); + assert(wait_result(2, 0, 0, 0)); + run_command(0, "vote", ",\"choices\":[0]"); run_command(1, "vote", ",\"choices\":[1]"); + run_command(2, "vote", ",\"custom\":\"Ответ C 👍\""); run_command(0, "react", ",\"emoji\":0,\"on\":true"); + assert(wait_result(0, 3, 1, 1) && wait_result(2, 3, 1, 1)); + puts("PASS: signed poll, votes, custom Unicode answer and reactions converge through the intermediate node"); + topo_group_peer_close(groups[2]); groups[2] = NULL; chat_service_stop(nodes[2]); + run_command(0, "vote", ",\"choices\":[1]"); run_command(1, "vote", ",\"choices\":[]"); + run_command(0, "react", ",\"clear\":true"); assert(wait_result(1, 2, 1, 0)); + assert(chat_service_start(nodes[2]) == 0); + assert(topo_group_peer_open(topo_groups_find(nodes[2]->topo_groups, gid), nodes[1]->node_id, &groups[2]) == 0); + assert(wait_ready(groups[2])); subscribe(1, 2); + assert(wait_result(2, 2, 1, 0) && wait_result(0, 2, 1, 0)); + struct chat_feedback_record old; + assert(chat_feedback_get(nodes[2], channel, message_id, nodes[0]->node_id, CHAT_FEEDBACK_REACTION, &old) == 1); + assert(old.ver == 2 && old.data_len == 0); + puts("PASS: returning peer receives new versions; withdrawn votes and reactions stay withdrawn"); + for (int i = 0; i < 3; i++) topo_group_peer_close(groups[i]); + node_conn_direct_close(ab); node_conn_direct_close(bc); + for (int i = 0; i < 3; i++) utun_instance_destroy(nodes[i]); + uasync_poll(ua, 0); assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); + uasync_destroy(ua, 0); puts("ALL PASS: network polls and offline recovery"); return 0; +} From 67d171845022316f8abcd3c82317799417a2e90d Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:31:53 +0200 Subject: [PATCH 43/49] Measure proxy test durations with a monotonic clock --- tests/tcp_proxy_full/run_test.sh | 23 ++++++++++++++--------- tools/chatgui/src/messagefeedback.cpp | 3 ++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/tcp_proxy_full/run_test.sh b/tests/tcp_proxy_full/run_test.sh index 546b05c8..bf35e194 100755 --- a/tests/tcp_proxy_full/run_test.sh +++ b/tests/tcp_proxy_full/run_test.sh @@ -107,10 +107,15 @@ wait_for_etcp() { # -------- test runners -------- +# Монотонное время не зависит от wall clock и поддержки точности %N в date. +clock_ms() { + python3 -c 'import time; print(time.monotonic_ns() // 1000000)' +} + run_test() { local name=$1; shift local t_start log rc elapsed - t_start=$(date +%s%3N) + t_start=$(clock_ms) echo -n " $name ... " log="$LOG_DIR/tcp_client_${name}.log" if python3 "$CLIENT_PY" "$@" --name "$name" >"$log" 2>&1; then @@ -118,7 +123,7 @@ run_test() { else rc=$? fi - elapsed=$(($(date +%s%3N) - t_start)) + elapsed=$(($(clock_ms) - t_start)) if [ $rc -eq 0 ]; then echo "PASS (${elapsed}ms)" tail -1 "$log" @@ -133,7 +138,7 @@ run_test() { run_test_neg() { local name=$1; shift local t_start log rc elapsed - t_start=$(date +%s%3N) + t_start=$(clock_ms) echo -n " $name ... " log="$LOG_DIR/tcp_client_${name}.log" if ! python3 "$CLIENT_PY" "$@" --name "$name" >"$log" 2>&1; then @@ -141,7 +146,7 @@ run_test_neg() { else rc=1 fi - elapsed=$(($(date +%s%3N) - t_start)) + elapsed=$(($(clock_ms) - t_start)) if [ $rc -eq 0 ]; then echo "PASS (expected error, ${elapsed}ms)"; return 0 else @@ -152,7 +157,7 @@ run_test_neg() { run_concurrent() { local name=$1 count=$2 size=$3; shift 3 local t_start pids ok elapsed - t_start=$(date +%s%3N) + t_start=$(clock_ms) pids=() for i in $(seq 1 $count); do python3 "$CLIENT_PY" \ @@ -164,7 +169,7 @@ run_concurrent() { done ok=0 for pid in "${pids[@]}"; do wait "$pid"; [ $? -eq 0 ] && ((++ok)); done - elapsed=$(($(date +%s%3N) - t_start)) + elapsed=$(($(clock_ms) - t_start)) echo -n " $name ($count) ... " if [ "$ok" -eq "$count" ]; then echo "PASS ($ok/$count, ${elapsed}ms)"; return 0 @@ -180,18 +185,18 @@ run_concurrent() { run_stress() { local t_start elapsed - t_start=$(date +%s%3N) + t_start=$(clock_ms) echo -n " stress_sessions ... " local log="$LOG_DIR/stress.log" if python3 "$STRESS_PY" \ --host 10.200.100.1 --port-base "$ECHO_PORT" --ports 20 \ --threads 10 --iters 20 --min-size 1024 --max-size 65536 --timeout 60 --verbose \ >"$log" 2>&1; then - elapsed=$(($(date +%s%3N) - t_start)) + elapsed=$(($(clock_ms) - t_start)) echo "PASS (${elapsed}ms)" head -1 "$log"; return 0 else - elapsed=$(($(date +%s%3N) - t_start)) + elapsed=$(($(clock_ms) - t_start)) echo "FAIL (${elapsed}ms)" tail -5 "$log"; return 1 fi diff --git a/tools/chatgui/src/messagefeedback.cpp b/tools/chatgui/src/messagefeedback.cpp index a0f64d69..3eda490a 100644 --- a/tools/chatgui/src/messagefeedback.cpp +++ b/tools/chatgui/src/messagefeedback.cpp @@ -136,7 +136,8 @@ void MessageList::createPoll() { unique.insert(text); values.append(text); bytes += 2 + text.toUtf8().size(); } if (!valid || values.size() < 2 || values.size() > CHAT_POLL_OPTIONS_MAX || bytes > CHAT_POLL_WIRE_MAX) { - QMessageBox::warning(&dialog, feedbackText("Create poll"), feedbackText("Enter a question and distinct options within the size limit.")); return; + QMessageBox::warning(&dialog, feedbackText("Create poll"), + feedbackText("Enter a question and distinct options within the size limit.")); return; } sendFeedback({{"cmd", "poll_create"}, {"question", title}, {"options", values}, {"mode", mode->currentIndex() ? "multiple" : "single"}, {"allow_custom_answer", custom->isChecked()}}); From 0c16c2b09ac5652a022d2b273bd7b958faca727a Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:33:35 +0200 Subject: [PATCH 44/49] Wait for authenticated bootstrap transport before opening group requests --- tests/test_chat_poll_network.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_chat_poll_network.c b/tests/test_chat_poll_network.c index 79565545..586d1880 100644 --- a/tests/test_chat_poll_network.c +++ b/tests/test_chat_poll_network.c @@ -76,6 +76,19 @@ static int wait_ready(struct TOPO_PEER_REQUEST* handle) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll network: group readiness timeout phase=%d", topo_group_peer_phase(handle)); return 0; } +/* C открывает групповой запрос после входящего UP; до него адрес B ещё не получен. */ +static int wait_transport(struct NODE_CONN_DIRECT* ab, struct NODE_CONN_DIRECT* bc) { + struct ETCP_CONN *a = NULL, *b = NULL; + for (int i = 0; i < 3000; i++) { + a = node_conn_direct_get_conn(ab); b = node_conn_direct_get_conn(bc); + struct ETCP_CONN* c = instance_find_conn(nodes[2], nodes[1]->node_id); + if (a && b && c && a->initialized && b->initialized && c->initialized + && a->links_up && b->links_up && c->links_up) return 1; + uasync_poll(ua, 5); + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "poll network: bootstrap timeout ab=%p bc=%p", (void*)a, (void*)b); return 0; +} + static sqlite3_int64 field(struct UTUN_INSTANCE* inst, const char* json, const char* path) { sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK); sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); @@ -131,6 +144,7 @@ int main(void) { } seed_members(x, ed, owner_private); struct NODE_CONN_DIRECT* ab = connect_node(0, 1, ports[1]), *bc = connect_node(1, 2, ports[2]); + assert(wait_transport(ab, bc)); struct TOPO_PEER_REQUEST *groups[3] = {0}; uint64_t gid = strtoull(channel, NULL, 10); assert(topo_group_peer_open(topo_groups_find(nodes[0]->topo_groups, gid), nodes[1]->node_id, &groups[0]) == 0); assert(topo_group_peer_open(topo_groups_find(nodes[1]->topo_groups, gid), nodes[2]->node_id, &groups[1]) == 0); From 7f4a1a66fa9a36e6f01a026add038c3f8884c256 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:33:52 +0200 Subject: [PATCH 45/49] Include transport state definitions in poll network regression --- tests/test_chat_poll_network.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_chat_poll_network.c b/tests/test_chat_poll_network.c index 586d1880..0c9ca367 100644 --- a/tests/test_chat_poll_network.c +++ b/tests/test_chat_poll_network.c @@ -10,6 +10,7 @@ #include "routing_layer/topo_node_sqlite.h" #include "transport_layer/node_conn_direct.h" #include "transport_layer/etcp_connections.h" +#include "transport_layer/etcp.h" #include "../lib/platform_compat.h" #include "../lib/socket_compat.h" #include "../lib/debug_config.h" From bbc8b7693a675e38a0454bb8ec9fe4d67d091503 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:40:47 +0200 Subject: [PATCH 46/49] Restart member and feedback synchronization over existing transports --- src/chat/chat_sync.c | 6 ++++++ tests/test_chat_poll_network.c | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index 03c2ec93..7ab95966 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -786,6 +786,12 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { chat_admin_init(inst); chat_join_init(inst); + /* После restart транспорт может уже быть UP: нового conn_up не будет. */ + if (cs->channel_count) { + cs_schedule_sync(cs); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: initial member sync scheduled channels=%d", CS_ID, cs->channel_count); + } + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: initialized", CS_ID); return 0; } diff --git a/tests/test_chat_poll_network.c b/tests/test_chat_poll_network.c index 0c9ca367..710de69b 100644 --- a/tests/test_chat_poll_network.c +++ b/tests/test_chat_poll_network.c @@ -70,7 +70,8 @@ static struct NODE_CONN_DIRECT* connect_node(int from, int to, unsigned port) { } static int wait_ready(struct TOPO_PEER_REQUEST* handle) { - for (int i = 0; i < 3000; i++) { + uint64_t deadline = get_time_tb() + 15u * 10000u; + while (get_time_tb() < deadline) { if (topo_group_peer_phase(handle) == TOPO_PEER_READY) return 1; uasync_poll(ua, 5); } @@ -80,7 +81,8 @@ static int wait_ready(struct TOPO_PEER_REQUEST* handle) { /* C открывает групповой запрос после входящего UP; до него адрес B ещё не получен. */ static int wait_transport(struct NODE_CONN_DIRECT* ab, struct NODE_CONN_DIRECT* bc) { struct ETCP_CONN *a = NULL, *b = NULL; - for (int i = 0; i < 3000; i++) { + uint64_t deadline = get_time_tb() + 15u * 10000u; + while (get_time_tb() < deadline) { a = node_conn_direct_get_conn(ab); b = node_conn_direct_get_conn(bc); struct ETCP_CONN* c = instance_find_conn(nodes[2], nodes[1]->node_id); if (a && b && c && a->initialized && b->initialized && c->initialized @@ -97,7 +99,8 @@ static sqlite3_int64 field(struct UTUN_INSTANCE* inst, const char* json, const c } static int wait_result(int index, int voters, int option_one, int reactions) { - for (int i = 0; i < 3000; i++) { + uint64_t deadline = get_time_tb() + 15u * 10000u; + while (get_time_tb() < deadline) { char* result = NULL; if (chat_feedback_snapshot(nodes[index], channel, message_id, &result) == 0) { int ok = !field(nodes[index], result, "$.pending") && field(nodes[index], result, "$.poll.voters") == voters From 587a9800810c18ba7141c6d729b8aaf34d0e78bc Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:43:13 +0200 Subject: [PATCH 47/49] Document poll creation, voting and reaction commands --- tools/chatcli | 5 +++++ tools/chatcli_commands.txt | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tools/chatcli b/tools/chatcli index 4511110e..3d5a9ecf 100755 --- a/tools/chatcli +++ b/tools/chatcli @@ -14,6 +14,11 @@ # members list members with full state # messages [count] read last messages # send send text message +# poll ... [--multiple] [--custom] +# vote [option_id ...] [--custom ] +# react [emoji_or_id] [--off] [--clear] +# feedback poll results and reactions +# reactions available emoji IDs # attach [--video] send a file (or video) as attachment # invite [node_id] create invite link (auto best node by default) # invite_nodes list reachable nodes for invite diff --git a/tools/chatcli_commands.txt b/tools/chatcli_commands.txt index 1f9f80e1..305996c4 100644 --- a/tools/chatcli_commands.txt +++ b/tools/chatcli_commands.txt @@ -57,6 +57,33 @@ attach [--video] Пример: chatcli attach 12345 /tmp/photo.jpg chatcli attach 12345 /tmp/clip.mov --video +poll ... [--multiple] [--custom] + Создать неизменяемый опрос. По умолчанию разрешён один пункт. + --multiple разрешает несколько пунктов; --custom разрешает собственный текст. + Пример: chatcli poll 12345 "Куда пойдём?" "В парк" "В кафе" --multiple --custom + Ответ содержит message_id; он также виден в команде messages. + +vote [option_id ...] [--custom ] + Сохранить полный новый ответ. Номера пунктов начинаются с 0, как id в feedback. + Пустой выбор без собственного текста отменяет голос, сохраняя новый ver. + При выборе одного пункта свой ответ заменяет готовый пункт; при нескольких дополняет выбор. + Пример: chatcli vote 12345 0 1 --custom "Ещё один вариант" + chatcli vote 12345 + +react [emoji_or_id] [--off] [--clear] + Установить реакцию; --off снимает выбранную, --clear снимает все свои реакции. + Пример: chatcli react 12345 "👍" + chatcli react 12345 0 --off + chatcli react 12345 --clear + +reactions + Общий каталог постоянных номеров и эмодзи. + +feedback + Текущие реакции, свои состояния, результаты и подписанные ответы участников. + Для нескольких пунктов проценты считаются от числа ответивших и могут суммарно превышать 100%. + Отсутствующее исходное сообщение обозначается pending; ответы сохраняются до его получения. + invite [node_id] Создать invite-ссылку для канала (формат utun://...). Без node_id — автоматический выбор лучшего достижимого узла. From 255853dd763bddbb9ba2b48559817b5f5be495f6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:47:06 +0200 Subject: [PATCH 48/49] Document complete poll protocol and verified recovery behavior --- doc/tasks.md | 14 +++++++++----- src/chat/merkle_sync_doc.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index ef273047..18101458 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -9,10 +9,12 @@ детерминированный конфликт ver, атомарное применение страниц и восстановление индекса. Merkle v3 разделяет MEMBERS/FEEDBACK, курсор записи — 32 байта. `./check.sh`: 130 passed, 0 failed, 1 skipped; proxy/full, burst и load также PASS. - Осталось: включить производный message_id в чтение/API сообщений, определить - канонические данные набора эмодзи и ответа, формат неизменяемого poll-сообщения - (single/multiple, allow_custom_answer), проверку и подсчёт результатов при получении - исходного сообщения; подключить headless, desktop и Android интерфейсы. + Подключены message_id к БД/API, общий каталог 16 эмодзи, канонические форматы + poll/answer/reactions, single/multiple, allow_custom_answer, подсчёт результатов, + отложенное получение опроса, CLI, desktop и Android. Собраны desktop и Android APK. + Сетевой тест A ↔ B ↔ C прошёл, включая restart C и сохранение отмены. + Исправлен запуск начального member_sync после restart чата поверх уже UP-транспорта. + Осталось: финальная чистая сборка актуальных приложений и общий check.sh. План продолжения: 1. Сохранить индексируемый message_id в msg_, вернуть его через API сообщений. 2. Добавить общий каталог эмодзи и канонические кодеки poll/answer/reactions; @@ -27,7 +29,9 @@ `basic_1mb: 161949390ms` и `concurrent_20: -1612179507375916304ms`. В `tests/tcp_proxy_full/run_test.sh` длительность считается через `date +%s%3N`; проверить фактический формат времени и арифметику в окружении запуска. - Причина пока не установлена; результаты передачи и проверки байтов — PASS. + Причина установлена: date +%s%3N в окружении отдаёт 19 цифр наносекунд вместо миллисекунд. + run_test.sh переведён на Python time.monotonic_ns() // 1000000. + Осталось подтвердить длительности финальным интеграционным прогоном. [ ] **rlottie: разбор предупреждений GCC 15 в импортированных исходниках** — 05.10.2026: при сборке `tools/chatgui/rlottie_src` GCC сообщает `-Wuninitialized` в diff --git a/src/chat/merkle_sync_doc.md b/src/chat/merkle_sync_doc.md index bd287855..7408293d 100644 --- a/src/chat/merkle_sync_doc.md +++ b/src/chat/merkle_sync_doc.md @@ -126,3 +126,37 @@ Wire-запись: message_id:32, node_id:8, kind:1, ver:8, data_len:2, data, si Членство проверяется через подписи дерева приглашений и join, привязывающий Ed25519 к узлу. Слой feedback хранит подписанные байты независимо от наличия исходного сообщения; семантику выбранных пунктов и эмодзи проверяет вызывающий модуль до учёта результата. + +## Опросы и реакции + +`chat_poll_codec` задаёт общий каталог 16 эмодзи и канонические кодеки. +Реакции: count:1 и отсортированные уникальные номера; максимум 8 реакций автора. +Ответ: count:1, отсортированные уникальные номера пунктов, custom_len:2, UTF-8 текст. +Пустой набор/ответ кодируется нулевой длиной. Собственный текст — максимум 1024 байта. +Структурно неверные состояния не применяются; семантически неверные подписанные ответы +сохраняются, но исключаются из подсчёта, чтобы порядок получения опроса не менял Merkle-корень. + +Опрос — подписанное сообщение `content_type=poll`, data=`b64:` + URL-safe base64. +Wire v1: version:1=1, mode:1 (single=1/multiple=2), allow_custom_answer:1, option_count:1, +затем вопрос и варианты как length:2 + UTF-8. Числа big-endian, весь poll максимум 2048 байт. +Вопрос — максимум 512 байт, 2–32 разных непустых варианта по 256 байт. +Порядок вариантов неизменяем; номер варианта — индекс с нуля. +single разрешает один вариант либо собственный текст, multiple — варианты и необязательный текст. +Изменение ответа заменяет его целиком с ver+1; нулевой ответ отменяет голос. + +message_id сохраняется в msg_ с UNIQUE-индексом: +SHA256(`utun-chat-message-v1` || BE64(channel_id) || BE64(timestamp) || author_signature). +Этот ID одинаков на всех узлах и возвращается API сообщений и результатом публикации опроса. +Получение исходного сообщения повторно проверяет отложенные ответы. Пользовательские результаты +вычисляются по последним допустимым непустым состояниям; проценты — от числа ответивших. +При нескольких вариантах сумма процентов может превышать 100%. + +Команды `poll_create`, `vote`, `react`, `feedback`, `reaction_catalog` разбираются единым +chat_feedback_command в потоке ядра. Desktop и Android получают адресные события FEEDBACK_CHANGED +и коррелированные результаты FEEDBACK_RESULT; GUI-поток не пишет подписанные данные. +При старте чата запускается member_sync по известным онлайн-пирам, затем feedback sync: +это нужно и при restart поверх сохранившегося UP-транспорта, когда нового conn_up не будет. + +test_chat_poll проверяет пользовательский API, кодеки, ограничения, отмены и отложенный опрос. +test_chat_poll_network проверяет A ↔ B ↔ C, собственный Unicode-ответ, реакцию и восстановление C +после остановки чата без возвращения отменённых состояний. From a8c81f7e2ba293b0bd013aebef46e6f87a0c0226 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:56:31 +0200 Subject: [PATCH 49/49] Record verified poll implementation and completed checks --- doc/tasks.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index 18101458..789af648 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -3,18 +3,20 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача -[ ] **Голосования, опросы и реакции в чате** — 06.10.2026, копия `/home/user/utun3`, ветка `feature/chat-feedback`: +[+] **Голосования, опросы и реакции в чате** — 06.10.2026, копия `/home/user/utun3`, ветка `feature/chat-feedback`: реализован базовый слой `src/chat/chat_feedback.c/h`: таблица каждого канала, подписи участников, независимые ver для ответа и реакции, сохраняемая отмена, детерминированный конфликт ver, атомарное применение страниц и восстановление индекса. Merkle v3 разделяет MEMBERS/FEEDBACK, курсор записи — 32 байта. - `./check.sh`: 130 passed, 0 failed, 1 skipped; proxy/full, burst и load также PASS. + Итоговый `./check.sh`: 132 passed, 0 failed, 1 skipped; proxy/full, burst и load также PASS. + Чистые сборки desktop и Android assembleDebug прошли; 7 Qt-тестов — PASS. Подключены message_id к БД/API, общий каталог 16 эмодзи, канонические форматы poll/answer/reactions, single/multiple, allow_custom_answer, подсчёт результатов, отложенное получение опроса, CLI, desktop и Android. Собраны desktop и Android APK. Сетевой тест A ↔ B ↔ C прошёл, включая restart C и сохранение отмены. Исправлен запуск начального member_sync после restart чата поверх уже UP-транспорта. - Осталось: финальная чистая сборка актуальных приложений и общий check.sh. + Реализация и финальная проверка завершены. APK: app/build/outputs/apk/debug/app-debug.apk + внутри tools/chatgui-android; desktop: tools/chatgui/build/vibechat. План продолжения: 1. Сохранить индексируемый message_id в msg_, вернуть его через API сообщений. 2. Добавить общий каталог эмодзи и канонические кодеки poll/answer/reactions; @@ -24,14 +26,15 @@ 4. Подключить единый командный API к CLI, desktop и Android; команды выполняются в uasync. 5. Прогнать проверки кодеков и сетевые сценарии, собрать интерфейсы и выполнить check.sh. -[ ] **tcp_proxy_full: проверить аномальные значения elapsed в отчёте** — 06.10.2026: +[+] **tcp_proxy_full: проверить аномальные значения elapsed в отчёте** — 06.10.2026: `/tmp/utun3-check.log` при успешной передаче показывает длительности вроде `basic_1mb: 161949390ms` и `concurrent_20: -1612179507375916304ms`. В `tests/tcp_proxy_full/run_test.sh` длительность считается через `date +%s%3N`; проверить фактический формат времени и арифметику в окружении запуска. Причина установлена: date +%s%3N в окружении отдаёт 19 цифр наносекунд вместо миллисекунд. run_test.sh переведён на Python time.monotonic_ns() // 1000000. - Осталось подтвердить длительности финальным интеграционным прогоном. + Подтверждено финальным check.sh: basic_1mb 179ms, concurrent_20 326ms, idle 1080ms; + все результаты передачи и проверки байтов — PASS. Лог: /tmp/utun3-polls-check.log. [ ] **rlottie: разбор предупреждений GCC 15 в импортированных исходниках** — 05.10.2026: при сборке `tools/chatgui/rlottie_src` GCC сообщает `-Wuninitialized` в