diff options
Diffstat (limited to 'tests/sys/kern/tty/test_sti.c')
-rw-r--r-- | tests/sys/kern/tty/test_sti.c | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/tests/sys/kern/tty/test_sti.c b/tests/sys/kern/tty/test_sti.c new file mode 100644 index 000000000000..f792001b4e3f --- /dev/null +++ b/tests/sys/kern/tty/test_sti.c @@ -0,0 +1,337 @@ +/*- + * Copyright (c) 2025 Kyle Evans <kevans@FreeBSD.org> + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include <sys/param.h> +#include <sys/ioctl.h> +#include <sys/wait.h> + +#include <assert.h> +#include <errno.h> +#include <fcntl.h> +#include <signal.h> +#include <stdbool.h> +#include <stdlib.h> +#include <termios.h> + +#include <atf-c.h> +#include <libutil.h> + +enum stierr { + STIERR_CONFIG_FETCH, + STIERR_CONFIG, + STIERR_INJECT, + STIERR_READFAIL, + STIERR_BADTEXT, + STIERR_DATAFOUND, + STIERR_ROTTY, + STIERR_WOTTY, + STIERR_WOOK, + STIERR_BADERR, + + STIERR_MAXERR +}; + +static const struct stierr_map { + enum stierr stierr; + const char *msg; +} stierr_map[] = { + { STIERR_CONFIG_FETCH, "Failed to fetch ctty configuration" }, + { STIERR_CONFIG, "Failed to configure ctty in the child" }, + { STIERR_INJECT, "Failed to inject characters via TIOCSTI" }, + { STIERR_READFAIL, "Failed to read(2) from stdin" }, + { STIERR_BADTEXT, "read(2) data did not match injected data" }, + { STIERR_DATAFOUND, "read(2) data when we did not expected to" }, + { STIERR_ROTTY, "Failed to open tty r/o" }, + { STIERR_WOTTY, "Failed to open tty w/o" }, + { STIERR_WOOK, "TIOCSTI on w/o tty succeeded" }, + { STIERR_BADERR, "Received wrong error from failed TIOCSTI" }, +}; +_Static_assert(nitems(stierr_map) == STIERR_MAXERR, + "Failed to describe all errors"); + +/* + * Inject each character of the input string into the TTY. The caller can + * assume that errno is preserved on return. + */ +static ssize_t +inject(int fileno, const char *str) +{ + size_t nb = 0; + + for (const char *walker = str; *walker != '\0'; walker++) { + if (ioctl(fileno, TIOCSTI, walker) != 0) + return (-1); + nb++; + } + + return (nb); +} + +/* + * Forks off a new process, stashes the parent's handle for the pty in *termfd + * and returns the pid. 0 for the child, >0 for the parent, as usual. + * + * Most tests fork so that we can do them while unprivileged, which we can only + * do if we're operating on our ctty (and we don't want to touch the tty of + * whatever may be running the tests). + */ +static int +init_pty(int *termfd, bool canon) +{ + int pid; + + pid = forkpty(termfd, NULL, NULL, NULL); + ATF_REQUIRE(pid != -1); + + if (pid == 0) { + struct termios term; + + /* + * Child reconfigures tty to disable echo and put it into raw + * mode if requested. + */ + if (tcgetattr(STDIN_FILENO, &term) == -1) + _exit(STIERR_CONFIG_FETCH); + term.c_lflag &= ~ECHO; + if (!canon) + term.c_lflag &= ~ICANON; + if (tcsetattr(STDIN_FILENO, TCSANOW, &term) == -1) + _exit(STIERR_CONFIG); + } + + return (pid); +} + +static void +finalize_child(pid_t pid, int signo) +{ + int status, wpid; + + while ((wpid = waitpid(pid, &status, 0)) != pid) { + if (wpid != -1) + continue; + ATF_REQUIRE_EQ_MSG(EINTR, errno, + "waitpid: %s", strerror(errno)); + } + + /* + * Some tests will signal the child for whatever reason, and we're + * expecting it to terminate it. For those cases, it's OK to just see + * that termination. For all other cases, we expect a graceful exit + * with an exit status that reflects a cause that we have an error + * mapped for. + */ + if (signo >= 0) { + ATF_REQUIRE(WIFSIGNALED(status)); + ATF_REQUIRE_EQ(signo, WTERMSIG(status)); + } else { + ATF_REQUIRE(WIFEXITED(status)); + if (WEXITSTATUS(status) != 0) { + int err = WEXITSTATUS(status); + + for (size_t i = 0; i < nitems(stierr_map); i++) { + const struct stierr_map *map = &stierr_map[i]; + + if ((int)map->stierr == err) { + atf_tc_fail("%s", map->msg); + __assert_unreachable(); + } + } + } + } +} + +ATF_TC(basic); +ATF_TC_HEAD(basic, tc) +{ + atf_tc_set_md_var(tc, "descr", + "Test for basic functionality of TIOCSTI"); + atf_tc_set_md_var(tc, "require.user", "unprivileged"); +} +ATF_TC_BODY(basic, tc) +{ + int pid, term; + + /* + * We don't canonicalize on this test because we can assume that the + * injected data will be available after TIOCSTI returns. This is all + * within a single thread for the basic test, so we simplify our lives + * slightly in raw mode. + */ + pid = init_pty(&term, false); + if (pid == 0) { + static const char sending[] = "Text"; + char readbuf[32]; + ssize_t injected, readsz; + + injected = inject(STDIN_FILENO, sending); + if (injected != sizeof(sending) - 1) + _exit(STIERR_INJECT); + + readsz = read(STDIN_FILENO, readbuf, sizeof(readbuf)); + + if (readsz < 0 || readsz != injected) + _exit(STIERR_READFAIL); + if (memcmp(readbuf, sending, readsz) != 0) + _exit(STIERR_BADTEXT); + + _exit(0); + } + + finalize_child(pid, -1); +} + +ATF_TC(root); +ATF_TC_HEAD(root, tc) +{ + atf_tc_set_md_var(tc, "descr", + "Test that root can inject into another TTY"); + atf_tc_set_md_var(tc, "require.user", "root"); +} +ATF_TC_BODY(root, tc) +{ + static const char sending[] = "Text\r"; + ssize_t injected; + int pid, term; + + /* + * We leave canonicalization enabled for this one so that the read(2) + * below hangs until we have all of the data available, rather than + * having to signal OOB that it's safe to read. + */ + pid = init_pty(&term, true); + if (pid == 0) { + char readbuf[32]; + ssize_t readsz; + + readsz = read(STDIN_FILENO, readbuf, sizeof(readbuf)); + if (readsz < 0 || readsz != sizeof(sending) - 1) + _exit(STIERR_READFAIL); + + /* + * Here we ignore the trailing \r, because it won't have + * surfaced in our read(2). + */ + if (memcmp(readbuf, sending, readsz - 1) != 0) + _exit(STIERR_BADTEXT); + + _exit(0); + } + + injected = inject(term, sending); + ATF_REQUIRE_EQ_MSG(sizeof(sending) - 1, injected, + "Injected %zu characters, expected %zu", injected, + sizeof(sending) - 1); + + finalize_child(pid, -1); +} + +ATF_TC(unprivileged_fail_noctty); +ATF_TC_HEAD(unprivileged_fail_noctty, tc) +{ + atf_tc_set_md_var(tc, "descr", + "Test that unprivileged cannot inject into non-controlling TTY"); + atf_tc_set_md_var(tc, "require.user", "unprivileged"); +} +ATF_TC_BODY(unprivileged_fail_noctty, tc) +{ + const char sending[] = "Text"; + ssize_t injected; + int pid, serrno, term; + + pid = init_pty(&term, false); + if (pid == 0) { + char readbuf[32]; + ssize_t readsz; + + /* + * This should hang until we get terminated by the parent. + */ + readsz = read(STDIN_FILENO, readbuf, sizeof(readbuf)); + if (readsz > 0) + _exit(STIERR_DATAFOUND); + + _exit(0); + } + + /* Should fail. */ + injected = inject(term, sending); + serrno = errno; + + /* Done with the child, just kill it now to avoid problems later. */ + kill(pid, SIGINT); + finalize_child(pid, SIGINT); + + ATF_REQUIRE_EQ_MSG(-1, (ssize_t)injected, + "TIOCSTI into non-ctty succeeded"); + ATF_REQUIRE_EQ(EACCES, serrno); +} + +ATF_TC(unprivileged_fail_noread); +ATF_TC_HEAD(unprivileged_fail_noread, tc) +{ + atf_tc_set_md_var(tc, "descr", + "Test that unprivileged cannot inject into TTY not opened for read"); + atf_tc_set_md_var(tc, "require.user", "unprivileged"); +} +ATF_TC_BODY(unprivileged_fail_noread, tc) +{ + int pid, term; + + /* + * Canonicalization actually doesn't matter for this one, we'll trust + * that the failure means we didn't inject anything. + */ + pid = init_pty(&term, true); + if (pid == 0) { + static const char sending[] = "Text"; + ssize_t injected; + int rotty, wotty; + + /* + * We open the tty both r/o and w/o to ensure we got the device + * name right; one of these will pass, one of these will fail. + */ + wotty = openat(STDIN_FILENO, "", O_EMPTY_PATH | O_WRONLY); + if (wotty == -1) + _exit(STIERR_WOTTY); + rotty = openat(STDIN_FILENO, "", O_EMPTY_PATH | O_RDONLY); + if (rotty == -1) + _exit(STIERR_ROTTY); + + /* + * This injection is expected to fail with EPERM, because it may + * be our controlling tty but it is not open for reading. + */ + injected = inject(wotty, sending); + if (injected != -1) + _exit(STIERR_WOOK); + if (errno != EPERM) + _exit(STIERR_BADERR); + + /* + * Demonstrate that it does succeed on the other fd we opened, + * which is r/o. + */ + injected = inject(rotty, sending); + if (injected != sizeof(sending) - 1) + _exit(STIERR_INJECT); + + _exit(0); + } + + finalize_child(pid, -1); +} + +ATF_TP_ADD_TCS(tp) +{ + ATF_TP_ADD_TC(tp, basic); + ATF_TP_ADD_TC(tp, root); + ATF_TP_ADD_TC(tp, unprivileged_fail_noctty); + ATF_TP_ADD_TC(tp, unprivileged_fail_noread); + + return (atf_no_error()); +} |