diff --git a/Makefile b/Makefile index d5ecfbc..1d18035 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later TOOLS = host-run host-multiview host-shell host-path host-reveal host-notify host-edit host-clip host-info host-download host-env -TEST_BINS = tests/test_host_common.out tests/test_host_command_builders.out tests/test_host_edit_command.out tests/test_host_download_command.out +TEST_BINS = tests/test_host_common.out tests/test_host_command_builders.out tests/test_host_edit_command.out tests/test_host_download_command.out tests/test_host_terminal_filter.out TEST_SCRIPTS = tests/test_package_layout.sh tests/test_ahi_driver_source.sh TESTS = $(TEST_BINS) $(TEST_SCRIPTS) COMMON_HEADERS = src/host_common.h src/host_path.h src/host_capture.h src/host_base64.h src/host_clip_command.h src/host_download_command.h src/host_edit_command.h src/host_env_command.h src/host_info_command.h src/host_notify_command.h src/host_powershell.h src/host_reveal_command.h src/host_shell_command.h src/uae_pragmas.h @@ -74,7 +74,7 @@ host-run: src/host-run.c $(COMMON_HEADERS) host-multiview: src/host-multiview.c $(COMMON_HEADERS) $(CC) $(CFLAGS) $(VERFLAGS) $(INCLUDES) src/host-multiview.c -o $@ -host-shell: src/host-shell.c $(COMMON_HEADERS) +host-shell: src/host-shell.c $(COMMON_HEADERS) src/host_terminal_filter.h $(CC) $(CFLAGS) $(VERFLAGS) $(INCLUDES) src/host-shell.c -o $@ host-path: src/host-path.c $(COMMON_HEADERS) @@ -113,6 +113,9 @@ tests/test_host_edit_command.out: tests/test_host_edit_command.c src/host_edit_c tests/test_host_download_command.out: tests/test_host_download_command.c src/host_download_command.h src/host_base64.h src/host_common.h $(HOST_CC) $(HOST_NATIVE_FLAGS) $(HOST_CFLAGS) tests/test_host_download_command.c -o $@ +tests/test_host_terminal_filter.out: tests/test_host_terminal_filter.c src/host_terminal_filter.h + $(HOST_CC) $(HOST_NATIVE_FLAGS) $(HOST_CFLAGS) tests/test_host_terminal_filter.c -o $@ + debug: CFLAGS += -DDEBUG -g debug: clean all diff --git a/src/host-shell.c b/src/host-shell.c index 9044962..8d93473 100644 --- a/src/host-shell.c +++ b/src/host-shell.c @@ -12,6 +12,7 @@ #include "host_capture.h" #include "host_common.h" #include "host_shell_command.h" +#include "host_terminal_filter.h" #define OUTBUFSIZE 4095 @@ -128,8 +129,8 @@ int main(int argc, char *argv[]) BPTR in = 0; BPTR out = 0; long handle = 0; - BOOL esc_pending = FALSE; BOOL raw_mode = FALSE; + struct host_terminal_filter terminal_filter = { HOST_TERMINAL_TEXT, 0 }; long actual; ULONG status; int status_supported = 0; @@ -222,30 +223,13 @@ int main(int argc, char *argv[]) actual = HostShell_Read(handle, (UBYTE *)buffer, sizeof(buffer) - 2); if (actual > 0) { - int outptr = 0; - for (int i = 0; i < actual; i++) { - unsigned char c = (unsigned char)buffer[i]; - if (esc_pending) { - if (c == 0x5B) { // '[' - outbuf[outptr++] = 0x9B; // CSI - } else { - outbuf[outptr++] = 0x1B; // Original ESC - outbuf[outptr++] = c; - } - esc_pending = FALSE; - } else { - if (c == 0x1B) { - esc_pending = TRUE; - } else { - outbuf[outptr++] = c; - } - } - - // Safety check for outbuf overflow (should rarely happen given the math) - if (outptr >= OUTBUFSIZE) { - Write(out, outbuf, outptr); - outptr = 0; - } + int outptr = host_terminal_filter_process(&terminal_filter, + (const unsigned char *)buffer, actual, + (unsigned char *)outbuf, sizeof(outbuf)); + if (outptr < 0) { + printf("Failed to translate host terminal output.\n"); + return_code = HOST_RETURN_ERROR; + goto cleanup; } if (outptr > 0) { Write(out, outbuf, outptr); @@ -318,9 +302,12 @@ int main(int argc, char *argv[]) } cleanup: - if (esc_pending && out != 0) { - outbuf[0] = 0x1B; - Write(out, outbuf, 1); + if (out != 0) { + int outptr = host_terminal_filter_finish(&terminal_filter, + (unsigned char *)outbuf, sizeof(outbuf)); + if (outptr > 0) { + Write(out, outbuf, outptr); + } } if (handle != 0) { HostShell_Close(handle); diff --git a/src/host_terminal_filter.h b/src/host_terminal_filter.h new file mode 100644 index 0000000..6a0e087 --- /dev/null +++ b/src/host_terminal_filter.h @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2020-2026 Dimitris Panokostas + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef HOST_TERMINAL_FILTER_H +#define HOST_TERMINAL_FILTER_H + +#define HOST_TERMINAL_TEXT 0 +#define HOST_TERMINAL_ESCAPE 1 +#define HOST_TERMINAL_OSC 2 +#define HOST_TERMINAL_OSC_ESCAPE 3 + +struct host_terminal_filter +{ + int state; + int utf8_remaining; +}; + +static inline int host_terminal_filter_utf8_continuations(unsigned char value) +{ + if (value >= 0xC2 && value <= 0xDF) { + return 1; + } + if (value >= 0xE0 && value <= 0xEF) { + return 2; + } + if (value >= 0xF0 && value <= 0xF4) { + return 3; + } + return 0; +} + +static inline int host_terminal_filter_put(unsigned char *output, int output_size, + int *output_len, unsigned char value) +{ + if (*output_len >= output_size) { + return 0; + } + + output[(*output_len)++] = value; + return 1; +} + +/* + * Convert host ANSI CSI sequences to the Amiga console's single-byte CSI and + * discard OSC sequences, which the Amiga console does not support. The state + * is retained so escape sequences may span HostShell_Read() calls. + * + * The output can be at most input_len + 1 bytes when a pending ESC from the + * previous call turns out to be literal. + */ +static inline int host_terminal_filter_process(struct host_terminal_filter *filter, + const unsigned char *input, int input_len, + unsigned char *output, int output_size) +{ + int output_len = 0; + + if (filter == NULL || input == NULL || input_len < 0 || + output == NULL || output_size < 0) { + return -1; + } + + for (int i = 0; i < input_len; i++) { + unsigned char c = input[i]; + + switch (filter->state) { + case HOST_TERMINAL_TEXT: + if (filter->utf8_remaining > 0 && c >= 0x80 && c <= 0xBF) { + filter->utf8_remaining--; + if (!host_terminal_filter_put(output, output_size, &output_len, c)) { + return -1; + } + } else { + filter->utf8_remaining = 0; + if (c == 0x1B) { + filter->state = HOST_TERMINAL_ESCAPE; + } else if (c == 0x9D) { + filter->state = HOST_TERMINAL_OSC; + } else { + filter->utf8_remaining = host_terminal_filter_utf8_continuations(c); + if (!host_terminal_filter_put(output, output_size, &output_len, c)) { + return -1; + } + } + } + break; + + case HOST_TERMINAL_ESCAPE: + if (c == '[') { + if (!host_terminal_filter_put(output, output_size, &output_len, 0x9B)) { + return -1; + } + filter->state = HOST_TERMINAL_TEXT; + } else if (c == ']') { + filter->state = HOST_TERMINAL_OSC; + filter->utf8_remaining = 0; + } else { + if (!host_terminal_filter_put(output, output_size, &output_len, 0x1B) || + !host_terminal_filter_put(output, output_size, &output_len, c)) { + return -1; + } + filter->state = HOST_TERMINAL_TEXT; + filter->utf8_remaining = host_terminal_filter_utf8_continuations(c); + } + break; + + case HOST_TERMINAL_OSC: + if (filter->utf8_remaining > 0 && c >= 0x80 && c <= 0xBF) { + filter->utf8_remaining--; + } else if (c == 0x07 || c == 0x9C) { + filter->state = HOST_TERMINAL_TEXT; + filter->utf8_remaining = 0; + } else if (c == 0x1B) { + filter->state = HOST_TERMINAL_OSC_ESCAPE; + filter->utf8_remaining = 0; + } else { + filter->utf8_remaining = host_terminal_filter_utf8_continuations(c); + } + break; + + case HOST_TERMINAL_OSC_ESCAPE: + if (c == '\\' || c == 0x07 || c == 0x9C) { + filter->state = HOST_TERMINAL_TEXT; + filter->utf8_remaining = 0; + } else if (c != 0x1B) { + filter->state = HOST_TERMINAL_OSC; + filter->utf8_remaining = host_terminal_filter_utf8_continuations(c); + } + break; + + default: + return -1; + } + } + + return output_len; +} + +static inline int host_terminal_filter_finish(struct host_terminal_filter *filter, + unsigned char *output, int output_size) +{ + int output_len = 0; + + if (filter == NULL || output == NULL || output_size < 0) { + return -1; + } + + if (filter->state == HOST_TERMINAL_ESCAPE && + !host_terminal_filter_put(output, output_size, &output_len, 0x1B)) { + return -1; + } + + filter->state = HOST_TERMINAL_TEXT; + filter->utf8_remaining = 0; + return output_len; +} + +#endif diff --git a/tests/test_host_terminal_filter.c b/tests/test_host_terminal_filter.c new file mode 100644 index 0000000..b9ccfdd --- /dev/null +++ b/tests/test_host_terminal_filter.c @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2020-2026 Dimitris Panokostas + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include +#include + +#include "host_terminal_filter.h" + +static void require(int condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "%s\n", message); + exit(1); + } +} + +static int process_chunks(const unsigned char *input, int input_len, int split, + unsigned char *output, int output_size) +{ + struct host_terminal_filter filter = { HOST_TERMINAL_TEXT, 0 }; + int first; + int second; + int tail; + + first = host_terminal_filter_process(&filter, input, split, output, output_size); + require(first >= 0, "first terminal-filter chunk should fit"); + + second = host_terminal_filter_process(&filter, input + split, input_len - split, + output + first, output_size - first); + require(second >= 0, "second terminal-filter chunk should fit"); + + tail = host_terminal_filter_finish(&filter, output + first + second, + output_size - first - second); + require(tail >= 0, "terminal-filter tail should fit"); + return first + second + tail; +} + +static void require_filtered_at_every_split(const unsigned char *input, int input_len, + const unsigned char *expected, int expected_len, + const char *message) +{ + unsigned char output[512]; + + for (int split = 0; split <= input_len; split++) { + int output_len; + + memset(output, 0, sizeof(output)); + output_len = process_chunks(input, input_len, split, output, sizeof(output)); + if (output_len != expected_len || memcmp(output, expected, expected_len) != 0) { + fprintf(stderr, "%s at split %d\n", message, split); + exit(1); + } + } +} + +static void test_plain_text(void) +{ + static const unsigned char input[] = "plain text\r\n"; + + require_filtered_at_every_split(input, sizeof(input) - 1, input, sizeof(input) - 1, + "plain text should be unchanged"); +} + +static void test_csi_conversion(void) +{ + static const unsigned char input[] = "\x1B[31mred\x1B[0m"; + static const unsigned char expected[] = "\233" "31mred" "\233" "0m"; + + require_filtered_at_every_split(input, sizeof(input) - 1, + expected, sizeof(expected) - 1, + "ANSI CSI should become Amiga CSI"); +} + +static void test_osc_st_filtering(void) +{ + static const unsigned char input[] = + "before\x1B]3008;start=01234567;machineid=abcdef;type=shell;cwd=/home/user\x1B\\after"; + static const unsigned char expected[] = "beforeafter"; + + require_filtered_at_every_split(input, sizeof(input) - 1, + expected, sizeof(expected) - 1, + "OSC terminated by ST should be discarded"); +} + +static void test_osc_bel_filtering(void) +{ + static const unsigned char input[] = "left\x1B]0;window title\x07right"; + static const unsigned char expected[] = "leftright"; + + require_filtered_at_every_split(input, sizeof(input) - 1, + expected, sizeof(expected) - 1, + "OSC terminated by BEL should be discarded"); +} + +static void test_osc_c1_st_filtering(void) +{ + static const unsigned char input[] = "left\x1B]0;window title\x9C" "right"; + static const unsigned char expected[] = "leftright"; + + require_filtered_at_every_split(input, sizeof(input) - 1, + expected, sizeof(expected) - 1, + "OSC terminated by C1 ST should be discarded"); +} + +static void test_c1_osc_filtering(void) +{ + static const unsigned char st_input[] = + "left\x9D" "0;window title\x9C" "right"; + static const unsigned char bel_input[] = + "left\x9D" "0;window title\x07right"; + static const unsigned char expected[] = "leftright"; + + require_filtered_at_every_split(st_input, sizeof(st_input) - 1, + expected, sizeof(expected) - 1, + "C1 OSC terminated by C1 ST should be discarded"); + require_filtered_at_every_split(bel_input, sizeof(bel_input) - 1, + expected, sizeof(expected) - 1, + "C1 OSC terminated by BEL should be discarded"); +} + +static void test_utf8_output(void) +{ + static const unsigned char input[] = + "UTF-8 quotes: \xE2\x80\x9C" "left\xE2\x80\x9D" " right"; + + require_filtered_at_every_split(input, sizeof(input) - 1, input, sizeof(input) - 1, + "UTF-8 continuation bytes should be unchanged"); +} + +static void test_utf8_osc_payload(void) +{ + static const unsigned char input[] = + "before\x1B]0;UTF-8 \xE2\x80\x9C" "title\xE2\x80\x9D\x1B\\after"; + static const unsigned char expected[] = "beforeafter"; + + require_filtered_at_every_split(input, sizeof(input) - 1, + expected, sizeof(expected) - 1, + "UTF-8 OSC payload should remain filtered"); +} + +static void test_incomplete_sequences(void) +{ + static const unsigned char dangling_escape[] = "text\x1B"; + static const unsigned char dangling_escape_expected[] = "text\x1B"; + static const unsigned char incomplete_osc[] = "text\x1B]3008;start=unfinished"; + static const unsigned char incomplete_osc_expected[] = "text"; + + require_filtered_at_every_split(dangling_escape, sizeof(dangling_escape) - 1, + dangling_escape_expected, + sizeof(dangling_escape_expected) - 1, + "a dangling non-OSC escape should be preserved"); + require_filtered_at_every_split(incomplete_osc, sizeof(incomplete_osc) - 1, + incomplete_osc_expected, + sizeof(incomplete_osc_expected) - 1, + "an incomplete OSC should be discarded"); +} + +static void test_unknown_escape(void) +{ + static const unsigned char input[] = "a\x1BPb"; + + require_filtered_at_every_split(input, sizeof(input) - 1, input, sizeof(input) - 1, + "an unknown escape should be preserved"); +} + +int main(void) +{ + test_plain_text(); + test_csi_conversion(); + test_osc_st_filtering(); + test_osc_bel_filtering(); + test_osc_c1_st_filtering(); + test_c1_osc_filtering(); + test_utf8_output(); + test_utf8_osc_payload(); + test_incomplete_sequences(); + test_unknown_escape(); + return 0; +}