From acc7998bac2856a39bcaf036e7e7d8fd2ed8c00f Mon Sep 17 00:00:00 2001 From: Bradley Bennett Date: Fri, 10 Apr 2026 11:25:50 -0400 Subject: [PATCH] GUACAMOLE-2237: Server-side terminal multiline paste fix. --- src/terminal/terminal.c | 77 +++++++++++- src/terminal/terminal/terminal.h | 22 ++++ src/terminal/tests/Makefile.am | 1 + src/terminal/tests/paste/normalize-newlines.c | 114 ++++++++++++++++++ 4 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/terminal/tests/paste/normalize-newlines.c diff --git a/src/terminal/terminal.c b/src/terminal/terminal.c index d6b4004cc6..48ae98326e 100644 --- a/src/terminal/terminal.c +++ b/src/terminal/terminal.c @@ -1555,6 +1555,79 @@ int guac_terminal_send_data(guac_terminal* term, const char* data, int length) { } +/** + * Sends clipboard contents as keyboard input, converting LF and CRLF to + * CR (and passing bare CR through as CR). Clipboard data is normalized + * on receipt so CRLF pairs are stored as LF, but bare CR can still be + * present. When sending the data as keyboard input, it must be CR, since + * CR represents the Enter key. LF is just a text newline and isn't + * reliably treated as Enter (e.g. on Windows). This also avoids CRLF + * being interpreted as two Enters (CRCR). + * + * This has to be done server-side. When the user pastes via right-click + * or middle-click, the browser only sends a mouse event; the server then + * pastes directly from its clipboard buffer. The client doesn't get a + * chance to intercept or normalize the content at that point. + * + * @param term + * The terminal whose clipboard contents should be sent. + * + * @return + * The number of bytes written to STDIN (zero if the clipboard was empty), + * or a negative value (if an error occurred preventing the + * data from being written). + */ +static int guac_terminal_send_clipboard(guac_terminal* term) { + + int length = term->clipboard->length; + + if (length <= 0) + return 0; + + char* buf = guac_mem_alloc(length); + + if (buf == NULL) + return -1; + + int written = guac_terminal_paste_normalize_newlines( + term->clipboard->buffer, length, buf); + + int ret = guac_terminal_send_data(term, buf, written); + guac_mem_free(buf); + return ret; + +} + +int guac_terminal_paste_normalize_newlines(const char* src, int length, + char* dest) { + + const char* end = src + length; + char* out = dest; + + while (src < end) { + + if (*src == '\r') { + *(out++) = '\r'; + src++; + + /* CRLF collapses to a single CR */ + if (src < end && *src == '\n') + src++; + } + else if (*src == '\n') { + *(out++) = '\r'; + src++; + } + else { + *(out++) = *(src++); + } + + } + + return out - dest; + +} + int guac_terminal_send_string(guac_terminal* term, const char* data) { /* Block all other sources of input if input is coming from a stream */ @@ -1827,7 +1900,7 @@ static int __guac_terminal_send_key(guac_terminal* term, int keysym, int pressed /* Ctrl+Shift+V or Cmd+v (mac style) shortcuts for paste */ if ((keysym == 'V' && term->mod_ctrl) || (keysym == 'v' && term->mod_meta)) - return guac_terminal_send_data(term, term->clipboard->buffer, term->clipboard->length); + return guac_terminal_send_clipboard(term); /* If Shift+Tab (Backtab), send the appropriate escape sequence */ if (term->mod_shift && keysym == 0xFF09) { @@ -2194,7 +2267,7 @@ static int __guac_terminal_send_mouse(guac_terminal* term, guac_user* user, /* Paste contents of clipboard on right or middle mouse button up */ if ((released_mask & GUAC_CLIENT_MOUSE_RIGHT) || (released_mask & GUAC_CLIENT_MOUSE_MIDDLE)) - return guac_terminal_send_data(term, term->clipboard->buffer, term->clipboard->length); + return guac_terminal_send_clipboard(term); /* If left mouse button was just released, stop selection */ if (released_mask & GUAC_CLIENT_MOUSE_LEFT) diff --git a/src/terminal/terminal/terminal.h b/src/terminal/terminal/terminal.h index dbb6be19fe..981c72202a 100644 --- a/src/terminal/terminal/terminal.h +++ b/src/terminal/terminal/terminal.h @@ -530,6 +530,28 @@ int guac_terminal_send_mouse(guac_terminal* term, guac_user* user, */ int guac_terminal_send_data(guac_terminal* term, const char* data, int length); +/** + * Rewrites clipboard bytes so each line terminator becomes a single CR. LF is + * replaced with CR, CRLF collapses to one CR, and bare CR passes through. All + * other bytes are copied verbatim. The output is never longer than the input, + * so the destination buffer only needs to be as large as the input. + * + * @param src + * The bytes to rewrite. + * + * @param length + * The number of bytes available at src. + * + * @param dest + * The destination buffer. Must be at least length bytes large. May not + * overlap src. + * + * @return + * The number of bytes written to dest. + */ +int guac_terminal_paste_normalize_newlines(const char* src, int length, + char* dest); + /** * Sends the given string as if typed by the user. If terminal input is * currently coming from a stream due to a prior call to diff --git a/src/terminal/tests/Makefile.am b/src/terminal/tests/Makefile.am index 93316594da..95798d5334 100644 --- a/src/terminal/tests/Makefile.am +++ b/src/terminal/tests/Makefile.am @@ -34,6 +34,7 @@ check_PROGRAMS = test_terminal TESTS = $(check_PROGRAMS) test_terminal_SOURCES = \ + paste/normalize-newlines.c \ selection-point/enclose-text.c \ selection-point/point-after.c \ selection-point/rounding.c diff --git a/src/terminal/tests/paste/normalize-newlines.c b/src/terminal/tests/paste/normalize-newlines.c new file mode 100644 index 0000000000..34fdb5086a --- /dev/null +++ b/src/terminal/tests/paste/normalize-newlines.c @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "terminal/terminal.h" + +#include +#include + +/** + * Runs guac_terminal_paste_normalize_newlines() against the given input and + * verifies both the returned length and the resulting bytes match expected. + */ +static void assert_normalized(const char* in, int in_len, + const char* expected, int expected_len) { + + char out[64]; + int written = guac_terminal_paste_normalize_newlines(in, in_len, out); + + CU_ASSERT_EQUAL(expected_len, written); + CU_ASSERT_EQUAL(0, memcmp(out, expected, expected_len)); + +} + +/** + * Verifies that bare LF is rewritten as CR. + */ +void test_paste__lf_to_cr() { + assert_normalized("a\nb", 3, "a\rb", 3); +} + +/** + * Verifies that CRLF pairs collapse to a single CR (not CRCR). + */ +void test_paste__crlf_to_single_cr() { + assert_normalized("a\r\nb", 4, "a\rb", 3); +} + +/** + * Verifies that bare CR passes through as CR. + */ +void test_paste__bare_cr_passthrough() { + assert_normalized("a\rb", 3, "a\rb", 3); +} + +/** + * Verifies that a mixed sequence of LF, CRLF, and bare CR each become exactly + * one CR. + */ +void test_paste__mixed_endings() { + assert_normalized("a\nb\r\nc\rd", 8, "a\rb\rc\rd", 7); +} + +/** + * Verifies that consecutive line endings each produce one CR. Catches + * cursor-advancement errors that would let one CRLF's LF be consumed by the + * next iteration. + */ +void test_paste__consecutive_endings() { + assert_normalized("\n\n", 2, "\r\r", 2); + assert_normalized("\r\n\r\n", 4, "\r\r", 2); + assert_normalized("\r\r", 2, "\r\r", 2); + assert_normalized("\n\r\n\r", 4, "\r\r\r", 3); +} + +/** + * Verifies that a trailing bare CR at end of input is emitted without reading + * past the buffer. + */ +void test_paste__trailing_cr() { + assert_normalized("abc\r", 4, "abc\r", 4); +} + +/** + * Verifies that an input with no line endings is copied verbatim. + */ +void test_paste__no_endings() { + assert_normalized("hello world", 11, "hello world", 11); +} + +/** + * Verifies that zero-length input writes nothing. + */ +void test_paste__empty() { + char out[1] = { 0x7F }; + int written = guac_terminal_paste_normalize_newlines("", 0, out); + CU_ASSERT_EQUAL(0, written); + CU_ASSERT_EQUAL((char) 0x7F, out[0]); +} + +/** + * Verifies that embedded NUL bytes are preserved (the function is byte-driven, + * not C-string driven). + */ +void test_paste__embedded_nul() { + const char in[] = { 'a', '\0', '\n', 'b' }; + const char expected[] = { 'a', '\0', '\r', 'b' }; + assert_normalized(in, sizeof(in), expected, sizeof(expected)); +}