From 79dc1de6fc4a371d48f677a2de574d250fc0d7b2 Mon Sep 17 00:00:00 2001 From: Alexander Hoffer Date: Mon, 20 Jul 2026 15:47:03 +0100 Subject: [PATCH] fix: preserve UTF-8 advert names --- docs/cli_commands.md | 2 +- src/helpers/AdvertDataHelpers.cpp | 14 ++--- src/helpers/UTF8Helpers.h | 56 ++++++++++++++++++++ test/test_utf8_helpers/test_utf8_helpers.cpp | 38 +++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 src/helpers/UTF8Helpers.h create mode 100644 test/test_utf8_helpers/test_utf8_helpers.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5598dba550..b618ae2bfe 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -305,7 +305,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** Varies by board -**Note:** Max length varies. If a location is set, the max length is 24 bytes; 32 otherwise. Emoji and unicode characters may take more than one byte. +**Note:** Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary. --- diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 0e05620ec2..998733ae04 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -1,4 +1,5 @@ #include +#include uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) { app_data[0] = _type; @@ -16,11 +17,12 @@ app_data[0] |= ADV_FEAT2_MASK; memcpy(&app_data[i], &_extra2, 2); i += 2; } - if (_name && *_name != 0) { - app_data[0] |= ADV_NAME_MASK; - const char* sp = _name; - while (*sp && i < MAX_ADVERT_DATA_SIZE) { - app_data[i++] = *sp++; + if (_name && *_name != 0) { + size_t name_len = mesh::validUtf8PrefixLength(_name, MAX_ADVERT_DATA_SIZE - i); + if (name_len > 0) { + app_data[0] |= ADV_NAME_MASK; + memcpy(&app_data[i], _name, name_len); + i += name_len; } } return i; @@ -84,4 +86,4 @@ void AdvertTimeHelper::formatRelativeTimeDiff(char dest[], int32_t seconds_from_ } } } -} \ No newline at end of file +} diff --git a/src/helpers/UTF8Helpers.h b/src/helpers/UTF8Helpers.h new file mode 100644 index 0000000000..e06cf4a62c --- /dev/null +++ b/src/helpers/UTF8Helpers.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace mesh { + +inline bool isUtf8Continuation(uint8_t byte) { + return (byte & 0xC0) == 0x80; +} + +inline size_t validUtf8PrefixLength(const char* text, size_t max_bytes) { + if (text == nullptr) return 0; + + size_t offset = 0; + while (text[offset] != '\0') { + const uint8_t first = static_cast(text[offset]); + size_t sequence_length = 0; + + if (first <= 0x7F) { + sequence_length = 1; + } else if (first >= 0xC2 && first <= 0xDF) { + sequence_length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequence_length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequence_length = 4; + } else { + break; + } + + if (offset + sequence_length > max_bytes) break; + + bool complete = true; + for (size_t i = 1; i < sequence_length; i++) { + if (text[offset + i] == '\0' || !isUtf8Continuation(static_cast(text[offset + i]))) { + complete = false; + break; + } + } + if (!complete) break; + + if (sequence_length == 3) { + const uint8_t second = static_cast(text[offset + 1]); + if ((first == 0xE0 && second < 0xA0) || (first == 0xED && second > 0x9F)) break; + } else if (sequence_length == 4) { + const uint8_t second = static_cast(text[offset + 1]); + if ((first == 0xF0 && second < 0x90) || (first == 0xF4 && second > 0x8F)) break; + } + + offset += sequence_length; + } + return offset; +} + +} // namespace mesh diff --git a/test/test_utf8_helpers/test_utf8_helpers.cpp b/test/test_utf8_helpers/test_utf8_helpers.cpp new file mode 100644 index 0000000000..ee95352207 --- /dev/null +++ b/test/test_utf8_helpers/test_utf8_helpers.cpp @@ -0,0 +1,38 @@ +#include + +#include + +TEST(UTF8Helpers, KeepsCompleteNameWithinLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(24u, mesh::validUtf8PrefixLength(name, 24)); +} + +TEST(UTF8Helpers, StopsBeforeCodePointCrossingLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(20u, mesh::validUtf8PrefixLength(name, 23)); +} + +TEST(UTF8Helpers, RejectsMalformedAndTruncatedSequences) { + const char overlong[] = {'A', static_cast(0xC0), static_cast(0xAF), 0}; + const char surrogate[] = {'A', static_cast(0xED), static_cast(0xA0), static_cast(0x80), 0}; + const char out_of_range[] = {'A', static_cast(0xF4), static_cast(0x90), static_cast(0x80), static_cast(0x80), 0}; + const char truncated[] = {'A', static_cast(0xF0), static_cast(0x9F), 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(overlong, sizeof(overlong))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(surrogate, sizeof(surrogate))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(out_of_range, sizeof(out_of_range))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(truncated, sizeof(truncated))); +} + +TEST(UTF8Helpers, RejectsUnexpectedContinuationByte) { + const char invalid[] = {'A', static_cast(0x80), 'B', 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(invalid, sizeof(invalid))); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}