Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file test_password_prompt.c
6 : * @brief TEST-87 — PTY tests for terminal_read_password() masking/echo.
7 : *
8 : * Exercises src/platform/posix/terminal.c::terminal_read_password via a
9 : * minimal harness binary (password_harness) that calls it directly and
10 : * prints the result. Using a harness — rather than tg-tui's 2FA path —
11 : * keeps these tests fast and independent from the mock server used by
12 : * the other PTY suites.
13 : *
14 : * Scenarios (matches TEST-87 ticket):
15 : * 1. echo off during masked input
16 : * type "hunter2"; the PTY screen must NOT show "hunter2" before
17 : * Enter is pressed.
18 : * 2. echo restored on return
19 : * after the password prompt returns, a subsequent fgets() prompt
20 : * echoes typed characters normally.
21 : * 3. echo restored after SIGINT
22 : * Ctrl-C kills the child; a fresh child's /bin/echo-style check
23 : * shows echoed characters. Proves the previous process did not
24 : * leave the terminal in a stuck no-echo state (SIGINT handlers
25 : * in terminal.c restore termios).
26 : * 4. backspace edits the hidden buffer
27 : * "bad<BS><BS><BS>hunter2<Enter>" → ACCEPTED:hunter2.
28 : * 5. Ctrl-D on empty reports error
29 : * 0x04 on an empty line → harness prints "ERROR" and exits 1.
30 : * 6. long input (256 chars) accepted without truncation
31 : * ACCEPTED:<256 chars>, LEN:256.
32 : *
33 : * Each test uses "goto cleanup" labels for ASAN-safe resource release on
34 : * failure paths (same pattern as tests/functional/pty/test_readline.c).
35 : */
36 :
37 : #include "ptytest.h"
38 : #include "pty_assert.h"
39 :
40 : #include <signal.h>
41 : #include <stdio.h>
42 : #include <stdlib.h>
43 : #include <string.h>
44 : #include <unistd.h>
45 :
46 : /* ── Globals required by ASSERT / ASSERT_WAIT_FOR macros ──────────────── */
47 :
48 : static int g_tests_run = 0;
49 : static int g_tests_failed = 0;
50 :
51 : #ifndef PASSWORD_HARNESS_BINARY
52 : #define PASSWORD_HARNESS_BINARY "password_harness"
53 : #endif
54 :
55 : /* ── Helpers ──────────────────────────────────────────────────────────── */
56 :
57 : #define RUN_TEST(fn) do { \
58 : printf(" running %s ...\n", #fn); \
59 : fn(); \
60 : } while(0)
61 :
62 : /** CHECK_WAIT_FOR: jumps to label on timeout instead of returning. */
63 : #define CHECK_WAIT_FOR(s, text, timeout_ms, label) do { \
64 : g_tests_run++; \
65 : if (pty_wait_for((s), (text), (timeout_ms)) != 0) { \
66 : printf(" [FAIL] %s:%d: wait_for(\"%s\", %d ms) timed out\n", \
67 : __FILE__, __LINE__, (text), (timeout_ms)); \
68 : g_tests_failed++; \
69 : goto label; \
70 : } \
71 : } while(0)
72 :
73 : /** CHECK: jumps to label instead of returning. */
74 : #define CHECK(cond, msg, label) do { \
75 : g_tests_run++; \
76 : if (!(cond)) { \
77 : printf(" [FAIL] %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
78 : g_tests_failed++; \
79 : goto label; \
80 : } \
81 : } while(0)
82 :
83 : /** Open an 80x24 PTY and start password_harness with the given mode. */
84 33 : static PtySession *open_harness(const char *mode) {
85 33 : PtySession *s = pty_open(80, 24);
86 33 : if (!s) return NULL;
87 33 : const char *argv[] = { PASSWORD_HARNESS_BINARY, mode, NULL };
88 33 : if (pty_run(s, argv) != 0) {
89 0 : pty_close(s);
90 0 : return NULL;
91 : }
92 : /* Wait for the prompt string emitted by terminal_read_password ("Password: "). */
93 27 : if (pty_wait_for(s, "Password", 3000) != 0) {
94 0 : pty_close(s);
95 0 : return NULL;
96 : }
97 27 : return s;
98 : }
99 :
100 : /* ── Tests ────────────────────────────────────────────────────────────── */
101 :
102 : /**
103 : * @brief Scenario 1: typed characters are NOT echoed during masked input.
104 : *
105 : * After the prompt appears, type "hunter2" but do NOT press Enter yet.
106 : * The PTY master should not have received any echo of those bytes
107 : * (because terminal_read_password cleared ECHO). Once Enter is pressed
108 : * the harness prints "ACCEPTED:hunter2" on its own stdout — which IS
109 : * visible. We assert "hunter2" is absent between the prompt and Enter
110 : * and present only in the "ACCEPTED:" line.
111 : */
112 8 : static void test_echo_off_during_input(void) {
113 8 : PtySession *s = open_harness("prompt");
114 7 : CHECK(s != NULL, "open_harness(prompt) should succeed", done_no_session);
115 :
116 : /* Type the secret (no Enter yet). */
117 7 : pty_send_str(s, "hunter2");
118 : /* Let any terminal echo land on the master — if it were going to. */
119 7 : pty_settle(s, 150);
120 :
121 : /* The literal "hunter2" must NOT be visible on screen yet. */
122 7 : CHECK(pty_screen_contains(s, "hunter2") == 0,
123 : "typed password must not be echoed while ECHO is cleared",
124 : done);
125 :
126 : /* Submit so the child exits cleanly. */
127 7 : pty_send_key(s, PTY_KEY_ENTER);
128 :
129 : /* After Enter, the harness prints ACCEPTED:hunter2 to its own stdout. */
130 7 : CHECK_WAIT_FOR(s, "ACCEPTED:hunter2", 3000, done);
131 :
132 7 : int exit_code = pty_wait_exit(s, 3000);
133 7 : CHECK(exit_code == 0, "harness must exit 0 after successful prompt", done);
134 7 : done:
135 7 : pty_close(s);
136 7 : done_no_session:
137 7 : return;
138 : }
139 :
140 : /**
141 : * @brief Scenario 2: echo is restored after the prompt returns.
142 : *
143 : * Prompt once, submit, then the harness fgets() a second line. Typing
144 : * "CONFIRM" on that second read MUST become visible on screen (the
145 : * kernel tty line discipline echoes it because ECHO was restored).
146 : */
147 7 : static void test_echo_restored_on_return(void) {
148 7 : PtySession *s = open_harness("prompt_then_echo");
149 6 : CHECK(s != NULL, "open_harness(prompt_then_echo) should succeed",
150 : done_no_session);
151 :
152 : /* First prompt: masked input. */
153 6 : pty_send_str(s, "secret");
154 6 : pty_send_key(s, PTY_KEY_ENTER);
155 6 : CHECK_WAIT_FOR(s, "ACCEPTED:secret", 3000, done);
156 :
157 : /* Now a plain fgets() is active. Type CONFIRM — it SHOULD echo. */
158 6 : pty_send_str(s, "CONFIRM");
159 : /* Give the kernel time to echo the typed bytes back on the master. */
160 6 : pty_settle(s, 150);
161 :
162 : /* The typed text must have been echoed — search for it on screen
163 : * BEFORE sending Enter (so we cannot be confused with the harness's
164 : * own "ECHO:CONFIRM\n" output). */
165 6 : CHECK(pty_screen_contains(s, "CONFIRM") != 0,
166 : "plain prompt after password must echo typed chars "
167 : "(ECHO bit was not restored)",
168 : done);
169 :
170 6 : pty_send_key(s, PTY_KEY_ENTER);
171 6 : CHECK_WAIT_FOR(s, "ECHO:CONFIRM", 3000, done);
172 :
173 6 : int exit_code = pty_wait_exit(s, 3000);
174 6 : CHECK(exit_code == 0, "harness must exit 0 after both reads succeed",
175 : done);
176 6 : done:
177 6 : pty_close(s);
178 6 : done_no_session:
179 6 : return;
180 : }
181 :
182 : /**
183 : * @brief Scenario 3: echo is still usable in a fresh process after SIGINT.
184 : *
185 : * Ctrl-C during the masked prompt raises SIGINT; the process dies.
186 : * If the SIGINT handler failed to restore termios we would observe
187 : * the problem in a NEW child started on a fresh PTY: ordinary typed
188 : * bytes would not echo. This test confirms SIGINT is not sticky.
189 : *
190 : * Note: canonical-mode Ctrl-C affects only the owning process. Because
191 : * we open a brand-new PTY for the "after" probe there is no cross-run
192 : * termios leakage to catch in a single test host; the assertion here
193 : * is really "Ctrl-C kills the prompt cleanly AND a brand-new prompt
194 : * still works". This is the strongest observable behaviour from the
195 : * user's perspective.
196 : */
197 6 : static void test_echo_restored_after_sigint(void) {
198 : /* First session: start the prompt, then send Ctrl-C. */
199 6 : PtySession *s = open_harness("prompt");
200 5 : CHECK(s != NULL, "open_harness(prompt) should succeed",
201 : done_no_session);
202 :
203 : /* Start typing, then interrupt before hitting Enter. */
204 5 : pty_send_str(s, "hun");
205 5 : pty_settle(s, 100);
206 :
207 : /* Ctrl-C (0x03) — ISIG is still enabled inside terminal_read_password,
208 : * so the tty line discipline will send SIGINT to the foreground group. */
209 5 : pty_send_key(s, PTY_KEY_CTRL_C);
210 :
211 5 : int exit_code = pty_wait_exit(s, 3000);
212 : /* pty_wait_exit encodes signal exit as 128 + signum; SIGINT = 2 → 130. */
213 5 : CHECK(exit_code == 130 || exit_code == -1,
214 : "Ctrl-C during masked prompt must terminate the child",
215 : done_first);
216 5 : pty_close(s);
217 :
218 : /* Second session: open a fresh PTY and a fresh harness. Running
219 : * through prompt_then_echo verifies that a new process's plain
220 : * fgets() echoes typed bytes normally. */
221 5 : s = open_harness("prompt_then_echo");
222 4 : CHECK(s != NULL, "fresh open_harness after SIGINT must succeed",
223 : done_no_session);
224 :
225 4 : pty_send_str(s, "pw2");
226 4 : pty_send_key(s, PTY_KEY_ENTER);
227 4 : CHECK_WAIT_FOR(s, "ACCEPTED:pw2", 3000, done);
228 :
229 4 : pty_send_str(s, "ECHOOK");
230 4 : pty_settle(s, 150);
231 4 : CHECK(pty_screen_contains(s, "ECHOOK") != 0,
232 : "fresh child after SIGINT must echo typed bytes",
233 : done);
234 :
235 4 : pty_send_key(s, PTY_KEY_ENTER);
236 4 : CHECK_WAIT_FOR(s, "ECHO:ECHOOK", 3000, done);
237 4 : pty_wait_exit(s, 3000);
238 4 : done:
239 4 : pty_close(s);
240 4 : return;
241 0 : done_first:
242 0 : pty_close(s);
243 0 : done_no_session:
244 0 : return;
245 : }
246 :
247 : /**
248 : * @brief Scenario 4: Backspace in the hidden buffer corrects the password.
249 : *
250 : * In canonical mode with ECHO cleared, the tty line discipline still
251 : * handles ERASE (DEL / 0x7F): typing "bad" then three ERASE chars then
252 : * "hunter2\n" must deliver the string "hunter2" (NOT "badhunter2") to
253 : * getline() inside terminal_read_password.
254 : */
255 4 : static void test_backspace_edits_hidden_buffer(void) {
256 4 : PtySession *s = open_harness("prompt");
257 3 : CHECK(s != NULL, "open_harness(prompt) should succeed", done_no_session);
258 :
259 3 : pty_send_str(s, "bad");
260 3 : pty_send_key(s, PTY_KEY_BACK); /* 0x7F */
261 3 : pty_send_key(s, PTY_KEY_BACK);
262 3 : pty_send_key(s, PTY_KEY_BACK);
263 3 : pty_send_str(s, "hunter2");
264 3 : pty_send_key(s, PTY_KEY_ENTER);
265 :
266 3 : CHECK_WAIT_FOR(s, "ACCEPTED:hunter2", 3000, done);
267 :
268 : /* Also assert the wrong string did not slip through. */
269 3 : CHECK(pty_screen_contains(s, "ACCEPTED:badhunter2") == 0,
270 : "backspace must erase the preceding chars, not append",
271 : done);
272 :
273 3 : CHECK_WAIT_FOR(s, "LEN:7", 3000, done);
274 :
275 3 : int exit_code = pty_wait_exit(s, 3000);
276 3 : CHECK(exit_code == 0, "harness must exit 0", done);
277 3 : done:
278 3 : pty_close(s);
279 3 : done_no_session:
280 3 : return;
281 : }
282 :
283 : /**
284 : * @brief Scenario 5: Ctrl-D on an empty line returns an error.
285 : *
286 : * In canonical mode, 0x04 (EOT) on an empty line causes read(2) to
287 : * return 0. getline() returns -1, terminal_read_password returns -1,
288 : * and the harness prints "ERROR" and exits 1.
289 : */
290 3 : static void test_ctrl_d_on_empty_reports_error(void) {
291 3 : PtySession *s = open_harness("prompt");
292 2 : CHECK(s != NULL, "open_harness(prompt) should succeed", done_no_session);
293 :
294 2 : pty_send_key(s, PTY_KEY_CTRL_D);
295 :
296 2 : CHECK_WAIT_FOR(s, "ERROR", 3000, done);
297 :
298 2 : int exit_code = pty_wait_exit(s, 3000);
299 2 : CHECK(exit_code == 1,
300 : "Ctrl-D on empty password line must exit 1 (error)",
301 : done);
302 2 : done:
303 2 : pty_close(s);
304 2 : done_no_session:
305 2 : return;
306 : }
307 :
308 : /**
309 : * @brief Scenario 6: a 256-character password is accepted without truncation.
310 : *
311 : * The harness's "prompt_big" mode uses a 512-byte buffer, so 256 chars
312 : * fit with room for NUL. We assert both the echoed string and the
313 : * reported length.
314 : */
315 2 : static void test_long_input_fits_buffer(void) {
316 2 : PtySession *s = pty_open(120, 24);
317 2 : CHECK(s != NULL, "pty_open should succeed", done_no_session);
318 :
319 2 : const char *argv[] = { PASSWORD_HARNESS_BINARY, "prompt_big", NULL };
320 2 : CHECK(pty_run(s, argv) == 0, "pty_run should succeed", done);
321 :
322 1 : CHECK_WAIT_FOR(s, "Password", 3000, done);
323 :
324 : /* Build a 256-char password made of repeating 'a'..'z' so we can
325 : * verify the tail arrived intact. */
326 : char pw[257];
327 257 : for (int i = 0; i < 256; i++) pw[i] = (char)('a' + (i % 26));
328 1 : pw[256] = '\0';
329 :
330 1 : pty_send(s, pw, 256);
331 1 : pty_send_key(s, PTY_KEY_ENTER);
332 :
333 1 : CHECK_WAIT_FOR(s, "LEN:256", 5000, done);
334 :
335 1 : int exit_code = pty_wait_exit(s, 3000);
336 1 : CHECK(exit_code == 0, "harness must exit 0 for 256-char password", done);
337 1 : done:
338 1 : pty_close(s);
339 1 : done_no_session:
340 1 : return;
341 : }
342 :
343 : /* ── Entry point ──────────────────────────────────────────────────────── */
344 :
345 8 : int main(void) {
346 8 : printf("TEST-87 password prompt PTY tests (%s)\n",
347 : PASSWORD_HARNESS_BINARY);
348 :
349 8 : RUN_TEST(test_echo_off_during_input);
350 7 : RUN_TEST(test_echo_restored_on_return);
351 6 : RUN_TEST(test_echo_restored_after_sigint);
352 4 : RUN_TEST(test_backspace_edits_hidden_buffer);
353 3 : RUN_TEST(test_ctrl_d_on_empty_reports_error);
354 2 : RUN_TEST(test_long_input_fits_buffer);
355 :
356 1 : printf("\n%d tests run, %d failed\n", g_tests_run, g_tests_failed);
357 1 : return g_tests_failed > 0 ? 1 : 0;
358 : }
|