diff --git a/include/neatvnc.h b/include/neatvnc.h index 2fcd318..593ee3d 100644 --- a/include/neatvnc.h +++ b/include/neatvnc.h @@ -343,7 +343,8 @@ void nvnc_set_normalised_pointer_fn(struct nvnc* self, void nvnc_set_new_client_fn(struct nvnc* self, nvnc_client_fn); /** - * Set a handler for clipboard text received from clients. + * Set a handler for clipboard text received from clients. The text is always + * delivered to the handler as UTF-8; Latin-1 cut text is converted. */ void nvnc_set_cut_text_fn(struct nvnc*, nvnc_cut_text_fn fn); @@ -781,7 +782,9 @@ struct nvnc_display* nvnc_desktop_layout_get_display( const struct nvnc_desktop_layout*, uint8_t display_index); /** - * Broadcast clipboard text to all connected clients. + * Broadcast clipboard text to all connected clients. The text must be UTF-8; + * it is sent as-is to extended-clipboard clients and converted to Latin-1 for + * other clients, where code points that do not fit are replaced with '?'. */ void nvnc_send_cut_text(struct nvnc*, const char* text, uint32_t len); diff --git a/include/utf8.h b/include/utf8.h new file mode 100644 index 0000000..7dd616d --- /dev/null +++ b/include/utf8.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Norbert Schultz + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE + * OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + */ + +#pragma once + +#include + +#define LATIN1_TO_UTF8_MAX_SIZE(x) ((size_t)(x) * 2 + 1) +#define UTF8_TO_LATIN1_MAX_SIZE(x) ((size_t)(x) + 1) + +/* NUL-terminates dst; returns the length excluding the terminator. */ +size_t latin1_to_utf8(char* dst, const char* src, size_t src_len); + +/* Code points above 0xff are replaced with '?'. NUL-terminates dst; returns + * the length excluding the terminator. + */ +size_t utf8_to_latin1(char* dst, const char* src, size_t src_len); diff --git a/meson.build b/meson.build index a2d2d5d..f29d96d 100644 --- a/meson.build +++ b/meson.build @@ -118,6 +118,7 @@ sources_dep = declare_dependency( 'src/parallel-deflate.c', 'src/compositor.c', 'src/region.c', + 'src/utf8.c', ] ) diff --git a/src/server.c b/src/server.c index 8c1d218..341b094 100644 --- a/src/server.c +++ b/src/server.c @@ -36,6 +36,7 @@ #include "transform-util.h" #include "type-macros.h" #include "server.h" +#include "utf8.h" #include #include @@ -1493,6 +1494,37 @@ static char* crlf_to_lf(const char* src, size_t len) return lf_buf; } +static void notify_cut_text_utf8(struct nvnc_client* client, const char* text, + uint32_t len) +{ + struct nvnc* server = client->server; + + if (!server->cut_text_fn) + return; + + server->cut_text_fn(client, text, len); +} + +static void notify_cut_text_latin1(struct nvnc_client* client, const char* text, + uint32_t len) +{ + struct nvnc* server = client->server; + + if (!server->cut_text_fn) + return; + + char* utf8 = malloc(LATIN1_TO_UTF8_MAX_SIZE(len)); + if (!utf8) { + nvnc_log(NVNC_LOG_ERROR, "OOM: %m"); + return; + } + + size_t utf8_len = latin1_to_utf8(utf8, text, len); + + server->cut_text_fn(client, utf8, utf8_len); + free(utf8); +} + static void process_client_ext_clipboard_provide(struct nvnc_client* client, unsigned char* zlib_data, size_t zlib_len) { @@ -1562,9 +1594,7 @@ static void process_client_ext_clipboard_provide(struct nvnc_client* client, } size_t converted_len = strlen(converted_buf); - nvnc_cut_text_fn fn = client->server->cut_text_fn; - if (fn) - fn(client, converted_buf, converted_len); + notify_cut_text_utf8(client, converted_buf, converted_len); free(converted_buf); } @@ -1680,9 +1710,7 @@ static int process_client_cut_text(struct nvnc_client* client) size_t msg_size = sizeof(*msg) + length; if (msg_size <= left_to_process) { - nvnc_cut_text_fn fn = client->server->cut_text_fn; - if (fn) - fn(client, msg->text, length); + notify_cut_text_latin1(client, msg->text, length); return msg_size; } @@ -1792,10 +1820,8 @@ static void process_big_cut_text(struct nvnc_client* client) (unsigned char*)client->cut_text.buffer, client->cut_text.length); } else { - nvnc_cut_text_fn fn = client->server->cut_text_fn; - if (fn) - fn(client, client->cut_text.buffer, - client->cut_text.length); + notify_cut_text_latin1(client, client->cut_text.buffer, + client->cut_text.length); } free(client->cut_text.buffer); @@ -1860,25 +1886,12 @@ static void send_cut_text_to_client(struct nvnc_client* client, stream_write(client->net_stream, text, len); } -EXPORT -void nvnc_send_cut_text(struct nvnc* server, const char* text, uint32_t len) +static void send_cut_text_utf8(struct nvnc* server, + const char* text, uint32_t len) { struct nvnc_client* client; - bool ext_clipboard_in_use = false; - LIST_FOREACH (client, &server->clients, link) { - if (client_has_encoding(client, RFB_ENCODING_EXTENDED_CLIPBOARD)) { - ext_clipboard_in_use = true; - break; - } - } - - if (ext_clipboard_in_use) { - ext_clipboard_save_provide_msg(server, text, len); - } else if (server->ext_clipboard_provide_msg.buffer) { - free(server->ext_clipboard_provide_msg.buffer); - server->ext_clipboard_provide_msg.buffer = NULL; - } + ext_clipboard_save_provide_msg(server, text, len); LIST_FOREACH (client, &server->clients, link) { if (client_has_encoding(client, RFB_ENCODING_EXTENDED_CLIPBOARD)) { @@ -1890,10 +1903,57 @@ void nvnc_send_cut_text(struct nvnc* server, const char* text, uint32_t len) send_ext_clipboard_provide(client); else if (client->ext_clipboard_caps & RFB_EXT_CLIPBOARD_ACTION_NOTIFY) send_ext_clipboard_notify(client); + } + } +} + +static void send_cut_text_latin1(struct nvnc* server, + const char* text, uint32_t len) +{ + struct nvnc_client* client; + + char* latin1_buf = malloc(UTF8_TO_LATIN1_MAX_SIZE(len)); + if (!latin1_buf) { + nvnc_log(NVNC_LOG_ERROR, "OOM: %m"); + return; + } + + size_t latin1_len = utf8_to_latin1(latin1_buf, text, len); + + LIST_FOREACH (client, &server->clients, link) { + if (!client_has_encoding(client, RFB_ENCODING_EXTENDED_CLIPBOARD)) { + send_cut_text_to_client(client, latin1_buf, latin1_len); + } + } + + free(latin1_buf); +} + +EXPORT +void nvnc_send_cut_text(struct nvnc* server, const char* text, uint32_t len) +{ + struct nvnc_client* client; + + bool latin1_in_use = false; + bool utf8_in_use = false; + + LIST_FOREACH (client, &server->clients, link) { + if (client_has_encoding(client, RFB_ENCODING_EXTENDED_CLIPBOARD)) { + utf8_in_use = true; } else { - send_cut_text_to_client(client, text, len); + latin1_in_use = true; } } + + if (utf8_in_use) { + send_cut_text_utf8(server, text, len); + } else { + free(server->ext_clipboard_provide_msg.buffer); + server->ext_clipboard_provide_msg.buffer = NULL; + } + + if (latin1_in_use) + send_cut_text_latin1(server, text, len); } static struct nvnc_desktop_layout* unpack_desktop_layout(uint16_t width, diff --git a/src/utf8.c b/src/utf8.c new file mode 100644 index 0000000..482cf5c --- /dev/null +++ b/src/utf8.c @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Norbert Schultz + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE + * OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + */ + +#include "utf8.h" +#include +#include + +size_t latin1_to_utf8(char* dst, const char* src, size_t src_len) +{ + size_t o = 0; + for (size_t i = 0; i < src_len; ++i) { + uint8_t c = src[i]; + if (c < 0x80) { + dst[o++] = c; + } else { + dst[o++] = 0xc0 | (c >> 6); + dst[o++] = 0x80 | (c & 0x3f); + } + } + + dst[o] = 0; + return o; +} + +size_t utf8_to_latin1(char* dst, const char* src, size_t src_len) +{ + size_t o = 0; + for (size_t i = 0; i < src_len;) { + uint8_t c = src[i]; + uint32_t cp; + uint32_t min_cp; + size_t n; + + if (c < 0x80) { + dst[o++] = c; + i++; + continue; + } else if ((c & 0xe0) == 0xc0) { + cp = c & 0x1f; + n = 2; + min_cp = 0x80; + } else if ((c & 0xf0) == 0xe0) { + cp = c & 0x0f; + n = 3; + min_cp = 0x800; + } else if ((c & 0xf8) == 0xf0) { + cp = c & 0x07; + n = 4; + min_cp = 0x10000; + } else { + /* Stray continuation byte or invalid lead byte. */ + dst[o++] = '?'; + i++; + continue; + } + + if (i + n > src_len) { + /* Truncated sequence at end of input. */ + dst[o++] = '?'; + break; + } + + bool valid = true; + for (size_t k = 1; k < n; ++k) { + uint8_t cc = src[i + k]; + if ((cc & 0xc0) != 0x80) { + valid = false; + break; + } + cp = (cp << 6) | (cc & 0x3f); + } + + if (!valid) { + /* Missing continuation byte; resync on the bad byte. */ + dst[o++] = '?'; + i++; + continue; + } + + /* Reject overlong encodings, UTF-16 surrogates and code points + * beyond the Unicode range. + */ + if (cp < min_cp || (cp >= 0xd800 && cp <= 0xdfff) || + cp > 0x10ffff) + dst[o++] = '?'; + else + dst[o++] = cp <= 0xff ? (char)cp : '?'; + + i += n; + } + + dst[o] = 0; + return o; +} diff --git a/test/meson.build b/test/meson.build index c22273f..011ee25 100644 --- a/test/meson.build +++ b/test/meson.build @@ -15,6 +15,12 @@ base64 = executable('base64', 'test-base64.c', ) test('base64', base64) +utf8 = executable('utf8', 'test-utf8.c', + include_directories: inc, + dependencies: dependencies +) +test('utf8', utf8) + if nettle.found() and python3.found() rfb_test_server = executable('rfb-test-server', 'rfb-test-server.c', include_directories: inc, diff --git a/test/test-utf8.c b/test/test-utf8.c new file mode 100644 index 0000000..4b6aa5f --- /dev/null +++ b/test/test-utf8.c @@ -0,0 +1,92 @@ +#include +#include +#include + +#include "utf8.h" + +static void test_single_utf8_to_latin1(const char* input, const char* expected) +{ + size_t inlen = strlen(input); + char* latin1 = malloc(UTF8_TO_LATIN1_MAX_SIZE(inlen)); + assert(latin1); + + size_t outlen = utf8_to_latin1(latin1, input, inlen); + assert(outlen == strlen(latin1)); + assert(strcmp(latin1, expected) == 0); + free(latin1); +} + +static void test_latin_to_utf8_and_back(const char* input, const char* expected) +{ + size_t inlen = strlen(input); + char* utf8 = malloc(LATIN1_TO_UTF8_MAX_SIZE(inlen)); + assert(utf8); + + size_t outlen = latin1_to_utf8(utf8, input, inlen); + assert(outlen == strlen(utf8)); + assert(strcmp(utf8, expected) == 0); + free(utf8); + + test_single_utf8_to_latin1(expected, input); +} + +static void test_utf8_to_latin1_n(const char* in, size_t inlen, + const char* exp, size_t explen) +{ + char* latin1 = malloc(UTF8_TO_LATIN1_MAX_SIZE(inlen)); + assert(latin1); + + size_t outlen = utf8_to_latin1(latin1, in, inlen); + assert(outlen == explen); + assert(memcmp(latin1, exp, explen) == 0); + free(latin1); +} + +static void test_malicious_utf8(void) +{ + /* Stray continuation bytes. */ + test_utf8_to_latin1_n("\x80", 1, "?", 1); + test_utf8_to_latin1_n("\x80\x80\xbf", 3, "???", 3); + + /* Truncated sequences at end of input. */ + test_utf8_to_latin1_n("\xc4", 1, "?", 1); + test_utf8_to_latin1_n("\xe0\xa0", 2, "?", 1); + test_utf8_to_latin1_n("\xf0\x90\x80", 3, "?", 1); + + /* Missing continuation byte; decoder must resync and keep the next + * byte instead of swallowing it. + */ + test_utf8_to_latin1_n("\xc4""A", 2, "?A", 2); + test_utf8_to_latin1_n("\xc4\xc4\x80", 3, "??", 2); + + /* Overlong encodings must not smuggle a byte through. */ + test_utf8_to_latin1_n("\xc0\x80", 2, "?", 1); + test_utf8_to_latin1_n("\xc0\xaf", 2, "?", 1); + test_utf8_to_latin1_n("\xe0\x80\x80", 3, "?", 1); + test_utf8_to_latin1_n("\xf0\x80\x80\x80", 4, "?", 1); + + /* UTF-16 surrogate (U+D800) and code point beyond U+10FFFF. */ + test_utf8_to_latin1_n("\xed\xa0\x80", 3, "?", 1); + test_utf8_to_latin1_n("\xf7\xbf\xbf\xbf", 4, "?", 1); + + /* Obsolete 5- and 6-byte lead bytes. */ + test_utf8_to_latin1_n("\xf8\x80\x80\x80\x80", 5, "?????", 5); + test_utf8_to_latin1_n("\xfc\x80\x80\x80\x80\x80", 6, "??????", 6); + + /* Valid characters around invalid input still decode correctly. */ + test_utf8_to_latin1_n("a\xc3\xa9""b", 4, "a\xe9""b", 3); +} + +int main() +{ + test_latin_to_utf8_and_back("", ""); + test_latin_to_utf8_and_back("abc", "abc"); + test_latin_to_utf8_and_back("ABC \xc4\xd6\xdc\xe4\xf6\xfc\xdf", + "ABC ÄÖÜäöüß"); + + test_single_utf8_to_latin1("Hello €!", "Hello ?!"); + + test_malicious_utf8(); + + return 0; +}