From d09ed0316f5343f4ee9e6c5409761e675be21c48 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 17:20:40 +0100 Subject: [PATCH] Preserve UTF-8 boundaries in DM peer names --- src/dm/dm_core.c | 49 +++++++++-- src/dm/dm_core.h | 5 +- tests/Makefile.am | 5 ++ tests/test_dm_names.c | 184 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 tests/test_dm_names.c diff --git a/src/dm/dm_core.c b/src/dm/dm_core.c index 3bf42ed1..465b53c6 100644 --- a/src/dm/dm_core.c +++ b/src/dm/dm_core.c @@ -217,7 +217,7 @@ struct dm_conv { uint64_t peer_node_id; uint8_t peer_x25519[32]; uint8_t peer_ed25519[32]; - char peer_name[64]; + char peer_name[DM_PEER_NAME_MAX + 1]; uint64_t group_id; uint8_t content_key[DM_CONTENT_KEY_SIZE]; uint64_t last_out_seq; @@ -227,6 +227,37 @@ struct dm_conv { enum dm_conv_result { DM_CONV_ERROR = -1, DM_CONV_FOUND = 0, DM_CONV_NOT_FOUND = 1 }; +/* Копировать целый UTF-8 префикс; NUL внутри SQL-строки и битый суффикс не попадают в имя/JSON. */ +static void dm_copy_peer_name(char out[DM_PEER_NAME_MAX + 1], const char* name, size_t len, const char* conv) { + size_t size = 0; + while (size < len && size < DM_PEER_NAME_MAX) { + unsigned c = (unsigned char)name[size]; + if (c && c < 0x80) { size++; continue; } + unsigned count, scalar, minimum; + if (c >= 0xc2 && c <= 0xdf) { count = 1; scalar = c & 31; minimum = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { count = 2; scalar = c & 15; minimum = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { count = 3; scalar = c & 7; minimum = 0x10000; } + else goto invalid; + if (count > len - size - 1) goto invalid; + for (unsigned i = 1; i <= count; i++) { + c = (unsigned char)name[size + i]; + if ((c & 0xc0) != 0x80) goto invalid; + scalar = (scalar << 6) | (c & 63); + } + if (scalar < minimum || scalar > 0x10ffff || (scalar >= 0xd800 && scalar <= 0xdfff)) goto invalid; + if (count + 1 > DM_PEER_NAME_MAX - size) break; + size += count + 1; + } + if (size < len) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "%s: peer name shortened conv=%s bytes=%zu->%zu", DM_ID, conv, len, size); + goto done; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_DM, "%s: invalid peer name encoding conv=%s offset=%zu bytes=%zu; suffix omitted", + DM_ID, conv, size, len); +done: + if (size) memcpy(out, name, size); + out[size] = '\0'; +} + /* FOUND выдаёт полную беседу; NOT_FOUND — только SQLITE_DONE; ERROR не меняет *c и БД. */ static enum dm_conv_result dm_conv_load(struct dm_state* dm, const char* conv_id, struct dm_conv* c) { sqlite3_stmt* st = NULL; @@ -259,7 +290,7 @@ static enum dm_conv_result dm_conv_load(struct dm_state* dm, const char* conv_id memcpy(loaded.peer_x25519, x, 32); memcpy(loaded.peer_ed25519, e, 32); const char* name = (const char*)sqlite3_column_text(st, 3); - if (name) snprintf(loaded.peer_name, sizeof(loaded.peer_name), "%s", name); + dm_copy_peer_name(loaded.peer_name, name, (size_t)sqlite3_column_bytes(st, 3), conv_id); loaded.group_id = (uint64_t)sqlite3_column_int64(st, 4); loaded.last_out_seq = (uint64_t)sqlite3_column_int64(st, 5); loaded.last_in_seq = (uint64_t)sqlite3_column_int64(st, 6); @@ -493,7 +524,9 @@ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len } if (dm_node_pubkeys(dm, author, c.peer_x25519, c.peer_ed25519) != 0 || dm_derive_content_key(inst->my_keys.private_key, c.peer_x25519, c.content_key) != 0) return -1; - chat_core_get_node_name(inst, author, c.peer_name, sizeof(c.peer_name)); + char name[128] = {0}; + chat_core_get_node_name(inst, author, name, sizeof(name)); + dm_copy_peer_name(c.peer_name, name, strlen(name), conv_id); } if (c.peer_node_id != author) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "%s: conversation author mismatch conv=%s", DM_ID, conv_id); @@ -760,7 +793,7 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, c.peer_node_id = peer_node_id; memcpy(c.peer_x25519, peer_x25519, 32); memcpy(c.peer_ed25519, peer_ed25519, 32); - if (peer_name) snprintf(c.peer_name, sizeof(c.peer_name), "%s", peer_name); + dm_copy_peer_name(c.peer_name, peer_name, peer_name ? strlen(peer_name) : 0, conv_id); c.group_id = source_ch_id ? strtoull(source_ch_id, NULL, 10) : 0; if (dm_derive_content_key(inst->my_keys.private_key, c.peer_x25519, c.content_key) != 0) return -1; @@ -927,17 +960,21 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf const char* conv_id = (const char*)sqlite3_column_text(st, 0); uint64_t peer = (uint64_t)sqlite3_column_int64(st, 1); const char* name = (const char*)sqlite3_column_text(st, 2); + size_t name_len = (size_t)sqlite3_column_bytes(st, 2); uint64_t last_ts = (uint64_t)sqlite3_column_int64(st, 3); uint64_t out_seq = (uint64_t)sqlite3_column_int64(st, 4); uint64_t in_seq = (uint64_t)sqlite3_column_int64(st, 5); char resolved[128]; - if (!name || !name[0]) { + if (!name || !name_len) { resolved[0] = '\0'; chat_core_get_node_name(inst, peer, resolved, sizeof(resolved)); name = resolved; + name_len = strlen(resolved); } + char safe_name[DM_PEER_NAME_MAX + 1]; + dm_copy_peer_name(safe_name, name, name_len, conv_id); char esc_name[512]; - json_escape(name ? name : "", esc_name, sizeof(esc_name)); + json_escape(safe_name, esc_name, sizeof(esc_name)); if (strbuf_addf(&json, "%s{\"conv_id\":\"%s\",\"peer_node_id\":%llu,\"peer_name\":\"%s\"," "\"last_ts\":%llu,\"out_seq\":%llu,\"in_seq\":%llu,\"generation\":%llu}", diff --git a/src/dm/dm_core.h b/src/dm/dm_core.h index 5845d91d..0cffc420 100644 --- a/src/dm/dm_core.h +++ b/src/dm/dm_core.h @@ -34,6 +34,7 @@ extern "C" { #define DM_MSG_SIG_SIZE 64 #define DM_DATA_MAX 1024 #define DM_RECEIPT_SIZE 136 +#define DM_PEER_NAME_MAX 63 /* UTF-8 байты без NUL; сохраняются только целые code points. */ /* Вычислить полную длину канонического тела из его заголовка. * Возвращает 1 и *out_len, либо 0 если буфер мал/повреждён (выход не меняется). @@ -78,6 +79,7 @@ void dm_core_destroy(struct UTUN_INSTANCE* inst); * ещё нет, выводит conv_id/ключи. Подключение для передачи медиа принадлежит media. * Снимает hidden без смены поколения; публикует CONV_UPDATED с текущим поколением. * Ключи обязательны, peer_name/source_ch_id могут быть NULL; переданные ключи пира проверяет вызывающий. + * Имя ограничено DM_PEER_NAME_MAX байтами по границе UTF-8; некорректный суффикс отбрасывается с WARN. * Возвращает 0 при успехе, <0 при ошибке. * Отказ чтения/проверки ключей не создаёт беседу, не меняет её поля/hidden и не выдаёт CONV_UPDATED. * Создание разрешено только после успешного SELECT без строки; существующие seq сохраняются. */ @@ -104,8 +106,7 @@ void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv); /* До 200 видимых бесед по last_ts DESC: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq,generation}]. * buf вмещает JSON и NUL; 0 и *out_len без NUL при успехе, -1 при ошибке. Все вызовы в uasync. - * Известный дефект: имя может быть обрезано внутри UTF-8 в dm_start/load, и результат с кодом0 будет - * некорректным UTF-8 JSON. См. doc/dm_audit_findings.txt, раздел8. */ + * Имя из БД либо nodes нормализуется по правилам dm_start; снимок не меняет сохранённые строки. */ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len); /* Сообщения беседы в JSON: расшифрованное содержимое, статусы доставки и медиа. diff --git a/tests/Makefile.am b/tests/Makefile.am index 346aafd2..fd98ee28 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -171,6 +171,7 @@ check_PROGRAMS += \ test_etcp_router \ test_dm_worker \ test_dm_history \ + test_dm_names \ test_chat_join \ test_radio \ test_bgp_route_exchange \ @@ -904,6 +905,10 @@ test_dm_history_SOURCES = test_dm_history.c test_dm_history_CFLAGS = $(test_dm_e2e_CFLAGS) test_dm_history_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_dm_names_SOURCES = test_dm_names.c +test_dm_names_CFLAGS = $(test_dm_e2e_CFLAGS) +test_dm_names_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + if OS_LINUX test_dm_load_SOURCES = test_dm_load.c test_dm_load_CFLAGS = $(test_dm_e2e_CFLAGS) diff --git a/tests/test_dm_names.c b/tests/test_dm_names.c new file mode 100644 index 00000000..938adc95 --- /dev/null +++ b/tests/test_dm_names.c @@ -0,0 +1,184 @@ +/* Настоящие DM/SQLite: целые UTF-8 имена при создании, загрузке, fallback и restart. */ +#include +#include +#include +#include "../src/utun_instance.h" +#include "../src/dm/dm_core.h" +#include "../src/dm/dm_crypto.h" +#include "../src/chat/chat_core.h" +#include "../lib/debug_config.h" +#include "../lib/json_flat.h" +#include "../lib/mem.h" +#include "test_utils.h" + +#define EXPECT(condition) do { if (!(condition)) { \ + DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM name test failed line=%d", __LINE__); return -1; \ +} } while (0) +static unsigned cases, snapshots; + +static struct UTUN_INSTANCE* start(struct UASYNC* ua, const char* directory, const struct SC_MYKEYS* identity) { + char config[1536], pub[65], priv[65]; + for (unsigned i = 0; i < 32; i++) { + snprintf(pub + 2 * i, 3, "%02x", identity->public_key[i]); + snprintf(priv + 2 * i, 3, "%02x", identity->private_key[i]); + } + snprintf(config, sizeof(config), "[global]\nmy_public_key=%s\nmy_private_key=%s\ndb_path=%s\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", pub, priv, directory); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); + if (inst && (utun_core_start(inst) || chat_service_start(inst))) { utun_instance_destroy(inst); return NULL; } + return inst; +} + +static int stored(sqlite3* db, const char* conv, const char* expected, size_t len) { + sqlite3_stmt* st = NULL; + EXPECT(sqlite3_prepare_v2(db, "SELECT CAST(peer_name AS BLOB) FROM dm_conversations WHERE conv_id=?", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); + int rc = sqlite3_step(st); + int exact = rc == SQLITE_ROW && (size_t)sqlite3_column_bytes(st, 0) == len && + (!len || !memcmp(sqlite3_column_blob(st, 0), expected, len)); + sqlite3_finalize(st); + EXPECT(exact); + return 0; +} + +static int replace(sqlite3* db, const char* conv, const char* name, size_t len) { + sqlite3_stmt* st = NULL; + EXPECT(sqlite3_prepare_v2(db, "UPDATE dm_conversations SET peer_name=? WHERE conv_id=?", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, name, (int)len, SQLITE_STATIC); + sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + EXPECT(rc == SQLITE_DONE); + return 0; +} + +/* Точные ожидаемые строки проверяются здесь; полный JSON отдельно сохраняется для строгого внешнего парсера. */ +static int snapshot(struct UTUN_INSTANCE* inst, const char* conv, const char* expected, FILE* file) { + char json[65536], token[96], member[1024], actual[256]; size_t len = 0; + int changes = sqlite3_total_changes(inst->topo_sqlite_db); + EXPECT(!dm_list_conversations_json(inst, json, sizeof(json), &len)); + EXPECT(len == strlen(json) && sqlite3_total_changes(inst->topo_sqlite_db) == changes); + snprintf(token, sizeof(token), "\"conv_id\":\"%s\"", conv); + const char* row = strstr(json, token); + const char* name = row ? strstr(row, "\"peer_name\":") : NULL; + EXPECT(name); + /* json_flat_get останавливается после первого ключа, до числовых полей снимка. */ + snprintf(member, sizeof(member), "{%s", name); + EXPECT(!json_flat_get(member, "peer_name", actual, sizeof(actual)) && !strcmp(actual, expected)); + EXPECT(fwrite(json, 1, len, file) == len && fputc('\n', file) != EOF); + snapshots++; + return 0; +} + +static int check_case(struct UTUN_INSTANCE* inst, const char* input, size_t len, const char* expected, + FILE* file, char last[64]) { + struct SC_MYKEYS peer; uint8_t ed[32]; + EXPECT(!sc_generate_keypair(&peer) && !sc_derive_ed25519_pubkey(peer.private_key, ed)); + uint64_t id = sc_derive_node_id_from_pubkey(peer.public_key); + snprintf(last, 64, "%llu", (unsigned long long)dm_derive_conv_id(inst->node_id, id)); + sqlite3* db = inst->topo_sqlite_db; + sqlite3_stmt* st = NULL; + EXPECT(sqlite3_prepare_v2(db, "INSERT INTO nodes(node_id,x25519_pubkey,ed25519_pubkey,name) VALUES(?,?,?,?)", + -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_int64(st, 1, (sqlite3_int64)id); + sqlite3_bind_blob(st, 2, peer.public_key, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 3, ed, 32, SQLITE_STATIC); + sqlite3_bind_text(st, 4, input, (int)len, SQLITE_STATIC); + int rc = sqlite3_step(st); sqlite3_finalize(st); EXPECT(rc == SQLITE_DONE); + EXPECT(!dm_start(inst, id, peer.public_key, ed, input, NULL)); + EXPECT(!stored(db, last, expected, strlen(expected)) && !snapshot(inst, last, expected, file)); + + /* Старый SQL может содержать полное длинное имя, оборванный UTF-8 или NUL внутри TEXT. */ + EXPECT(!replace(db, last, input, len)); + EXPECT(!snapshot(inst, last, expected, file) && !stored(db, last, input, len)); + char messages[8192]; size_t bytes = 0; + int changes = sqlite3_total_changes(db); + EXPECT(!dm_list_messages_json(inst, last, 10, 0, messages, sizeof(messages), &bytes) && !strcmp(messages, "[]")); + EXPECT(sqlite3_total_changes(db) == changes && !stored(db, last, input, len)); + EXPECT(!dm_send(inst, last, "text", (const uint8_t*)"one", 3)); + EXPECT(!stored(db, last, expected, strlen(expected)) && !snapshot(inst, last, expected, file)); + + /* Пустое сохранённое имя разрешается через nodes: этот путь тоже обязан выдавать целый UTF-8. */ + EXPECT(!replace(db, last, "", 0) && !snapshot(inst, last, expected, file) && !stored(db, last, "", 0)); + /* Убрать повреждение фикстуры: следующие снимки не должны повторять предупреждения прошлых случаев. */ + EXPECT(!replace(db, last, expected, strlen(expected))); + EXPECT(sqlite3_prepare_v2(db, "UPDATE nodes SET name=? WHERE node_id=?", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, expected, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)id); + rc = sqlite3_step(st); sqlite3_finalize(st); EXPECT(rc == SQLITE_DONE); + cases++; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "DM name test: case=%u input=%zu kept=%zu create/load/save/fallback PASS", + cases, len, strlen(expected)); + return 0; +} + +static int run(struct UTUN_INSTANCE* inst, FILE* file, char last[64], char expected[256]) { + char input[512]; + const size_t lengths[] = {0, 1, 62, 63, 64, 127}; + for (size_t i = 0; i < sizeof(lengths) / sizeof(lengths[0]); i++) { + size_t len = lengths[i], keep = len < 63 ? len : 63; + memset(input, 'A', len); input[len] = 0; memset(expected, 'A', keep); expected[keep] = 0; + EXPECT(!check_case(inst, input, len, expected, file, last)); + } + const char* points[] = {"\xd0\xaf", "\xe2\x82\xac", "\xf0\x9f\x98\x80", "\xf4\x8f\xbf\xbf"}; + for (size_t p = 0; p < sizeof(points) / sizeof(points[0]); p++) { + size_t width = strlen(points[p]); + for (size_t prefix = 58; prefix <= 64; prefix++) { + memset(input, 'a', prefix); memcpy(input + prefix, points[p], width); input[prefix + width] = 'z'; + input[prefix + width + 1] = 0; + size_t keep = prefix < 63 ? prefix : 63; + memcpy(expected, input, keep); + if (prefix + width <= 63) { + memcpy(expected + keep, points[p], width); keep += width; + if (keep < 63) expected[keep++] = 'z'; + } + expected[keep] = 0; + EXPECT(!check_case(inst, input, prefix + width + 1, expected, file, last)); + } + for (size_t count = 63 / width; count <= 63 / width + 1; count++) { + for (size_t n = 0; n < count; n++) memcpy(input + n * width, points[p], width); + input[count * width] = 0; + size_t keep = (count * width <= 63 ? count : 63 / width) * width; + memcpy(expected, input, keep); expected[keep] = 0; + EXPECT(!check_case(inst, input, count * width, expected, file, last)); + } + } + const char* invalid[] = {"\x80", "\xc0\xaf", "\xe0\x80\x80", "\xf0\x80\x80\x80", "\xed\xa0\x80", + "\xf4\x90\x80\x80", "\xf5\x80\x80\x80", "\xff", "\xc2", "\xe2\x82", "\xf0\x9f\x98", "\xd0q"}; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + for (size_t prefix = 0; prefix <= 2; prefix += 2) { + memset(input, 'a', prefix); memcpy(input + prefix, invalid[i], strlen(invalid[i]) + 1); + memset(expected, 'a', prefix); expected[prefix] = 0; + EXPECT(!check_case(inst, input, prefix + strlen(invalid[i]), expected, file, last)); + } + } + memcpy(input, "OK\0hidden", 9); input[9] = 0; snprintf(expected, 256, "OK"); + EXPECT(!check_case(inst, input, 9, expected, file, last)); + snprintf(input, sizeof(input), "quote\" slash\\ line\n%s", points[0]); memcpy(expected, input, strlen(input) + 1); + EXPECT(!check_case(inst, input, strlen(input), expected, file, last)); + return 0; +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + debug_set_category_level(DEBUG_CATEGORY_DM, getenv("UTUN_TEST_DEBUG") ? DEBUG_LEVEL_DEBUG : DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); + size_t allocations = u_get_allocated_count(); + char directory[512] = "/tmp/utun_dm_names_XXXXXX", path[768], last[64], expected[256]; + struct UASYNC* ua = NULL; struct UTUN_INSTANCE* inst = NULL; FILE* file = NULL; + struct SC_MYKEYS identity; int failed = 1; + if (test_mkdtemp(directory) || sc_generate_keypair(&identity) || !(ua = uasync_create())) goto done; + if (!(inst = start(ua, directory, &identity))) goto done; + snprintf(path, sizeof(path), "%s/names.jsonl", directory); file = fopen(path, "wb"); + if (!file || run(inst, file, last, expected)) goto done; + utun_instance_destroy(inst); inst = start(ua, directory, &identity); + if (!inst || snapshot(inst, last, expected, file)) goto done; + failed = 0; +done: + if (failed) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM name test failed after cases=%u snapshots=%u", cases, snapshots); + if (file && fclose(file)) failed = 1; + if (inst) utun_instance_destroy(inst); + if (ua) uasync_destroy(ua, 0); + if (allocations != u_get_allocated_count()) failed = 1; + printf("test_dm_names: cases=%u snapshots=%u allocations=%zu->%zu %s artifacts=%s\n", + cases, snapshots, allocations, u_get_allocated_count(), failed ? "FAIL" : "PASS", directory); + return failed; +}