diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist --- a/etc/mtree/BSD.tests.dist +++ b/etc/mtree/BSD.tests.dist @@ -1197,6 +1197,8 @@ .. rs .. + script + .. sdiff .. sed diff --git a/usr.bin/script/Makefile b/usr.bin/script/Makefile --- a/usr.bin/script/Makefile +++ b/usr.bin/script/Makefile @@ -1,5 +1,10 @@ +.include + PROG= script LIBADD= util +HAS_TESTS= +SUBDIR.${MK_TESTS}+= tests + .include diff --git a/usr.bin/script/script.c b/usr.bin/script/script.c --- a/usr.bin/script/script.c +++ b/usr.bin/script/script.c @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -62,8 +63,15 @@ uint32_t scr_direction; /* 'i', 'o', etc (also indicates endianness) */ }; +enum elm_type { + ET_DATA, + ET_EOF, + ET_CLOSE, +}; + struct buf_elm { TAILQ_ENTRY(buf_elm) link; + enum elm_type etype; size_t rpos; size_t len; char ibuf[]; @@ -74,7 +82,7 @@ static int child; static const char *fname; static char *fmfname; -static int fflg, qflg, ttyflg; +static int fflg, kflg, qflg, ttyflg; static int usesleep, rawout, showexit; static TAILQ_HEAD(, buf_elm) obuf_list = TAILQ_HEAD_INITIALIZER(obuf_list); static volatile sig_atomic_t doresize; @@ -97,6 +105,66 @@ static void usage(void) __dead2; static void resizeit(int); +/* + * Writes the enqueued buf_elm out to the pty. Returns true if the element has + * been completely consumed, false if it has not. + */ +static bool +write_input(struct buf_elm *be, struct termios *stt) +{ + ssize_t cc; + + switch (be->etype) { + case ET_DATA: + cc = write(master, be->ibuf + be->rpos, be->len); + if (cc == -1) { + if (errno == EWOULDBLOCK || + errno == EINTR) + break; + warn("write master"); + done(1); + } else if (cc == 0) + return (false); /* retry later ? */ + + if (kflg && tcgetattr(master, stt) >= 0 && + ((stt->c_lflag & ECHO) == 0)) + (void)fwrite(be->ibuf + be->rpos, 1, cc, fscript); + + be->len -= cc; + if (be->len != 0) { + be->rpos += cc; + return (false); + } + + break; + case ET_EOF: + (void)write(master, &stt->c_cc[VEOF], 1); + break; + case ET_CLOSE: + (void)close(master); + master = -1; + break; + } + + return (true); +} + +static void +enqueue_input(enum elm_type etype, const char *buf, size_t bufsz) +{ + struct buf_elm *be; + + assert(etype != ET_DATA || buf != NULL); + be = malloc(sizeof(*be) + bufsz); + if (be == NULL) + err(1, "malloc"); + be->etype = etype; + be->rpos = 0; + be->len = bufsz; + memcpy(be->ibuf, buf, bufsz); + TAILQ_INSERT_TAIL(&obuf_list, be, link); +} + int main(int argc, char *argv[]) { @@ -110,7 +178,7 @@ fd_set rfd, wfd; struct buf_elm *be; ssize_t cc; - int aflg, Fflg, kflg, pflg, wflg, ch, k, n, fcm; + int aflg, Fflg, pflg, wflg, ch, k, n, fcm; int flushtime, readstdin; int fm_fd, fm_log; @@ -295,11 +363,16 @@ start = tvec = time(0); readstdin = 1; - for (;;) { + + /* + * Keep looping until we end up closing the pty or we have run out of + * obuf_list and cannot possibly read any more. + */ + while (master >= 0 && (!TAILQ_EMPTY(&obuf_list) || readstdin >= 0)) { FD_ZERO(&rfd); FD_ZERO(&wfd); FD_SET(master, &rfd); - if (readstdin) + if (readstdin > 0) FD_SET(STDIN_FILENO, &rfd); if (!TAILQ_EMPTY(&obuf_list)) FD_SET(master, &wfd); @@ -327,51 +400,42 @@ if (n > 0 && FD_ISSET(STDIN_FILENO, &rfd)) { cc = read(STDIN_FILENO, ibuf, BUFSIZ); - if (cc < 0) - break; + if (cc < 0) { + /* + * readstdin == 0 signals that we have nothing + * to read, but we may try to read stdin after + * some timeout if it's a tty -- negative values + * of readstdin will avoid it. + */ + readstdin = -1; + enqueue_input(ET_CLOSE, NULL, 0); + } if (cc == 0) { if (tcgetattr(master, &stt) == 0 && (stt.c_lflag & ICANON) != 0) { - (void)write(master, &stt.c_cc[VEOF], 1); + enqueue_input(ET_EOF, NULL, 0); } readstdin = 0; } if (cc > 0) { if (rawout) record(fscript, ibuf, cc, 'i'); - be = malloc(sizeof(*be) + cc); - be->rpos = 0; - be->len = cc; - memcpy(be->ibuf, ibuf, cc); - TAILQ_INSERT_TAIL(&obuf_list, be, link); + enqueue_input(ET_DATA, ibuf, cc); } + + n--; } if (n > 0 && FD_ISSET(master, &wfd)) { while ((be = TAILQ_FIRST(&obuf_list)) != NULL) { - cc = write(master, be->ibuf + be->rpos, - be->len); - if (cc == -1) { - if (errno == EWOULDBLOCK || - errno == EINTR) - break; - warn("write master"); - done(1); - } - if (cc == 0) - break; /* retry later ? */ - if (kflg && tcgetattr(master, &stt) >= 0 && - ((stt.c_lflag & ECHO) == 0)) { - (void)fwrite(be->ibuf + be->rpos, - 1, cc, fscript); - } - be->len -= cc; - if (be->len == 0) { - TAILQ_REMOVE(&obuf_list, be, link); - free(be); - } else { - be->rpos += cc; - } + if (!write_input(be, &stt)) + break; + + /* Item is consumed. */ + TAILQ_REMOVE(&obuf_list, be, link); + free(be); } + + n--; } if (n > 0 && FD_ISSET(master, &rfd)) { cc = read(master, obuf, sizeof(obuf)); @@ -382,6 +446,8 @@ record(fscript, obuf, cc, 'o'); else (void)fwrite(obuf, 1, cc, fscript); + + n--; } tvec = time(0); if (tvec - start >= flushtime) { @@ -391,6 +457,10 @@ if (Fflg) fflush(fscript); } + + if (master < 0) + assert(TAILQ_EMPTY(&obuf_list)); + finish(); done(0); } @@ -470,7 +540,8 @@ } } (void)fclose(fscript); - (void)close(master); + if (master >= 0) + (void)close(master); exit(eno); } diff --git a/usr.bin/script/tests/Makefile b/usr.bin/script/tests/Makefile new file mode 100644 --- /dev/null +++ b/usr.bin/script/tests/Makefile @@ -0,0 +1,6 @@ +PACKAGE= tests + +ATF_TESTS_SH+= script_test +${PACKAGE}FILES+= echo_loop.sh + +.include diff --git a/usr.bin/script/tests/echo_loop.sh b/usr.bin/script/tests/echo_loop.sh new file mode 100755 --- /dev/null +++ b/usr.bin/script/tests/echo_loop.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Copyright (C) 2025 Kyle Evans +# +# SPDX-License-Identifier: BSD-2-Clause +# + +# porch(1) will race script(1) putting the tty into raw mode and flushing it at +# the same time, and we lose our input almost every time. Providing a sentinel +# that naturally occurs *after* the tty is configured to avoid that is the main +# point of this script. +echo "Proceed" +while read line; do + echo "$line" +done +echo "Finished" diff --git a/usr.bin/script/tests/script_test.sh b/usr.bin/script/tests/script_test.sh new file mode 100644 --- /dev/null +++ b/usr.bin/script/tests/script_test.sh @@ -0,0 +1,119 @@ +# +# Copyright (c) 2025 Kyle Evans +# +# SPDX-License-Identifier: BSD-2-Clause +# + +echo_loop="$(atf_get_srcdir)/echo_loop.sh" + +atf_test_case interactive_basic +interactive_basic_head() +{ + atf_set "descr" "Test basic interactive use" + atf_set "require.progs" "porch" +} +interactive_basic_body() +{ + cat < basic.porch +match "Proceed" +write "sample\r" +match "sample" +write "^D" +eof() +EOF + + atf_check porch -f basic.porch script echo.script sh "$echo_loop" + atf_check grep -q "Script started" echo.script + atf_check grep -q "Command:" echo.script + atf_check grep -q "sample" echo.script + atf_check grep -q "exit status" echo.script + atf_check grep -q "Script done" echo.script + atf_check -o match:"1" -x 'grep -o "sample" echo.script | uniq -c' + + # Check -k for input recording while we're here. + atf_check porch -f basic.porch script -k echo.script sh "$echo_loop" + atf_check -o match:"2" -x 'grep -o "sample" echo.script | uniq -c' +} + +atf_test_case noninteractive_cmd +noninteractive_cmd_head() +{ + atf_set "descr" "Test non-interactive usage of the command form" +} +noninteractive_cmd_body() +{ + echo "sample" > input + atf_check -o not-empty script cat.script cat < input + + atf_check grep -q "Script started" cat.script + atf_check grep -q "Command:" cat.script + atf_check grep -q "sample" cat.script + atf_check grep -q "exit status" cat.script + atf_check grep -q "Script done" cat.script + + # We should see the sample string twice: once for echo, once from cat. + # One will have VEOF following it, the other not (thus grep -o). + atf_check -o match:"2" -x 'grep -o "sample" cat.script | uniq -c' +} + +atf_test_case noninteractive_cmd_append +noninteractive_cmd_append_head() +{ + atf_set "descr" "Test append (-a) using the noninteractive command form" +} +noninteractive_cmd_append_body() +{ + echo "sample" > input + atf_check -o not-empty script cat.script cat < input + atf_check -o not-empty script -a cat.script cat < input + + atf_check -o match:"4" -x 'grep -o "sample" cat.script | uniq -c' +} + +atf_test_case noninteractive_cmd_error +noninteractive_cmd_error_head() +{ + atf_set "descr" \ + "Test non-interactive usage of the command form encountering an error" +} +noninteractive_cmd_error_body() +{ + # Check both that the error is propagated, and that we can observe it + # in the script file. + atf_check -o not-empty -s exit:9 script _.script /bin/sh -c 'exit 9' + atf_check grep -q 'exit status: 9' _.script +} + +atf_test_case quiet +quiet_head() +{ + atf_set "descr" "Test the quiet form of output" +} +quiet_body() +{ + echo "sample" > input + + # Largely lifted from the noninteractive_cmd test; just the framing is + # stripped off. + atf_check -o not-empty script -q cat.script cat < input + + atf_check -s not-exit:0 grep -q "Script started" cat.script + atf_check -s not-exit:0 grep -q "Command:" cat.script + atf_check grep -q "sample" cat.script + atf_check -s not-exit:0 grep -q "exit status" cat.script + atf_check -s not-exit:0 grep -q "Script done" cat.script + + # We should see the sample string twice: once for echo, once from cat. + # One will have VEOF following it, the other not (thus grep -o). + atf_check -o match:"2" -x 'grep -o "sample" cat.script | uniq -c' +} + +atf_init_test_cases() +{ + atf_add_test_case interactive_basic + atf_add_test_case noninteractive_cmd + atf_add_test_case noninteractive_cmd_append + atf_add_test_case noninteractive_cmd_error + atf_add_test_case quiet +} +