Line data Source code
1 : /**
2 : * @file tests/unit/test_tui_screen.c
3 : * @brief Unit tests for the TUI double-buffered screen (US-11 v2).
4 : */
5 :
6 : #include "test_helpers.h"
7 : #include "tui/screen.h"
8 : #include "platform/terminal.h"
9 :
10 : #include <locale.h>
11 : #include <stdio.h>
12 : #include <stdlib.h>
13 : #include <string.h>
14 :
15 : /* Capture screen output into a heap-backed memstream. */
16 : typedef struct {
17 : FILE *stream;
18 : char *buf;
19 : size_t len;
20 : } Sink;
21 :
22 10 : static void sink_open(Sink *s) {
23 10 : s->buf = NULL;
24 10 : s->len = 0;
25 10 : s->stream = open_memstream(&s->buf, &s->len);
26 10 : }
27 :
28 : /* Flush the memstream so buf/len reflect everything written so far. */
29 13 : static void sink_flush(Sink *s) { fflush(s->stream); }
30 :
31 10 : static void sink_close(Sink *s) {
32 10 : if (s->stream) { fclose(s->stream); s->stream = NULL; }
33 10 : free(s->buf); s->buf = NULL; s->len = 0;
34 10 : }
35 :
36 9 : static int sink_contains(const Sink *s, const char *needle) {
37 9 : if (!s->buf || !needle) return 0;
38 9 : return memmem(s->buf, s->len, needle, strlen(needle)) != NULL;
39 : }
40 :
41 : /* --- Tests --- */
42 :
43 1 : static void test_init_allocates_and_free_releases(void) {
44 : Screen s;
45 1 : ASSERT(screen_init(&s, 10, 40) == 0, "init should succeed");
46 1 : ASSERT(s.front != NULL, "front grid allocated");
47 1 : ASSERT(s.back != NULL, "back grid allocated");
48 1 : ASSERT(s.rows == 10 && s.cols == 40, "dims recorded");
49 1 : ASSERT(s.out == stdout, "out defaults to stdout");
50 1 : screen_free(&s);
51 1 : ASSERT(s.front == NULL, "front cleared on free");
52 1 : ASSERT(s.back == NULL, "back cleared on free");
53 : }
54 :
55 1 : static void test_init_rejects_bad_dims(void) {
56 : Screen s;
57 1 : ASSERT(screen_init(&s, 0, 40) != 0, "rows=0 rejected");
58 1 : ASSERT(screen_init(&s, 10, 0) != 0, "cols=0 rejected");
59 1 : ASSERT(screen_init(NULL, 10, 40) != 0, "null screen rejected");
60 : }
61 :
62 1 : static void test_put_str_ascii_lands_in_back(void) {
63 1 : Screen s; ASSERT(screen_init(&s, 4, 20) == 0, "init");
64 1 : int w = screen_put_str(&s, 1, 2, "hello", SCREEN_ATTR_NORMAL);
65 1 : ASSERT(w == 5, "hello uses 5 columns");
66 1 : ASSERT(s.back[1 * 20 + 2].cp == 'h', "h at (1,2)");
67 1 : ASSERT(s.back[1 * 20 + 6].cp == 'o', "o at (1,6)");
68 1 : ASSERT(s.back[1 * 20 + 7].cp == ' ', "untouched cells remain blank");
69 1 : screen_free(&s);
70 : }
71 :
72 1 : static void test_put_str_clips_at_right_edge(void) {
73 1 : Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
74 1 : int w = screen_put_str(&s, 0, 5, "abcdef", SCREEN_ATTR_NORMAL);
75 1 : ASSERT(w == 3, "clipped to 3 columns (5..7)");
76 1 : ASSERT(s.back[5].cp == 'a', "a at (0,5)");
77 1 : ASSERT(s.back[7].cp == 'c', "c at (0,7)");
78 1 : screen_free(&s);
79 : }
80 :
81 1 : static void test_put_str_wide_char_occupies_two_cells(void) {
82 1 : setlocale(LC_CTYPE, "en_US.UTF-8");
83 1 : Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
84 : /* 日 is U+65E5, encoded in UTF-8 as 0xE6 0x97 0xA5, wcwidth==2. */
85 1 : int w = screen_put_str(&s, 0, 0, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
86 1 : if (w == 0) { screen_free(&s); return; /* locale unsupported on host */ }
87 1 : ASSERT(w == 2, "wide char consumes 2 columns");
88 1 : ASSERT(s.back[0].cp == 0x65E5, "lead holds codepoint");
89 1 : ASSERT(s.back[0].width == 2, "lead width is 2");
90 1 : ASSERT(s.back[1].width == 0, "trailer width is 0");
91 1 : screen_free(&s);
92 : }
93 :
94 1 : static void test_put_str_wide_char_clipped_when_one_cell_left(void) {
95 1 : setlocale(LC_CTYPE, "en_US.UTF-8");
96 1 : Screen s; ASSERT(screen_init(&s, 1, 3) == 0, "init");
97 1 : int pre = screen_put_str(&s, 0, 0, "ab", SCREEN_ATTR_NORMAL);
98 1 : ASSERT(pre == 2, "pre-fill");
99 1 : int w = screen_put_str(&s, 0, 2, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
100 1 : if (w == 2) {
101 : /* Host has no UTF-8 locale — glibc treated cp as narrow. Skip check. */
102 0 : screen_free(&s); return;
103 : }
104 1 : ASSERT(w == 0, "wide char does not fit; nothing written");
105 1 : ASSERT(s.back[2].cp == ' ', "cell remains blank");
106 1 : screen_free(&s);
107 : }
108 :
109 1 : static void test_put_str_rejects_out_of_range(void) {
110 1 : Screen s; ASSERT(screen_init(&s, 3, 10) == 0, "init");
111 1 : ASSERT(screen_put_str(&s, -1, 0, "x", 0) == 0, "negative row");
112 1 : ASSERT(screen_put_str(&s, 3, 0, "x", 0) == 0, "row == rows");
113 1 : ASSERT(screen_put_str(&s, 0, 10, "x", 0) == 0, "col == cols");
114 1 : ASSERT(screen_put_str(&s, 0, -1, "x", 0) == 0, "negative col");
115 1 : screen_free(&s);
116 : }
117 :
118 1 : static void test_clear_back_resets_every_cell(void) {
119 1 : Screen s; ASSERT(screen_init(&s, 2, 6) == 0, "init");
120 1 : screen_put_str(&s, 0, 0, "hey", SCREEN_ATTR_BOLD);
121 1 : screen_clear_back(&s);
122 13 : for (int i = 0; i < 12; i++) {
123 12 : ASSERT(s.back[i].cp == ' ', "cell blank");
124 12 : ASSERT(s.back[i].attrs == 0, "attrs reset");
125 : }
126 1 : screen_free(&s);
127 : }
128 :
129 1 : static void test_fill_writes_attrs(void) {
130 1 : Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
131 1 : screen_fill(&s, 1, 2, 4, SCREEN_ATTR_REVERSE);
132 1 : ASSERT(s.back[1 * 8 + 1].attrs == 0, "cell before fill untouched");
133 1 : ASSERT(s.back[1 * 8 + 2].attrs == SCREEN_ATTR_REVERSE, "fill cell 0");
134 1 : ASSERT(s.back[1 * 8 + 5].attrs == SCREEN_ATTR_REVERSE, "fill cell 3");
135 1 : ASSERT(s.back[1 * 8 + 6].attrs == 0, "cell after fill untouched");
136 1 : screen_free(&s);
137 : }
138 :
139 1 : static void test_fill_clips_right_edge(void) {
140 1 : Screen s; ASSERT(screen_init(&s, 1, 6) == 0, "init");
141 1 : screen_fill(&s, 0, 4, 100, SCREEN_ATTR_BOLD);
142 1 : ASSERT(s.back[4].attrs == SCREEN_ATTR_BOLD, "col 4");
143 1 : ASSERT(s.back[5].attrs == SCREEN_ATTR_BOLD, "col 5");
144 : /* nothing at col 6/7 — no buffer overflow */
145 1 : screen_free(&s);
146 : }
147 :
148 1 : static void test_flip_first_time_emits_everything(void) {
149 1 : Sink sink; sink_open(&sink);
150 1 : Screen s; ASSERT(screen_init(&s, 2, 10) == 0, "init");
151 1 : s.out = sink.stream;
152 1 : screen_put_str(&s, 0, 0, "hi", SCREEN_ATTR_NORMAL);
153 1 : size_t n = screen_flip(&s);
154 1 : sink_flush(&sink);
155 1 : ASSERT(n > 0, "first flip emits bytes");
156 1 : ASSERT(sink_contains(&sink, "hi"), "output contains 'hi'");
157 1 : ASSERT(sink_contains(&sink, "\033[1;1H"), "cursor to (1,1)");
158 1 : screen_free(&s);
159 1 : sink_close(&sink);
160 : }
161 :
162 1 : static void test_flip_second_time_identical_emits_nothing(void) {
163 1 : Sink sink; sink_open(&sink);
164 1 : Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
165 1 : s.out = sink.stream;
166 1 : screen_put_str(&s, 0, 0, "hi", 0);
167 1 : screen_flip(&s);
168 1 : sink_flush(&sink);
169 1 : size_t baseline = sink.len;
170 1 : screen_put_str(&s, 0, 0, "hi", 0); /* same content */
171 1 : size_t n = screen_flip(&s);
172 1 : sink_flush(&sink);
173 1 : ASSERT(n == 0, "idempotent flip emits 0 bytes");
174 1 : ASSERT(sink.len == baseline, "sink unchanged");
175 1 : screen_free(&s);
176 1 : sink_close(&sink);
177 : }
178 :
179 1 : static void test_flip_emits_only_changed_cells(void) {
180 1 : Sink sink; sink_open(&sink);
181 1 : Screen s; ASSERT(screen_init(&s, 2, 10) == 0, "init");
182 1 : s.out = sink.stream;
183 1 : screen_put_str(&s, 0, 0, "abc", 0);
184 1 : screen_put_str(&s, 1, 0, "xyz", 0);
185 1 : screen_flip(&s);
186 1 : sink_flush(&sink);
187 1 : size_t baseline = sink.len;
188 :
189 : /* Change only row 1, column 1 — 'y' → 'Y'. */
190 1 : screen_put_str(&s, 1, 1, "Y", 0);
191 1 : screen_flip(&s);
192 1 : sink_flush(&sink);
193 :
194 1 : size_t diff_bytes = sink.len - baseline;
195 1 : ASSERT(diff_bytes > 0, "change emits bytes");
196 : /* Should not re-emit 'abc' at all. */
197 1 : const char *delta = sink.buf + baseline;
198 1 : size_t dlen = diff_bytes;
199 1 : ASSERT(!memmem(delta, dlen, "abc", 3), "unchanged row not re-emitted");
200 1 : ASSERT(memmem(delta, dlen, "Y", 1) != NULL, "new Y emitted");
201 1 : ASSERT(memmem(delta, dlen, "\033[2;2H", 6) != NULL, "cursor to (2,2)");
202 1 : screen_free(&s);
203 1 : sink_close(&sink);
204 : }
205 :
206 1 : static void test_flip_emits_sgr_for_attrs_and_resets_at_end(void) {
207 1 : Sink sink; sink_open(&sink);
208 1 : Screen s; ASSERT(screen_init(&s, 1, 6) == 0, "init");
209 1 : s.out = sink.stream;
210 1 : screen_put_str(&s, 0, 0, "hi", SCREEN_ATTR_BOLD | SCREEN_ATTR_REVERSE);
211 1 : screen_flip(&s);
212 1 : sink_flush(&sink);
213 1 : ASSERT(sink_contains(&sink, "\033[0;1;7m"), "SGR for bold+reverse");
214 : /* Trailing reset so subsequent writes aren't styled. */
215 1 : ASSERT(sink_contains(&sink, "\033[0m"), "trailing reset emitted");
216 1 : screen_free(&s);
217 1 : sink_close(&sink);
218 : }
219 :
220 1 : static void test_invalidate_forces_full_redraw(void) {
221 1 : Sink sink; sink_open(&sink);
222 1 : Screen s; ASSERT(screen_init(&s, 1, 5) == 0, "init");
223 1 : s.out = sink.stream;
224 1 : screen_put_str(&s, 0, 0, "abc", 0);
225 1 : screen_flip(&s);
226 1 : sink_flush(&sink);
227 1 : size_t baseline = sink.len;
228 :
229 : /* Nothing changed in the back buffer but invalidate forces a redraw. */
230 1 : screen_invalidate(&s);
231 1 : screen_put_str(&s, 0, 0, "abc", 0); /* must re-stage explicitly */
232 1 : size_t n = screen_flip(&s);
233 1 : sink_flush(&sink);
234 1 : ASSERT(n > 0, "invalidate forces redraw");
235 1 : const char *delta = sink.buf + baseline;
236 1 : size_t dlen = sink.len - baseline;
237 1 : ASSERT(memmem(delta, dlen, "abc", 3) != NULL, "full content re-emitted");
238 1 : screen_free(&s);
239 1 : sink_close(&sink);
240 : }
241 :
242 1 : static void test_cursor_writes_cup(void) {
243 1 : Sink sink; sink_open(&sink);
244 1 : Screen s; ASSERT(screen_init(&s, 5, 20) == 0, "init");
245 1 : s.out = sink.stream;
246 1 : screen_cursor(&s, 3, 7);
247 1 : sink_flush(&sink);
248 1 : ASSERT(sink_contains(&sink, "\033[3;7H"), "CUP emitted 1-based");
249 1 : screen_free(&s);
250 1 : sink_close(&sink);
251 : }
252 :
253 1 : static void test_cursor_visible_emits_dectcem(void) {
254 1 : Sink sink; sink_open(&sink);
255 1 : Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
256 1 : s.out = sink.stream;
257 1 : screen_cursor_visible(&s, 0);
258 1 : screen_cursor_visible(&s, 1);
259 1 : sink_flush(&sink);
260 1 : ASSERT(sink_contains(&sink, "\033[?25l"), "hide sequence");
261 1 : ASSERT(sink_contains(&sink, "\033[?25h"), "show sequence");
262 1 : screen_free(&s);
263 1 : sink_close(&sink);
264 : }
265 :
266 1 : static void test_flip_skips_wide_char_trailer(void) {
267 1 : setlocale(LC_CTYPE, "en_US.UTF-8");
268 1 : Sink sink; sink_open(&sink);
269 1 : Screen s; ASSERT(screen_init(&s, 1, 4) == 0, "init");
270 1 : s.out = sink.stream;
271 1 : int w = screen_put_str(&s, 0, 0, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
272 1 : if (w != 2) {
273 0 : screen_free(&s); sink_close(&sink);
274 0 : return; /* locale unsupported */
275 : }
276 1 : screen_flip(&s);
277 1 : sink_flush(&sink);
278 : /* UTF-8 byte sequence appears exactly once (we skip the trailer). */
279 1 : const char needle[] = "\xE6\x97\xA5";
280 1 : int count = 0;
281 14 : for (size_t i = 0; i + 3 <= sink.len; i++) {
282 13 : if (memcmp(sink.buf + i, needle, 3) == 0) count++;
283 : }
284 1 : ASSERT(count == 1, "wide char emitted once");
285 1 : screen_free(&s);
286 1 : sink_close(&sink);
287 : }
288 :
289 : /* --- SEC-01 sanitization tests --- */
290 :
291 : /* U+00B7 MIDDLE DOT encoded as UTF-8: 0xC2 0xB7 */
292 : static const char MIDDLE_DOT_UTF8[] = "\xC2\xB7";
293 :
294 : /**
295 : * @brief SEC-01: ESC (U+001B) in input must not appear in flip output.
296 : *
297 : * A malicious message containing ESC followed by '[2J' (erase-display)
298 : * must have the ESC replaced with U+00B7 MIDDLE DOT so no raw 0x1B byte
299 : * reaches the terminal.
300 : */
301 1 : static void test_sec01_esc_replaced_by_placeholder(void) {
302 1 : Sink sink; sink_open(&sink);
303 1 : Screen s; ASSERT(screen_init(&s, 2, 20) == 0, "init");
304 1 : s.out = sink.stream;
305 : /* "\033[2J" — erase-display escape sequence embedded in message text */
306 1 : screen_put_str(&s, 0, 0, "\033[2J", SCREEN_ATTR_NORMAL);
307 : /* ESC should have been replaced; the cell at col 0 must hold U+00B7. */
308 1 : ASSERT(s.back[0].cp == 0x00B7, "ESC codepoint replaced with U+00B7");
309 1 : screen_flip(&s);
310 1 : sink_flush(&sink);
311 : /* The raw 0x1B ESC byte must not appear in the terminal output. */
312 1 : int found_esc = 0;
313 58 : for (size_t i = 0; i < sink.len; i++) {
314 57 : if ((unsigned char)sink.buf[i] == 0x1B
315 3 : && i + 1 < sink.len
316 : /* Allow legitimate CUP / SGR sequences emitted by screen_flip
317 : * itself — those follow the pattern 0x1B '[' digit. They are
318 : * fine; what we forbid is 0x1B injected from message content
319 : * at cell positions. The placeholder U+00B7 emits 0xC2 0xB7
320 : * so it cannot accidentally become 0x1B. */
321 3 : && (unsigned char)sink.buf[i + 1] != '[') {
322 0 : found_esc = 1;
323 0 : break;
324 : }
325 : }
326 1 : ASSERT(!found_esc, "no raw ESC from message content in terminal output");
327 : /* The placeholder byte sequence (UTF-8 for U+00B7) must be present. */
328 1 : ASSERT(sink_contains(&sink, MIDDLE_DOT_UTF8), "middle-dot placeholder emitted");
329 1 : screen_free(&s);
330 1 : sink_close(&sink);
331 : }
332 :
333 : /**
334 : * @brief SEC-01: DEL (U+007F) in input must be replaced with U+00B7.
335 : */
336 1 : static void test_sec01_del_replaced_by_placeholder(void) {
337 1 : Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
338 1 : screen_put_str(&s, 0, 0, "\x7F", SCREEN_ATTR_NORMAL);
339 1 : ASSERT(s.back[0].cp == 0x00B7, "DEL (0x7F) replaced with U+00B7");
340 1 : screen_free(&s);
341 : }
342 :
343 : /**
344 : * @brief SEC-01: 8-bit CSI (U+009B, encoded as 0xC2 0x9B in UTF-8) in input
345 : * must be replaced with U+00B7.
346 : */
347 1 : static void test_sec01_csi_replaced_by_placeholder(void) {
348 1 : Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
349 : /* U+009B encoded in UTF-8: 0xC2 0x9B */
350 1 : screen_put_str(&s, 0, 0, "\xC2\x9B", SCREEN_ATTR_NORMAL);
351 1 : ASSERT(s.back[0].cp == 0x00B7, "8-bit CSI (U+009B) replaced with U+00B7");
352 1 : screen_free(&s);
353 : }
354 :
355 : /**
356 : * @brief SEC-01: plain ASCII text must pass through unchanged (no false positives).
357 : */
358 1 : static void test_sec01_plain_text_unchanged(void) {
359 1 : Sink sink; sink_open(&sink);
360 1 : Screen s; ASSERT(screen_init(&s, 1, 20) == 0, "init");
361 1 : s.out = sink.stream;
362 1 : screen_put_str(&s, 0, 0, "Hello, world!", SCREEN_ATTR_NORMAL);
363 1 : ASSERT(s.back[0].cp == 'H', "H at col 0");
364 1 : ASSERT(s.back[4].cp == 'o', "o at col 4");
365 1 : screen_flip(&s);
366 1 : sink_flush(&sink);
367 1 : ASSERT(sink_contains(&sink, "Hello, world!"), "plain text passes through");
368 1 : screen_free(&s);
369 1 : sink_close(&sink);
370 : }
371 :
372 1 : void test_tui_screen_run(void) {
373 1 : RUN_TEST(test_init_allocates_and_free_releases);
374 1 : RUN_TEST(test_init_rejects_bad_dims);
375 1 : RUN_TEST(test_put_str_ascii_lands_in_back);
376 1 : RUN_TEST(test_put_str_clips_at_right_edge);
377 1 : RUN_TEST(test_put_str_wide_char_occupies_two_cells);
378 1 : RUN_TEST(test_put_str_wide_char_clipped_when_one_cell_left);
379 1 : RUN_TEST(test_put_str_rejects_out_of_range);
380 1 : RUN_TEST(test_clear_back_resets_every_cell);
381 1 : RUN_TEST(test_fill_writes_attrs);
382 1 : RUN_TEST(test_fill_clips_right_edge);
383 1 : RUN_TEST(test_flip_first_time_emits_everything);
384 1 : RUN_TEST(test_flip_second_time_identical_emits_nothing);
385 1 : RUN_TEST(test_flip_emits_only_changed_cells);
386 1 : RUN_TEST(test_flip_emits_sgr_for_attrs_and_resets_at_end);
387 1 : RUN_TEST(test_invalidate_forces_full_redraw);
388 1 : RUN_TEST(test_cursor_writes_cup);
389 1 : RUN_TEST(test_cursor_visible_emits_dectcem);
390 1 : RUN_TEST(test_flip_skips_wide_char_trailer);
391 1 : RUN_TEST(test_sec01_esc_replaced_by_placeholder);
392 1 : RUN_TEST(test_sec01_del_replaced_by_placeholder);
393 1 : RUN_TEST(test_sec01_csi_replaced_by_placeholder);
394 1 : RUN_TEST(test_sec01_plain_text_unchanged);
395 1 : }
|