From 3e96e429eb73c171f1f603c1f3000c5fd58b00cd Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 13:46:53 +0100 Subject: [PATCH 1/7] Preserve unsigned node IDs in Android invite previews Encode JNI node IDs as decimal strings and decode both uint64 fields without floating point or signed parsing. Compare all invitation fields and binary contents. Cover boundary IDs, malformed JSON types, and model equality with nine JVM tests. Native build, invite codec/build tests, and both debug APK ABIs passed. --- tools/chatgui-android/app/build.gradle.kts | 1 + .../java/com/utun/chat/data/InviteLink.kt | 81 ++++++++++++++----- .../java/com/utun/chat/data/InviteLinkTest.kt | 78 ++++++++++++++++++ .../jni_bridge/android_jni_bridge.c | 5 +- 4 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 000edd51..4eb86300 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -84,6 +84,7 @@ tasks.withType().configureEach { dependencies { testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") // Реальный JSON для проверки границы JNI, без Android mock stubs. implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt index 4d198104..cbd5053f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -1,7 +1,8 @@ package com.utun.chat.data -import com.utun.chat.data.LogManager - +/* Kotlin-представление invite: wire/base64 и допустимость полей проверяет общий C-кодек через NativeLib. + * Декодирование не запускает подключение. Для join передавать исходный link: из preview ссылка не собирается. + * Массивы моделей принадлежат получателю, copy() их не клонирует; после создания использовать только для чтения. */ const val INVITE_LINK_VERSION: Int = 0x03 const val INVITE_PUBKEY_SIZE: Int = 32 const val INVITE_SNAME_MAX: Int = 64 @@ -10,14 +11,32 @@ const val INVITE_PROTO_UDP: Int = 0x01 const val INVITE_PROTO_TCP: Int = 0x02 const val INVITE_PROTO_REALITY: Int = 0x04 // флаг reality-камуфляжа поверх TCP (reality-TCP = 0x06) +/** REALITY-снимок; has=true у непустого native-preview, byte-поля сравниваются по содержимому. + * shortId — 8 байт, serverPubkey — 32 байта, version — 3 байта; serverName — SNI до 63 UTF-8 байт. */ data class InviteReality( val has: Boolean, val shortId: ByteArray, val serverPubkey: ByteArray, val version: ByteArray, val serverName: String -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteReality) return false + return has == other.has && shortId.contentEquals(other.shortId) && serverPubkey.contentEquals(other.serverPubkey) && + version.contentEquals(other.version) && serverName == other.serverName + } + override fun hashCode(): Int { + var result = has.hashCode() + result = result * 31 + shortId.contentHashCode() + result = result * 31 + serverPubkey.contentHashCode() + result = result * 31 + version.contentHashCode() + return result * 31 + serverName.hashCode() + } +} +/** Адрес узла подключения: family=4/6, address — 4/16 сетевых байт; port=1..65535 в порядке байтов хоста. + * socketId=0..255, proto — UDP=1/TCP=2/TCP+REALITY=6; поля native-preview проверены общим C-кодеком. */ data class InviteAddr( val family: Int, // 4 or 6 val address: ByteArray, @@ -34,6 +53,9 @@ data class InviteAddr( override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId * 31 + proto } +/** Полный снимок приглашения; равенство учитывает все поля, включая реквизиты входа и исходную ссылку. + * channelId — 1..Long.MAX_VALUE; joinKey/nodeId хранят все биты uint64 в Long (для unsigned-вывода использовать toULong()). + * pubkey — 32 байта X25519 узла подключения, addrs — 1..32 его адресов. password — UTF-8 preview, link — исходная wire-ссылка. */ data class InviteData( val channelId: Long, val joinKey: Long, @@ -47,36 +69,53 @@ data class InviteData( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is InviteData) return false - return channelId == other.channelId && pubkey.contentEquals(other.pubkey) && - nodeId == other.nodeId && addrs == other.addrs + return channelId == other.channelId && joinKey == other.joinKey && pubkey.contentEquals(other.pubkey) && + nodeId == other.nodeId && addrs == other.addrs && password == other.password && reality == other.reality && link == other.link + } + override fun hashCode(): Int { + var result = channelId.hashCode() + result = result * 31 + joinKey.hashCode() + result = result * 31 + pubkey.contentHashCode() + result = result * 31 + nodeId.hashCode() + result = result * 31 + addrs.hashCode() + result = result * 31 + password.hashCode() + result = result * 31 + (reality?.hashCode() ?: 0) + return result * 31 + link.hashCode() } - override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode() } object InviteLink { - private const val PREFIX = "utun://" - + /** Возвращает собственный preview или null при отказе C-кодека/повреждённом JSON; ошибок подключения здесь нет. + * Загруженная libutun обязательна. JNI-ответ не логируется: содержит join key и пароль. */ fun decode(link: String): InviteData? { val encoded = NativeLib.decodeInvite(link) ?: return null return try { - fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val value = org.json.JSONObject(encoded) - val addresses = value.getJSONArray("addresses") - val reality = value.optJSONObject("reality")?.let { - InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), - bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) - } - InviteData(value.getLong("channel"), value.getString("key").toULong().toLong(), - bytes(value.getString("pubkey")), value.getLong("node"), - (0 until addresses.length()).map { i -> - val a = addresses.getJSONObject(i) - InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) - }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + fromNativePreview(encoded, link) } catch (e: Exception) { LogManager.addLog("ERROR", "INVITE", "invalid native invitation preview: ${e.javaClass.simpleName}"); null } } + /** Преобразует проверенный C-кодеком JSON JNI без повторного разбора wire; ошибки полей передаёт decode. + * key/node — десятичные строки uint64, остальные byte-поля — hex. Числовой JSON/Double для ID не допускается. */ + internal fun fromNativePreview(encoded: String, link: String): InviteData { + fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val value = org.json.JSONObject(encoded) + val joinKey = (value.get("key") as String).toULong().toLong() + val nodeId = (value.get("node") as String).toULong().toLong() + val addresses = value.getJSONArray("addresses") + val reality = value.optJSONObject("reality")?.let { + InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), + bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) + } + return InviteData(value.getLong("channel"), joinKey, bytes(value.getString("pubkey")), nodeId, + (0 until addresses.length()).map { i -> + val a = addresses.getJSONObject(i) + InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) + }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + } + + /** Диагностическая подпись адреса/сокета; IPv6 выводится hex без сжатия, строка не является endpoint для подключения. */ fun addrToString(addr: InviteAddr): String { val protoStr = when { (addr.proto and INVITE_PROTO_REALITY) != 0 -> "TCP+REALITY" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt new file mode 100644 index 00000000..d4ef9959 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt @@ -0,0 +1,78 @@ +package com.utun.chat.data + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +/** Значения JNI проверяются как строки uint64; Android Number/Double не сохраняет весь диапазон ID. */ +class InviteLinkTest { + private fun reality() = InviteReality(true, ByteArray(8) { 1 }, ByteArray(32) { 2 }, byteArrayOf(1, 2, 3), "example.test") + private fun invite() = InviteData(71, 13, ByteArray(32) { 3 }, 19, + listOf(InviteAddr(4, byteArrayOf(127, 0, 0, 1), 9999, 7, INVITE_PROTO_TCP or INVITE_PROTO_REALITY)), + "password", reality(), "utun://original") + + private fun preview(node: String, key: String = "13") = """ + {"channel":71,"key":"$key","node":"$node","pubkey":"${"03".repeat(32)}","password":"70617373776f7264", + "reality":{"short":"${"01".repeat(8)}","pubkey":"${"02".repeat(32)}","version":"010203","name":"6578616d706c652e74657374"}, + "addresses":[{"family":4,"socket":7,"proto":6,"port":9999,"ip":"7f000001"}]} + """.trimIndent() + + @Test fun nativePreviewPreservesEveryNodeBit() { + for (node in listOf(1uL, Long.MAX_VALUE.toULong(), 1uL shl 63, 0xfedcba9876543210uL, ULong.MAX_VALUE)) { + val actual = InviteLink.fromNativePreview(preview(node.toString()), "utun://original") + assertEquals(node.toLong(), actual.nodeId) + assertEquals(node, actual.nodeId.toULong()) + } + } + + @Test fun joinKeyAndAllPreviewFieldsSurviveMapping() { + assertEquals(invite().copy(joinKey = -1), InviteLink.fromNativePreview(preview("19", ULong.MAX_VALUE.toString()), "utun://original")) + } + + @Test fun floatingPointNodeIsRejected() { + // Именно такой тип может получиться после разбора JSON-числа > Long.MAX_VALUE в Android. + val value = JSONObject(preview("19")).put("node", 18446744073709551615.0) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(value.toString(), "utun://original") } + } + + @Test fun uint64FieldsRequireStringsRegardlessOfTheirValue() { + for (field in listOf("key", "node")) for (value in listOf(19L, 19.0, true, JSONObject.NULL)) { + val json = JSONObject(preview("19")).put(field, value) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(json.toString(), "utun://original") } + } + } + + @Test fun signedChannelRangeKeepsExactMaximumValue() { + val json = JSONObject(preview("19")).put("channel", Long.MAX_VALUE) + assertEquals(Long.MAX_VALUE, InviteLink.fromNativePreview(json.toString(), "utun://original").channelId) + } + + @Test fun unsignedNodeAndKeyRejectOutOfRangeStrings() { + for (bad in listOf("-1", "18446744073709551616", "1.5", "not-a-number")) { + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview(bad), "utun://original") } + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview("19", bad), "utun://original") } + } + } + + @Test fun independentSnapshotsHaveStructuralEquality() { + val first = invite(); val second = invite() + assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode()) + assertEquals(first.reality, second.reality); assertEquals(first.reality.hashCode(), second.reality.hashCode()) + assertEquals(1, hashSetOf(first, second).size) + } + + @Test fun everyInvitationFieldParticipatesInEquality() { + val first = invite() + val changed = listOf(first.copy(channelId = 72), first.copy(joinKey = 14), first.copy(pubkey = ByteArray(32) { 4 }), + first.copy(nodeId = 20), first.copy(addrs = emptyList()), first.copy(password = "different"), + first.copy(reality = null), first.copy(link = "utun://different")) + changed.forEach { assertNotEquals(first, it) } + assertEquals(changed.size + 1, (changed + first).toSet().size) + } + + @Test fun everyRealityFieldParticipatesInEquality() { + val first = reality() + listOf(first.copy(has = false), first.copy(shortId = ByteArray(8)), first.copy(serverPubkey = ByteArray(32)), + first.copy(version = byteArrayOf(4, 5, 6)), first.copy(serverName = "other.test")).forEach { assertNotEquals(first, it) } + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index cbbd4feb..62c738c5 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1912,7 +1912,8 @@ JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( (*env)->ReleaseStringUTFChars(env, link, text); return result; } -/* Представление результата общего C-кодека для предпросмотра Kotlin. */ +/* Представление результата общего C-кодека для предпросмотра Kotlin. + * uint64 key/node передаются десятичными строками: числовой JSON может потерять биты при преобразовании в Double/Long. */ static int invite_hex_json(struct strbuf* out, const uint8_t* bytes, size_t len) { char hex[257]; if (len > 128) return -1; @@ -1930,7 +1931,7 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDecodeInvite( (*env)->ReleaseStringUTFChars(env, link, text); if (result) return NULL; struct strbuf out = strbuf_new(); - if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":%llu,\"pubkey\":", + if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":\"%llu\",\"pubkey\":", (unsigned long long)data.channelId, (unsigned long long)data.join_key, (unsigned long long)data.nodeId) < 0 || invite_hex_json(&out, data.pubkey, 32) < 0 || strbuf_addf(&out, ",\"password\":") < 0 || invite_hex_json(&out, (const uint8_t*)data.password, data.password_len) < 0) goto failed; From f04637a2570ba4ace173c848f4d215ec80807361 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:04:12 +0100 Subject: [PATCH 2/7] Keep Android message link parsing linear and validate authority --- tools/chatgui-android/app/build.gradle.kts | 3 + .../java/com/utun/chat/data/MessageLinks.kt | 38 +++++++--- .../com/utun/chat/data/MessageLinksTest.kt | 73 +++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 4eb86300..c435a736 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -80,6 +80,9 @@ android { // Зависимость только между задачами: Android fixtures из JVM-модуля не входят в classpath приложения/APK. tasks.withType().configureEach { dependsOn(":router-tests:test") + val messageLinkCases = rootProject.file("../chatgui/tests/message_links_cases.tsv") + inputs.file(messageLinkCases) + systemProperty("utun.messageLinkCases", messageLinkCases.absolutePath) } dependencies { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt index a4ca1da8..527e93d3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt @@ -1,33 +1,51 @@ package com.utun.chat.data +import java.net.URI import java.net.URL -/** Границы UTF-16 и полный адрес; разбор не меняет текст и не открывает ссылки. */ +/** Диапазон [start, end) в UTF-16 исходного сообщения и полный адрес без внешней пунктуации. */ data class MessageLink(val start: Int, val end: Int, val url: String) +/** Чистый разбор для подсветки: HTTP/HTTPS без учёта регистра и utun://; сеть и JNI не вызываются. */ object MessageLinks { private val pattern = Regex("utun://[A-Za-z0-9+/]+={0,2}|(?i:https?://)[^\\s\\p{Z}\\p{Cc}<>\"'`«»“”‘’]+") private val invalidEscape = Regex("%(?![0-9A-Fa-f]{2})") - /** Некорректные URL в пользовательском тексте остаются обычным текстом. */ + /** Проверяет полный HTTP/HTTPS URL; порт пустой либо десятичный 0..65535. Некорректный текст — обычный ввод, без логов. */ fun isWebUrl(url: String): Boolean { if (!pattern.matches(url) || invalidEscape.containsMatchIn(url)) return false return try { val parsed = URL(url) - (parsed.protocol == "http" || parsed.protocol == "https") && parsed.host.isNotEmpty() && parsed.port in -1..65535 - } catch (_: java.net.MalformedURLException) { false } + if ((parsed.protocol != "http" && parsed.protocol != "https") || parsed.host.isEmpty() || parsed.port !in -1..65535) return false + // URL допускает -1/+80 и недопустимые символы host; URI проверяет только authority, не ограничивая Unicode/[] в пути. + val authority = URI("//${parsed.authority}").rawAuthority?.substringAfterLast('@') ?: return false + val port = if (authority.startsWith("[")) authority.substringAfter(']').substringAfter(':', "") + else authority.substringAfter(':', "") + port.all { it in '0'..'9' } + } catch (_: java.net.MalformedURLException) { false } catch (_: java.net.URISyntaxException) { false } } + /** Возвращает непересекающиеся диапазоны по порядку; отделение хвоста занимает O(n), адрес копируется один раз. */ fun parse(text: String): List = pattern.findAll(text).mapNotNull { match -> var url = match.value if (!url.startsWith("utun://")) { - while (url.isNotEmpty()) { - val last = url.last() - val closing = ")]}".indexOf(last) - val unmatched = closing >= 0 && url.count { it == last } > url.count { it == "([{"[closing] } - if (last !in ".,;:!?" && !unmatched) break - url = url.dropLast(1) + val balance = IntArray(3) + for (char in url) { + val opening = "([{".indexOf(char) + val closing = ")]}".indexOf(char) + if (opening >= 0) balance[opening]-- + if (closing >= 0) balance[closing]++ } + var end = url.length + while (end > 0) { + val last = url[end - 1] + val closing = ")]}".indexOf(last) + val unmatched = closing >= 0 && balance[closing] > 0 + if (last !in ".,;:!?" && !unmatched) break + if (closing >= 0) balance[closing]-- + end-- + } + url = url.substring(0, end) if (!isWebUrl(url)) return@mapNotNull null } MessageLink(match.range.first, match.range.first + url.length, url) diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt new file mode 100644 index 00000000..bab81c7f --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt @@ -0,0 +1,73 @@ +package com.utun.chat.data + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** Общие с Qt адреса и диапазоны UTF-16; разбор длинного хвоста не задерживает UI. */ +class MessageLinksTest { + private fun assertLinks(text: String, expected: List) { + val links = MessageLinks.parse(text) + assertEquals(text.take(200), expected, links.map { it.url }) + var end = 0 + for (link in links) { + assertTrue(link.start >= end) + assertEquals(link.url, text.substring(link.start, link.end)) + end = link.end + } + } + + @Test fun sharedQtAndAndroidCases() { + val fixture = File(requireNotNull(System.getProperty("utun.messageLinkCases"))) + var count = 0 + fixture.forEachLine { line -> + if (line.isNotEmpty() && !line.startsWith("#")) { + val columns = line.split('\t') + assertLinks(columns.first().replace("\\n", "\n"), columns.drop(1)) + count++ + } + } + assertTrue("Expected shared link cases", count >= 15) + } + + @Test fun openingOnlyAcceptsCompleteWebUrls() { + for (url in listOf("http://example.com/", "HTTPS://пример.рф/путь", "https://[::1]:65535/", + "http://example.com:0/", "http://example.com:/", "http://example.com:00080/")) { + assertTrue(url, MessageLinks.isWebUrl(url)) + } + for (url in listOf("utun://abc+/==", "ftp://example.com/", "https://", "https://example.com/a b", "https://example.com/%GG")) { + assertFalse(url, MessageLinks.isWebUrl(url)) + } + } + + @Test fun malformedAuthorityIsNeitherAnnotatedNorOpened() { + for (url in listOf("http://example.com:-1/", "http://example.com:+80/", "http://example.com:-0/", "http://example.com\\foo/")) { + assertFalse(url, MessageLinks.isWebUrl(url)) + assertLinks(url, emptyList()) + } + } + + @Test fun bracketTypesHaveIndependentBalances() { + for (path in listOf("a(b)", "a[0]", "a{0}", "a([{}])", "a((b)", "a[b[0]")) { + val url = "https://example.com/$path" + assertLinks("✨ $url.,;:!?", listOf(url)) + } + assertLinks("https://example.com/a(b)))! next", listOf("https://example.com/a(b)")) + assertLinks("https://example.com/a[0]}).", listOf("https://example.com/a[0]")) + assertLinks("https://example.com/a)(b)", listOf("https://example.com/a)(b")) + } + + @Test(timeout = 5_000) fun longMixedTailKeepsOriginalRangesWithoutRepeatedCopies() { + val first = "https://example.com/a([{}])" + val second = "http://example.org/b" + val text = "👋 $first" + ")]},.!?".repeat(20_000) + " $second" + assertLinks(text, listOf(first, second)) + assertEquals(3, MessageLinks.parse(text).first().start) + } + + @Test fun textWithoutLinksStaysUnannotated() { + for (text in listOf("", "Обычный текст 👋", "www.example.com", "http:// https://?x=1")) { + assertLinks(text, emptyList()) + } + } +} From 3d48024fd84b3f5a3d1d6ab1da719369ddefcf9b Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:08:48 +0100 Subject: [PATCH 3/7] Trim desktop message link punctuation in linear time --- doc/message_links.md | 15 +++++++++++---- tools/chatgui/src/message_links.cpp | 23 +++++++++++++++++------ tools/chatgui/tests/test_invite_links.cpp | 16 ++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/doc/message_links.md b/doc/message_links.md index 624a0e78..276465c3 100644 --- a/doc/message_links.md +++ b/doc/message_links.md @@ -22,6 +22,10 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`, где end исключён. Единицы обоих API — UTF-16: `tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`. +Конец диапазона Android не включён в ссылку; Qt возвращает начало и длину. +Удаление внешней пунктуации занимает линейное время: баланс трёх видов скобок +считается один раз, хвост сокращается по индексу без повторного копирования адреса. +Порт веб-адреса пустой либо десятичный от 0 до 65535; знак перед числом недопустим. Подсветка и нажатие используют эти диапазоны. Android подставляет emoji placeholders только в обычный текст между ссылками: emoji внутри адреса остаётся частью полного URL. Qt использует один QTextLayout для размеров, отрисовки, выделения и координат нажатия. @@ -38,11 +42,14 @@ Qt `QDesktopServices::openUrl`. На нажатии веб-адрес снова Общие случаи: `tools/chatgui/tests/message_links_cases.tsv`. Qt `test_invite_links` проверяет разбор, перенесённые веб-ссылки, смесь схем, нажатие, drag/copy и сокращённые приглашения; `test_emoji_playback` — жесты приглашений. -Android `tests/test_message_links.kt` проверяет тот же TSV на классах из APK-сборки: +Android `MessageLinksTest` проверяет тот же TSV через Gradle, некорректные authority +и длинный хвост пунктуации. Аналогичный тест длинного хвоста есть в Qt. +Оба разбора не открывают ссылки; некорректный пользовательский текст не создаёт логов. ```bash cd tools/chatgui-android -./build.sh noinstall -kotlinc tests/test_message_links.kt -classpath app/build/tmp/kotlin-classes/debug -d /tmp/message-links-tests.jar -kotlin -classpath /tmp/message-links-tests.jar:app/build/tmp/kotlin-classes/debug Test_message_linksKt ../chatgui/tests/message_links_cases.tsv +./gradlew :app:testDebugUnitTest --tests com.utun.chat.data.MessageLinksTest ``` + +`tests/test_message_links.kt` остаётся отдельным CLI-тестом того же TSV; +ему передаётся путь к файлу, а в classpath нужны собранные классы `MessageLinks`. diff --git a/tools/chatgui/src/message_links.cpp b/tools/chatgui/src/message_links.cpp index cbe988e5..e74a6b0f 100644 --- a/tools/chatgui/src/message_links.cpp +++ b/tools/chatgui/src/message_links.cpp @@ -12,13 +12,24 @@ QVector messageLinks(const QString& text) { const auto match = matches.next(); QString url = match.captured(); if (!url.startsWith(QStringLiteral("utun://"))) { - while (!url.isEmpty()) { - const QChar last = url.back(); - const int closing = QStringLiteral(")]}").indexOf(last); - const bool unmatched = closing >= 0 && url.count(last) > url.count(QStringLiteral("([{").at(closing)); - if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; - url.chop(1); + // Один подсчёт и один проход по хвосту: повторный count() на каждой скобке даёт O(n²). + qsizetype balance[3] = {}; + for (const QChar ch : url) { + const int opening = QStringLiteral("([{").indexOf(ch); + const int closing = QStringLiteral(")]}").indexOf(ch); + if (opening >= 0) balance[opening]--; + if (closing >= 0) balance[closing]++; } + auto end = url.size(); + while (end > 0) { + const QChar last = url.at(end - 1); + const int closing = QStringLiteral(")]}").indexOf(last); + const bool unmatched = closing >= 0 && balance[closing] > 0; + if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; + if (closing >= 0) balance[closing]--; + end--; + } + url.truncate(end); const QUrl parsed(url, QUrl::StrictMode); if (!parsed.isValid() || parsed.host().isEmpty()) continue; } diff --git a/tools/chatgui/tests/test_invite_links.cpp b/tools/chatgui/tests/test_invite_links.cpp index 2ebec6a6..b34eb6bf 100644 --- a/tools/chatgui/tests/test_invite_links.cpp +++ b/tools/chatgui/tests/test_invite_links.cpp @@ -259,6 +259,22 @@ private slots: QVERIFY(count >= 15); } + void longMessageLinkTail() { + const QString first = QStringLiteral("https://example.com/a([0])"); + const QString second = QStringLiteral("http://example.org/b"); + const QString text = QString::fromUtf8("👋 ") + first + QStringLiteral(")]},.!?").repeated(20000) + ' ' + second; + QElapsedTimer timer; timer.start(); + const auto links = messageLinks(text); + const auto elapsed = timer.elapsed(); + qInfo() << "Message links:" << text.size() << "UTF-16 units parsed in" << elapsed << "ms"; + QCOMPARE(links.size(), 2); + QCOMPARE(links.first().url, first); + QCOMPARE(links.first().start, 3); + QCOMPARE(links.last().url, second); + QCOMPARE(text.mid(links.last().start, links.last().length), second); + QVERIFY2(elapsed < 5000, "Message link parsing exceeded the UI regression guard"); + } + void webLinksUsePaintedCoordinatesAndDragDoesNotOpen() { const QString first = QStringLiteral("https://пример.рф/путь?") + QString(160, 'a') + QStringLiteral("=1#fragment"); const QString second = QStringLiteral("http://example.org/a(b)?x=2"); From 0fe14699b09cca658ddedf6b913af6ed95a34256 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:09:51 +0100 Subject: [PATCH 4/7] Document Android conversation targets and test message ID conversion --- .../java/com/utun/chat/data/ChannelReply.kt | 13 +++++- .../com/utun/chat/data/ConversationTarget.kt | 7 ++- .../com/utun/chat/data/ChannelReplyTest.kt | 45 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt index 3c4c78d2..41c4b37d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt @@ -1,12 +1,23 @@ package com.utun.chat.data -/** Снимок выбранного ответа: revision отличает повторный выбор того же сообщения. */ +/** + * Выбранный оригинал фиксируется до асинхронной отправки; PM не поддерживает этот reply-контекст. + * @property channelId ID канала оригинала; отправитель проверяет совпадение с каналом назначения. + * @property message Снимок оригинала с глобальным messageId; вложенные списки не копируются. + * @property revision Счётчик выбора в ViewModel: завершение старой отправки не снимает повторно выбранный ответ. + */ data class ChannelReply(val channelId: String, val message: Message, val revision: Long) +/** + * Переводит 64 строчных hex-символа в новый массив 32 байтов для JNI; порядок байтов совпадает со строкой. + * Пустая строка даёт пустой массив (JNI заполнит отсутствующую ссылку нулями); 64 нуля тоже допустимы при конвертации. + * Для выбора реального оригинала/редактирования нужен [validMessageId]. Неверный непустой формат — IllegalArgumentException. + */ fun messageIdBytes(id: String): ByteArray { if (id.isEmpty()) return byteArrayOf() require(id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' }) { "Invalid message ID" } return ByteArray(32) { id.substring(it * 2, it * 2 + 2).toInt(16).toByte() } } +/** Только реальный глобальный ID: 32 байта в строчном hex; пустая строка и нулевой ID не обозначают сообщение. */ fun validMessageId(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' } && id.any { it != '0' } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt index 877e3892..d83968a4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt @@ -1,9 +1,14 @@ package com.utun.chat.data -/** Адресат фиксируется до записи, выбора файла или фоновой подготовки. */ +/** + * Снимок адресата до записи, выбора файла и фоновой подготовки; смена экрана не меняет уже начатую отправку. + * @property id Десятичный ID канала/PM из модели, без префикса; класс не разбирает и не проверяет его. + */ sealed class ConversationTarget { abstract val id: String data class Channel(override val id: String) : ConversationTarget() + /** generation берётся вместе с ID беседы; после удаления старое поколение не может публиковать в заново открытую PM. */ data class Pm(override val id: String, val generation: Long) : ConversationTarget() + /** Target для общего voice/video API. Поколение PM не входит в строку и передаётся native отдельно. */ val nativeId: String get() = if (this is Pm) "dm:$id" else id } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt new file mode 100644 index 00000000..0ad5ebfd --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt @@ -0,0 +1,45 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +/** Контракт строкового глобального ID на границе Kotlin/JNI, включая отсутствие reply. */ +class ChannelReplyTest { + @Test fun everyByteValueKeepsItsBits() { + for (value in 0..255) { + val id = "%02x".format(value).repeat(32) + assertArrayEquals(ByteArray(32) { value.toByte() }, messageIdBytes(id)) + assertEquals(value != 0, validMessageId(id)) + } + } + + @Test fun byteOrderMatchesGlobalId() { + val id = "0123456789abcdef".repeat(4) + val expected = byteArrayOf(1, 0x23, 0x45, 0x67, 0x89.toByte(), 0xab.toByte(), 0xcd.toByte(), 0xef.toByte()) + assertArrayEquals(ByteArray(32) { expected[it % expected.size] }, messageIdBytes(id)) + } + + @Test fun absentReplyAndZeroIdAreDistinctEncodingsWithoutRealMessage() { + assertArrayEquals(byteArrayOf(), messageIdBytes("")) + assertArrayEquals(ByteArray(32), messageIdBytes("0".repeat(64))) + assertFalse(validMessageId("")) + assertFalse(validMessageId("0".repeat(64))) + assertTrue(validMessageId("0".repeat(63) + "1")) + } + + @Test fun malformedNonemptyIdsAreRejectedWithoutNormalization() { + for (id in listOf("1", "a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), + "a".repeat(64), " " + "a".repeat(63), "a".repeat(63) + "\n")) { + assertFalse(id, validMessageId(id)) + assertThrows(IllegalArgumentException::class.java) { messageIdBytes(id) } + } + } + + @Test fun returnedIdBytesBelongToCaller() { + val id = "ff".repeat(32) + val first = messageIdBytes(id) + val second = messageIdBytes(id) + first.fill(0) + assertArrayEquals(ByteArray(32) { -1 }, second) + } +} From 080c7a5fd52039e773b66b6fe992ec87a1b0bbcf Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:16:16 +0100 Subject: [PATCH 5/7] Clarify Android auto socket identity and port reuse contracts --- .../java/com/utun/chat/data/ServerEntry.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt index a137ba60..93d986c0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt @@ -2,12 +2,17 @@ package com.utun.chat.data import java.util.UUID -/** Протоколы автосокетов. Порты сохраняются отдельно для интерфейса, семейства IP и протокола. */ +/** Режим автосокетов для ConfigProvider; configValue сохраняется в настройках/INI, protocols задаёт порядок секций. */ enum class AutoSocketProtocols(val configValue: String, val protocols: List) { TCP("tcp", listOf("tcp")), UDP("udp", listOf("udp")), BOTH("tcp+udp", listOf("tcp", "udp")); + /** + * Для одного интерфейса и IP-литерала создаёт запись каждого выбранного протокола с новым UUID. + * Порт берётся из первой saved-записи с тем же socketKey (даже при смене IP), иначе выбирается из 2000..9999. + * saved не изменяется: неактивные интерфейсы/протоколы сохраняет вызывающий код. Сеть не вызывается, bind/проверки порта здесь нет. + */ fun createServers(ifName: String, bindIp: String, saved: List): List = protocols.map { protocol -> val key = Triple(ifName, bindIp.contains(":"), protocol) val port = saved.firstOrNull { it.socketKey == key }?.port ?: (2000..9999).random() @@ -15,13 +20,16 @@ enum class AutoSocketProtocols(val configValue: String, val protocols: List get() = Triple(ifName, bindIp.contains(":"), protocol) + /** Суффикс [server:...]; один сокет на интерфейс, семейство IP и протокол. */ val sectionName: String get() = "${ifName}_${if (bindIp.contains(":")) "v6" else "v4"}_$protocol" } From 7f0c0804920dafc8c867925da2ebfbfa21a51a0c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:18:55 +0100 Subject: [PATCH 6/7] Validate saved Android socket ports before reuse --- .../java/com/utun/chat/data/ConfigProvider.kt | 58 +++++-------- .../java/com/utun/chat/data/ServerConfig.kt | 50 ++++++++++++ .../com/utun/chat/data/ServerConfigTest.kt | 81 +++++++++++++++++++ 3 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 7a3aa0f6..7aba166f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -20,7 +20,6 @@ import java.net.InetAddress import java.net.NetworkInterface import java.io.File import java.io.IOException -import java.util.UUID private val Context.dataStore by preferencesDataStore("utun_config") @@ -633,54 +632,28 @@ class ConfigProvider(private val context: Context) { return "$dir/utun.cfg" } + /** Читает проверенные listen-секции; IP берёт с текущего интерфейса либо оставляет wildcard для отсутствующего. */ fun loadServersFromConfig(includeCommented: Boolean = false): List { val file = File(configFilePath()) if (!file.exists()) return emptyList() - data class RawSrv(val ifName: String, val family: String, val port: Int, val protocol: String) - val raw = mutableListOf() - try { - var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp" - var sectionOpen = false; var sectionCommented = false - for (line in file.readLines()) { - val trimmed = line.trim() - val commented = trimmed.startsWith("#") - val body = if (commented) trimmed.removePrefix("#").trim() else trimmed - if (body.startsWith("[") && body.endsWith("]")) { - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) - sectionOpen = false; sectionCommented = commented; addrPort = 0; proto = "tcp"; ifName = ""; family = "" - if (!body.startsWith("[server:")) continue - if (commented && !includeCommented) continue - val section = body.removeSurrounding("[", "]").removePrefix("server:").removeSuffix("_tcp").removeSuffix("_udp") - val lastUnderscore = section.lastIndexOf('_') - if (lastUnderscore > 0) { - ifName = section.substring(0, lastUnderscore) - family = section.substring(lastUnderscore + 1) - } - sectionOpen = true - } else if (sectionOpen && body.startsWith("addr=")) { - val addr = body.removePrefix("addr=") - val colonIdx = addr.lastIndexOf(':') - if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0 - } else if (sectionOpen && body.startsWith("transport=")) { - proto = if (body.removePrefix("transport=") == "udp") "udp" else "tcp" + val saved = try { + file.bufferedReader().use { reader -> + ServerConfig.parse(reader.lineSequence(), includeCommented) { warning -> + LogManager.addLog("WARN", "CFG", warning) } } - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "loadServersFromConfig failed path=${file.path}: ${e.message}") return emptyList() } - + if (saved.isEmpty()) return emptyList() val liveIps = getAvailableInterfaces() - return raw.map { rs -> - val isV6 = rs.family == "v6" + return saved.map { server -> + val isV6 = server.bindIp.contains(':') val realIp = liveIps.firstOrNull { (ifn, ip) -> - ifn == rs.ifName && (if (isV6) ip.contains(":") else !ip.contains(":")) + ifn == server.ifName && ip.contains(':') == isV6 }?.second - ServerEntry(UUID.randomUUID().toString(), rs.ifName, - realIp ?: if (isV6) "::" else "0.0.0.0", rs.protocol, rs.port) + server.copy(bindIp = realIp ?: server.bindIp) } } @@ -803,7 +776,7 @@ class ConfigProvider(private val context: Context) { sb.appendLine() } - /** Return all currently available network interface names (for dropdown) */ + /** Синхронный список (интерфейс, IP) для UI/конфига; ошибка и fallback на loopback видны в CFG-логе. */ fun getAvailableInterfaces(): List> = runBlocking { val list = mutableListOf>() try { @@ -821,8 +794,13 @@ class ConfigProvider(private val context: Context) { list.add(ni.name to ip) } } - } catch (_: Exception) {} - if (list.isEmpty()) list.add("lo" to "127.0.0.1") + } catch (e: Exception) { + LogManager.addLog("ERROR", "CFG", "getAvailableInterfaces failed: ${e.javaClass.simpleName}: ${e.message}") + } + if (list.isEmpty()) { + LogManager.addLog("WARN", "CFG", "getAvailableInterfaces: no usable addresses, using lo=127.0.0.1") + list.add("lo" to "127.0.0.1") + } list } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt new file mode 100644 index 00000000..fb36cc83 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt @@ -0,0 +1,50 @@ +package com.utun.chat.data + +import java.util.UUID + +/** Кодек listen-секций ConfigProvider, без файловой системы/Android. Полученные wildcard IP вызывающий код заменяет текущими. */ +internal object ServerConfig { + private val sectionName = Regex("(.+)_(v4|v6)(?:_(tcp|udp))?") + + /** + * Читает [server:interface_v4/v6_tcp/udp]; без суффикса протокола по умолчанию TCP. + * transport имеет приоритет над суффиксом; порт 1..65535. Остальные секции/поля не относятся к этому кодеку. + * includeCommented восстанавливает полностью закомментированные секции. Неверная секция пропускается с warn. + */ + fun parse(lines: Sequence, includeCommented: Boolean, warn: (String) -> Unit): List { + val result = mutableListOf() + val fields = mutableMapOf() + var name: String? = null + var sectionCommented = false + + fun finishSection() { + val current = name ?: return + val match = sectionName.matchEntire(current) + if (match == null) { warn("saved server '$current': invalid interface/family/protocol section name"); return } + val protocol = fields["transport"] ?: match.groupValues[3].ifEmpty { "tcp" } + if (protocol != "tcp" && protocol != "udp") { warn("saved server '$current': unsupported transport"); return } + val port = fields["addr"]?.substringAfterLast(':', "")?.toIntOrNull() + if (port == null || port !in 1..65535) { warn("saved server '$current': invalid listen port parsed=$port, expected 1..65535"); return } + val ip = if (match.groupValues[2] == "v6") "::" else "0.0.0.0" + result.add(ServerEntry(UUID.randomUUID().toString(), match.groupValues[1], ip, protocol, port)) + } + + for (line in lines) { + val trimmed = line.trim() + val commented = trimmed.startsWith('#') + val body = if (commented) trimmed.removePrefix("#").trim() else trimmed + if (body.startsWith('[') && body.endsWith(']')) { + finishSection() + fields.clear() + sectionCommented = commented + name = if (body.startsWith("[server:") && (!commented || includeCommented)) + body.removeSurrounding("[", "]").removePrefix("server:") else null + } else if (name != null && commented == sectionCommented) { + val equals = body.indexOf('=') + if (equals > 0) fields[body.substring(0, equals).trim()] = body.substring(equals + 1).trim() + } + } + finishSection() + return result + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt new file mode 100644 index 00000000..fa539eb0 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt @@ -0,0 +1,81 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +/** Сохранённые порты проверяются до повторного использования в автосокетах; ошибки сохраняют остальные секции. */ +class ServerConfigTest { + private fun section(name: String, port: String, transport: String = "tcp") = + "[server:$name]\naddr=0.0.0.0:$port\ntransport=$transport\n" + + @Test fun roundTripIdentityUsesInterfaceFamilyAndTransport() { + val config = section("wlan_0_v4_tcp", "2001") + section("wlan_0_v4_udp", "2002", "udp") + + "[server:wlan_0_v6_tcp]\naddr=[::]:2003\ntransport=tcp\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf(2001, 2002, 2003), servers.map { it.port }) + assertEquals(listOf("wlan_0_v4_tcp", "wlan_0_v4_udp", "wlan_0_v6_tcp"), servers.map { it.sectionName }) + assertEquals(listOf("0.0.0.0", "0.0.0.0", "::"), servers.map { it.bindIp }) + assertEquals(listOf(2001, 2002), AutoSocketProtocols.BOTH.createServers("wlan_0", "192.0.2.1", servers).map { it.port }) + } + + @Test fun invalidSavedPortsAreSkippedBeforeAutoReuse() { + for (port in listOf("0", "-1", "65536", "2147483648", "invalid", "")) { + val warnings = mutableListOf() + val config = section("wlan0_v4_tcp", port) + section("other_v4_tcp", "3456") + val servers = ServerConfig.parse(config.lineSequence(), false, warnings::add) + assertEquals(listOf("other"), servers.map { it.ifName }) + assertEquals(listOf(3456), servers.map { it.port }) + assertEquals(1, warnings.size) + assertTrue(warnings.single(), warnings.single().contains("wlan0_v4_tcp")) + val recovered = AutoSocketProtocols.TCP.createServers("wlan0", "192.0.2.1", servers).single() + assertTrue(recovered.port in 2000..9999) + } + } + + @Test fun bothPortBoundariesArePreserved() { + val config = section("first_v4_tcp", "1") + section("last_v4_udp", "65535", "udp") + assertEquals(listOf(1, 65535), ServerConfig.parse(config.lineSequence(), false) { fail(it) }.map { it.port }) + } + + @Test fun commentedSectionsAreOnlyRestoredWhenRequested() { + val inactive = section("offline_v6_udp", "4567", "udp").lineSequence().filter { it.isNotEmpty() }.joinToString("\n") { "#$it" } + val config = section("live_v4_tcp", "2345") + inactive + val active = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + val all = ServerConfig.parse(config.lineSequence(), true) { fail(it) } + assertEquals(listOf("live"), active.map { it.ifName }) + assertEquals(listOf("live", "offline"), all.map { it.ifName }) + assertEquals("::", all.last().bindIp) + assertEquals("udp", all.last().protocol) + } + + @Test fun commentedFieldsDoNotOverrideActiveFields() { + val config = section("wlan0_v4_tcp", "2345") + "#addr=0.0.0.0:9999\n#transport=udp\n" + for (includeCommented in listOf(false, true)) { + val server = ServerConfig.parse(config.lineSequence(), includeCommented) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + } + + @Test fun unknownSectionsDoNotLeakFieldsIntoServer() { + val config = "[global]\naddr=0.0.0.0:9999\n" + section("wlan0_v4_tcp", "2345") + "[debug]\ntransport=udp\n" + val server = ServerConfig.parse(config.lineSequence(), false) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + + @Test fun invalidSectionAndTransportAreDiagnosed() { + val warnings = mutableListOf() + val config = section("wlan0_unknown_tcp", "2345") + section("wlan0_v4_tcp", "2345", "unknown") + + "[server:missing_v4_tcp]\ntransport=tcp\n" + assertTrue(ServerConfig.parse(config.lineSequence(), false, warnings::add).isEmpty()) + assertEquals(3, warnings.size) + } + + @Test fun transportFieldWinsAndSuffixSuppliesDefault() { + val config = section("wlan0_v4_tcp", "2345", "udp") + "[server:wlan0_v6_udp]\naddr=[::]:3456\n" + + "[server:old_v4]\naddr=0.0.0.0:4567\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf("udp", "udp", "tcp"), servers.map { it.protocol }) + } +} From fb461cd56c4d7b28f43d4f01aa274a748f308e1c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 14:23:59 +0100 Subject: [PATCH 7/7] Keep merged message link range documentation concise --- doc/message_links.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/message_links.md b/doc/message_links.md index 276465c3..14d79ddf 100644 --- a/doc/message_links.md +++ b/doc/message_links.md @@ -22,7 +22,6 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`, где end исключён. Единицы обоих API — UTF-16: `tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`. -Конец диапазона Android не включён в ссылку; Qt возвращает начало и длину. Удаление внешней пунктуации занимает линейное время: баланс трёх видов скобок считается один раз, хвост сокращается по индексу без повторного копирования адреса. Порт веб-адреса пустой либо десятичный от 0 до 65535; знак перед числом недопустим.