From 1a7e4ec8d499fb0691264c9cbc995f3c4d781b37 Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Sat, 4 Mar 2023 12:56:01 +0000 Subject: [PATCH] New centralised version of local line editing. This takes over from both the implementation in ldisc.c and the one in term_get_userpass_input, which were imperfectly duplicating each other's functionality. The new version should be more consistent between the two already, and also, it means further improvements can now be made in just one place. In the course of this, I've restructured the inside of ldisc.c by moving the input_queue bufchain to the other side of the translation code in ldisc_send. Previously, ldisc_send received a string, an optional 'dedicated key' indication (bodgily signalled by a negative length) and an 'interactive' flag, translated that somehow into a combination of raw backend output and specials, and saved the latter in input_queue. Now it saves the original (string, dedicated flag, interactive flag) data in input_queue, and doesn't do the translation until the data is pulled back _out_ of the queue. That's because the new line editing system expects to receive something much closer to the original data format. The term_get_userpass_input system is also substantially restructured. Instead of ldisc.c handing each individual keystroke to terminal.c so that it can do line editing on it, terminal.c now just gives the Ldisc a pointer to its instance of the new TermLineEditor object - and then ldisc.c can put keystrokes straight into that, in the same way it would put them into its own TermLineEditor, without having to go via terminal.c at all. So the term_get_userpass_input edifice is only called back when the line editor actually delivers the answer to a username or password prompt. (I considered not _even_ having a separate TermLineEditor for password prompts, and just letting ldisc.c use its own. But the problem is that some of the behaviour differences between the two line editors are deliberate, for example the use of ^D to signal 'abort this prompt', and the use of Escape as an alternative line-clearing command. So TermLineEditor has a flags word that allows ldisc and terminal to set it up differently. Also this lets me give the two TermLineEditors a different vtable of callback functions, which is a convenient way for terminal.c to get notified when a prompt has been answered.) The new line editor still passes all the tests I wrote for the old one. But it already has a couple of important improvements, both in the area of UTF-8 handling: Firstly, when we display a UTF-8 character on the terminal, we check with the terminal how many character cells it occupied, and then if the user deletes it again from the editing buffer, we can emit the right number of backspace-space-backspace sequences. (The old ldisc line editor incorrectly assumed all Unicode characters had terminal with 1, partly because its buffer was byte- rather than character- oriented and so it was more than enough work just finding where the character _start_ was.) Secondly, terminal.c's userpass line editor would never emit a byte in the 80-BF range to the terminal at all, which meant that nontrivial UTF-8 characters always came out as U+FFFD blobs! --- CMakeLists.txt | 5 +- defs.h | 5 + ldisc.c | 668 +++++++++++++++---------------------------- putty.h | 23 +- stubs/no-lineedit.c | 18 ++ terminal/lineedit.c | 520 +++++++++++++++++++++++++++++++++ terminal/terminal.c | 236 ++++++++------- terminal/terminal.h | 48 ++++ test/fuzzterm.c | 5 +- test/test_lineedit.c | 30 +- 10 files changed, 997 insertions(+), 561 deletions(-) create mode 100644 stubs/no-lineedit.c create mode 100644 terminal/lineedit.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 7da0cbc3..91f040d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,7 +53,7 @@ add_library(agent STATIC add_library(guiterminal STATIC terminal/terminal.c terminal/bidi.c - ldisc.c config.c dialog.c + ldisc.c terminal/lineedit.c config.c dialog.c $) add_library(noterminal STATIC @@ -115,7 +115,8 @@ add_executable(bidi_test target_link_libraries(bidi_test guiterminal utils ${platform_libraries}) add_executable(plink - ${platform}/plink.c) + ${platform}/plink.c + stubs/no-lineedit.c) # Note: if we ever port Plink to a platform where we can't implement a # serial backend, this be_list command will need to become platform- # dependent, so that it only sets the SERIAL option on platforms where diff --git a/defs.h b/defs.h index d7e2f338..faff0482 100644 --- a/defs.h +++ b/defs.h @@ -115,6 +115,11 @@ typedef struct LogContext LogContext; typedef struct LogPolicy LogPolicy; typedef struct LogPolicyVtable LogPolicyVtable; +typedef struct TermLineEditor TermLineEditor; +typedef struct TermLineEditorCallbackReceiver TermLineEditorCallbackReceiver; +typedef struct TermLineEditorCallbackReceiverVtable + TermLineEditorCallbackReceiverVtable; + typedef struct Seat Seat; typedef struct SeatVtable SeatVtable; typedef struct SeatDialogText SeatDialogText; diff --git a/ldisc.c b/ldisc.c index 27f41a6f..899f5c82 100644 --- a/ldisc.c +++ b/ldisc.c @@ -12,50 +12,36 @@ #include "putty.h" #include "terminal.h" +typedef enum InputType { NORMAL, DEDICATED, NONINTERACTIVE } InputType; + +struct input_chunk { + struct input_chunk *next; + InputType type; + size_t size; +}; + struct Ldisc_tag { Terminal *term; Backend *backend; Seat *seat; /* - * When the backend is not reporting true from sendok(), terminal - * input that comes here is stored in this bufchain instead. When - * the backend later decides it wants session input, we empty the - * queue in ldisc_check_sendok_callback(), passing its contents on - * to the backend. Before then, we also provide data from this - * queue to term_get_userpass_input() via ldisc_get_input_token(), - * to be interpreted as user responses to username and password - * prompts during authentication. + * When the backend is not reporting true from sendok(), we must + * buffer the input received by ldisc_send(). It's stored in the + * bufchain below, together with a linked list of input_chunk + * blocks storing the extra metadata about special keys and + * interactivity that ldisc_send() receives. * - * Unfortunately, the data stored in this queue is not all of the - * same type: our output to the backend consists of both raw bytes - * sent to backend_send(), and also session specials such as - * SS_EOL and SS_EC. So we have to encode our queued data in a way - * that can represent both. - * - * The encoding is private to this source file, so we can change - * it if necessary and only have to worry about the encode and - * decode functions here. Currently, it is: - * - * - Bytes other than 0xFF are stored literally. - * - The byte 0xFF itself is stored as 0xFF 0xFF. - * - A session special (code, arg) is stored as 0xFF, followed by - * a big-endian 4-byte integer containing code, followed by - * another big-endian 4-byte integer containing arg. - * - * (This representation relies on session special codes being at - * most 0xFEFFFFFF when represented in 32 bits, so that the first - * byte of the 'code' integer can't be confused with the 0xFF - * followup byte indicating a literal 0xFF, But since session - * special codes are defined by an enum counting up from zero, and - * there are only a couple of dozen of them, that shouldn't be a - * problem! Even so, just in case, an assertion checks that at - * encode time.) + * All input is added to this buffer initially, but we then + * process as much of it as possible immediately and hand it off + * to the backend or a TermLineEditor. Anything left stays in this + * buffer until ldisc_check_sendok() is next called, triggering a + * run of the callback that tries again to process the queue. */ bufchain input_queue; + struct input_chunk *inchunk_head, *inchunk_tail; IdempotentCallback input_queue_callback; - prompts_t *prompts; /* * Values cached out of conf. @@ -63,9 +49,13 @@ struct Ldisc_tag { bool telnet_keyboard, telnet_newline; int protocol, localecho, localedit; - char *buf; - size_t buflen, bufsiz; - bool quotenext; + TermLineEditor *le; + TermLineEditorCallbackReceiver le_rcv; + + /* We get one of these communicated to us by + * term_get_userpass_input while it's reading a prompt, so that we + * can push data straight into it */ + TermLineEditor *userpass_le; }; #define ECHOING (ldisc->localecho == FORCE_ON || \ @@ -75,71 +65,16 @@ struct Ldisc_tag { (ldisc->localedit == AUTO && \ (backend_ldisc_option_state(ldisc->backend, LD_EDIT)))) -static void c_write(Ldisc *ldisc, const void *buf, int len) -{ - seat_stdout(ldisc->seat, buf, len); -} - -static int plen(Ldisc *ldisc, unsigned char c) -{ - if ((c >= 32 && c <= 126) || (c >= 160 && !in_utf(ldisc->term))) - return 1; - else if (c < 128) - return 2; /* ^x for some x */ - else if (in_utf(ldisc->term) && c >= 0xC0) - return 1; /* UTF-8 introducer character - * (FIXME: combining / wide chars) */ - else if (in_utf(ldisc->term) && c >= 0x80 && c < 0xC0) - return 0; /* UTF-8 followup character */ - else - return 4; /* hex representation */ -} - -static void pwrite(Ldisc *ldisc, unsigned char c) -{ - if ((c >= 32 && c <= 126) || - (!in_utf(ldisc->term) && c >= 0xA0) || - (in_utf(ldisc->term) && c >= 0x80)) { - c_write(ldisc, &c, 1); - } else if (c < 128) { - char cc[2]; - cc[1] = (c == 127 ? '?' : c + 0x40); - cc[0] = '^'; - c_write(ldisc, cc, 2); - } else { - char cc[5]; - sprintf(cc, "<%02X>", c); - c_write(ldisc, cc, 4); - } -} - -static bool char_start(Ldisc *ldisc, unsigned char c) -{ - if (in_utf(ldisc->term)) - return (c < 0x80 || c >= 0xC0); - else - return true; -} - -static void bsb(Ldisc *ldisc, int n) -{ - while (n--) - c_write(ldisc, "\010 \010", 3); -} - static void ldisc_input_queue_callback(void *ctx); +static const TermLineEditorCallbackReceiverVtable ldisc_lineedit_receiver_vt; + #define CTRL(x) (x^'@') -#define KCTRL(x) ((x^'@') | 0x100) Ldisc *ldisc_create(Conf *conf, Terminal *term, Backend *backend, Seat *seat) { Ldisc *ldisc = snew(Ldisc); - - ldisc->buf = NULL; - ldisc->buflen = 0; - ldisc->bufsiz = 0; - ldisc->quotenext = false; + memset(ldisc, 0, sizeof(Ldisc)); ldisc->backend = backend; ldisc->term = term; @@ -147,12 +82,15 @@ Ldisc *ldisc_create(Conf *conf, Terminal *term, Backend *backend, Seat *seat) bufchain_init(&ldisc->input_queue); - ldisc->prompts = NULL; ldisc->input_queue_callback.fn = ldisc_input_queue_callback; ldisc->input_queue_callback.ctx = ldisc; - ldisc->input_queue_callback.queued = false; bufchain_set_callback(&ldisc->input_queue, &ldisc->input_queue_callback); + if (ldisc->term) { + ldisc->le_rcv.vt = &ldisc_lineedit_receiver_vt; + ldisc->le = lineedit_new(ldisc->term, 0, &ldisc->le_rcv); + } + ldisc_configure(ldisc, conf); /* Link ourselves into the backend and the terminal */ @@ -171,19 +109,28 @@ void ldisc_configure(Ldisc *ldisc, Conf *conf) ldisc->protocol = conf_get_int(conf, CONF_protocol); ldisc->localecho = conf_get_int(conf, CONF_localecho); ldisc->localedit = conf_get_int(conf, CONF_localedit); + + unsigned flags = 0; + if (ldisc->protocol == PROT_RAW) + flags |= LE_CRLF_NEWLINE; + if (ldisc->telnet_keyboard) + flags |= LE_INTERRUPT | LE_SUSPEND | LE_ABORT; + lineedit_modify_flags(ldisc->le, ~0U, flags); } void ldisc_free(Ldisc *ldisc) { bufchain_clear(&ldisc->input_queue); + while (ldisc->inchunk_head) { + struct input_chunk *oldhead = ldisc->inchunk_head; + ldisc->inchunk_head = ldisc->inchunk_head->next; + sfree(oldhead); + } + lineedit_free(ldisc->le); if (ldisc->term) ldisc->term->ldisc = NULL; if (ldisc->backend) backend_provide_ldisc(ldisc->backend, NULL); - if (ldisc->buf) - sfree(ldisc->buf); - if (ldisc->prompts && ldisc->prompts->ldisc_ptr_to_us == &ldisc->prompts) - ldisc->prompts->ldisc_ptr_to_us = NULL; delete_callbacks_for_context(ldisc); sfree(ldisc); } @@ -191,180 +138,119 @@ void ldisc_free(Ldisc *ldisc) void ldisc_echoedit_update(Ldisc *ldisc) { seat_echoedit_update(ldisc->seat, ECHOING, EDITING); + + /* + * If we've just turned off local line editing mode, and our + * TermLineEditor had a partial buffer, then send the contents of + * the buffer. Rationale: (a) otherwise you lose data; (b) the + * user quite likely typed the buffer contents _anticipating_ that + * local editing would be turned off shortly, and the event was + * slow arriving. + */ + if (!EDITING) + lineedit_send_line(ldisc->le); } -void ldisc_enable_prompt_callback(Ldisc *ldisc, prompts_t *prompts) +void ldisc_provide_userpass_le(Ldisc *ldisc, TermLineEditor *le) { /* - * Called by the terminal to indicate that there's a prompts_t - * currently in flight, or to indicate that one has just finished - * (by passing NULL). When ldisc->prompts is not null, we notify - * the terminal whenever new data arrives in our input queue, so - * that it can continue the interactive prompting process. + * Called by term_get_userpass_input to tell us when it has its + * own TermLineEditor processing a password prompt, so that we can + * inject our input into that instead of putting it into our own + * TermLineEditor or sending it straight to the backend. */ - ldisc->prompts = prompts; - if (prompts) - ldisc->prompts->ldisc_ptr_to_us = &ldisc->prompts; + ldisc->userpass_le = le; } -static void ldisc_input_queue_callback(void *ctx) +static inline bool is_dedicated_byte(char c, InputType type) { - /* - * Toplevel callback that is triggered whenever the input queue - * lengthens. If we're currently processing an interactive prompt, - * we call back the Terminal to tell it to do some more stuff with - * that prompt based on the new input. - */ - Ldisc *ldisc = (Ldisc *)ctx; - if (ldisc->term && ldisc->prompts) { + switch (type) { + case DEDICATED: + return true; + case NORMAL: + return false; + case NONINTERACTIVE: /* - * The integer return value from this call is discarded, - * because we have no channel to pass it on to the backend - * that originally wanted it. But that's OK, because if the - * return value is >= 0 (that is, the prompts are either - * completely filled in, or aborted by the user), then the - * terminal will notify the callback in the prompts_t, and - * when that calls term_get_userpass_input again, it will - * return the same answer again. + * Non-interactive input (e.g. from a paste) doesn't come with + * the ability to distinguish dedicated keypresses like Return + * from generic ones like Ctrl+M. So we just have to make up + * an answer to this question. In particular, we _must_ treat + * Ctrl+M as the Return key, because that's the only way a + * newline can be pasted at all. */ - term_get_userpass_input(ldisc->term, ldisc->prompts); + return c == '\r'; + default: + unreachable("those values should be exhaustive"); } } -static void ldisc_to_backend_raw( - Ldisc *ldisc, const void *vbuf, size_t len) +static void ldisc_input_queue_consume(Ldisc *ldisc, size_t size) { - if (backend_sendok(ldisc->backend)) { - backend_send(ldisc->backend, vbuf, len); - } else { - const char *buf = (const char *)vbuf; - while (len > 0) { - /* - * Encode raw data in input_queue, by storing large chunks - * as long as they don't include 0xFF, and pausing every - * time they do to escape it. - */ - const char *ff = memchr(buf, '\xFF', len); - size_t this_len = ff ? ff - buf : len; - if (this_len > 0) { - bufchain_add(&ldisc->input_queue, buf, len); - } else { - bufchain_add(&ldisc->input_queue, "\xFF\xFF", 2); - this_len = 1; - } - buf += this_len; - len -= this_len; + bufchain_consume(&ldisc->input_queue, size); + while (size > 0) { + size_t thissize = (size < ldisc->inchunk_head->size ? + size : ldisc->inchunk_head->size); + ldisc->inchunk_head->size -= thissize; + size -= thissize; + + if (!ldisc->inchunk_head->size) { + struct input_chunk *oldhead = ldisc->inchunk_head; + ldisc->inchunk_head = ldisc->inchunk_head->next; + if (!ldisc->inchunk_head) + ldisc->inchunk_tail = NULL; + sfree(oldhead); } } } -static void ldisc_to_backend_special( - Ldisc *ldisc, SessionSpecialCode code, int arg) +static void ldisc_lineedit_to_terminal( + TermLineEditorCallbackReceiver *rcv, ptrlen data) { - if (backend_sendok(ldisc->backend)) { - backend_special(ldisc->backend, code, arg); - } else { - /* - * Encode a session special in input_queue. - */ - unsigned char data[9]; - data[0] = 0xFF; - PUT_32BIT_MSB_FIRST(data+1, code); - PUT_32BIT_MSB_FIRST(data+5, arg); - assert(data[1] != 0xFF && - "SessionSpecialCode encoding collides with FF FF escape"); - bufchain_add(&ldisc->input_queue, data, 9); - } + Ldisc *ldisc = container_of(rcv, Ldisc, le_rcv); + if (ECHOING) + seat_stdout(ldisc->seat, data.ptr, data.len); } -bool ldisc_has_input_buffered(Ldisc *ldisc) +static void ldisc_lineedit_to_backend( + TermLineEditorCallbackReceiver *rcv, ptrlen data) { - return bufchain_size(&ldisc->input_queue) > 0; + Ldisc *ldisc = container_of(rcv, Ldisc, le_rcv); + backend_send(ldisc->backend, data.ptr, data.len); } -LdiscInputToken ldisc_get_input_token(Ldisc *ldisc) +static void ldisc_lineedit_special( + TermLineEditorCallbackReceiver *rcv, SessionSpecialCode code, int arg) { - assert(bufchain_size(&ldisc->input_queue) > 0 && - "You're not supposed to call this unless there is buffered input!"); - - LdiscInputToken tok; - - char c; - bufchain_fetch_consume(&ldisc->input_queue, &c, 1); - if (c != '\xFF') { - /* A literal non-FF byte */ - tok.is_special = false; - tok.chr = c; - return tok; - } else { - char data[8]; - - /* See if the byte after the FF is also FF, indicating a literal FF */ - bufchain_fetch_consume(&ldisc->input_queue, data, 1); - if (data[0] == '\xFF') { - tok.is_special = false; - tok.chr = '\xFF'; - return tok; - } - - /* If not, get the rest of an 8-byte chunk and decode a special */ - bufchain_fetch_consume(&ldisc->input_queue, data+1, 7); - tok.is_special = true; - tok.code = GET_32BIT_MSB_FIRST(data); - tok.arg = toint(GET_32BIT_MSB_FIRST(data+4)); - return tok; - } + Ldisc *ldisc = container_of(rcv, Ldisc, le_rcv); + backend_special(ldisc->backend, code, arg); } -static void ldisc_check_sendok_callback(void *ctx) +static void ldisc_lineedit_newline(TermLineEditorCallbackReceiver *rcv) { - Ldisc *ldisc = (Ldisc *)ctx; - - if (!(ldisc->backend && backend_sendok(ldisc->backend))) - return; - - /* - * Flush the ldisc input queue into the backend, which is now - * willing to receive the data. - */ - while (bufchain_size(&ldisc->input_queue) > 0) { - /* - * Process either a chunk of non-special data, or an FF - * escape, depending on whether the first thing we see is an - * FF byte. - */ - ptrlen data = bufchain_prefix(&ldisc->input_queue); - const char *ff = memchr(data.ptr, '\xFF', data.len); - if (ff != data.ptr) { - /* Send a maximal block of data not containing any - * difficult bytes. */ - if (ff) - data.len = ff - (const char *)data.ptr; - backend_send(ldisc->backend, data.ptr, data.len); - bufchain_consume(&ldisc->input_queue, data.len); - } else { - /* Decode either a special or an escaped FF byte. The - * easiest way to do this is to reuse the decoding code - * already in ldisc_get_input_token. */ - LdiscInputToken tok = ldisc_get_input_token(ldisc); - if (tok.is_special) - backend_special(ldisc->backend, tok.code, tok.arg); - else - backend_send(ldisc->backend, &tok.chr, 1); - } - } + Ldisc *ldisc = container_of(rcv, Ldisc, le_rcv); + if (ldisc->protocol == PROT_RAW) + backend_send(ldisc->backend, "\r\n", 2); + else if (ldisc->protocol == PROT_TELNET && ldisc->telnet_newline) + backend_special(ldisc->backend, SS_EOL, 0); + else + backend_send(ldisc->backend, "\r", 1); } +static const TermLineEditorCallbackReceiverVtable +ldisc_lineedit_receiver_vt = { + .to_terminal = ldisc_lineedit_to_terminal, + .to_backend = ldisc_lineedit_to_backend, + .special = ldisc_lineedit_special, + .newline = ldisc_lineedit_newline, +}; + void ldisc_check_sendok(Ldisc *ldisc) { - queue_toplevel_callback(ldisc_check_sendok_callback, ldisc); + queue_idempotent_callback(&ldisc->input_queue_callback); } void ldisc_send(Ldisc *ldisc, const void *vbuf, int len, bool interactive) { - const char *buf = (const char *)vbuf; - int keyflag = 0; - assert(ldisc->term); if (interactive) { @@ -379,202 +265,124 @@ void ldisc_send(Ldisc *ldisc, const void *vbuf, int len, bool interactive) term_nopaste(ldisc->term); } - /* - * Less than zero means null terminated special string. - */ + InputType type; if (len < 0) { - len = strlen(buf); - keyflag = KCTRL('@'); - } - /* - * Either perform local editing, or just send characters. - */ - if (EDITING) { - while (len--) { - int c; - c = (unsigned char)(*buf++) + keyflag; - if (!interactive && c == '\r') - c += KCTRL('@'); - switch (ldisc->quotenext ? ' ' : c) { - /* - * ^h/^?: delete, and output BSBs, to return to - * last character boundary (in UTF-8 mode this may - * be more than one byte) - * ^w: delete, and output BSBs, to return to last - * space/nonspace boundary - * ^u: delete, and output BSBs, to return to BOL - * ^c: Do a ^u then send a telnet IP - * ^z: Do a ^u then send a telnet SUSP - * ^\: Do a ^u then send a telnet ABORT - * ^r: echo "^R\n" and redraw line - * ^v: quote next char - * ^d: if at BOL, end of file and close connection, - * else send line and reset to BOL - * ^m: send line-plus-\r\n and reset to BOL - */ - case KCTRL('H'): - case KCTRL('?'): /* backspace/delete */ - if (ldisc->buflen > 0) { - do { - if (ECHOING) - bsb(ldisc, plen(ldisc, ldisc->buf[ldisc->buflen - 1])); - ldisc->buflen--; - } while (!char_start(ldisc, ldisc->buf[ldisc->buflen])); - } - break; - case CTRL('W'): /* delete word */ - while (ldisc->buflen > 0) { - if (ECHOING) - bsb(ldisc, plen(ldisc, ldisc->buf[ldisc->buflen - 1])); - ldisc->buflen--; - if (ldisc->buflen > 0 && - isspace((unsigned char)ldisc->buf[ldisc->buflen-1]) && - !isspace((unsigned char)ldisc->buf[ldisc->buflen])) - break; - } - break; - case CTRL('U'): /* delete line */ - case CTRL('C'): /* Send IP */ - case CTRL('\\'): /* Quit */ - case CTRL('Z'): /* Suspend */ - while (ldisc->buflen > 0) { - if (ECHOING) - bsb(ldisc, plen(ldisc, ldisc->buf[ldisc->buflen - 1])); - ldisc->buflen--; - } - if (c == CTRL('U')) - break; /* ^U *just* erases a line */ - ldisc_to_backend_special(ldisc, SS_EL, 0); - /* - * We don't send IP, SUSP or ABORT if the user has - * configured telnet specials off! This breaks - * talkers otherwise. - */ - if (!ldisc->telnet_keyboard) - goto default_case; - if (c == CTRL('C')) - ldisc_to_backend_special(ldisc, SS_IP, 0); - if (c == CTRL('Z')) - ldisc_to_backend_special(ldisc, SS_SUSP, 0); - if (c == CTRL('\\')) - ldisc_to_backend_special(ldisc, SS_ABORT, 0); - break; - case CTRL('R'): /* redraw line */ - if (ECHOING) { - int i; - c_write(ldisc, "^R\r\n", 4); - for (i = 0; i < ldisc->buflen; i++) - pwrite(ldisc, ldisc->buf[i]); - } - break; - case CTRL('V'): /* quote next char */ - ldisc->quotenext = true; - break; - case CTRL('D'): /* logout or send */ - if (ldisc->buflen == 0) { - ldisc_to_backend_special(ldisc, SS_EOF, 0); - } else { - ldisc_to_backend_raw(ldisc, ldisc->buf, ldisc->buflen); - ldisc->buflen = 0; - } - break; - /* - * This particularly hideous bit of code from RDB - * allows ordinary ^M^J to do the same thing as - * magic-^M when in Raw protocol. The line `case - * KCTRL('M'):' is _inside_ the if block. Thus: - * - * - receiving regular ^M goes straight to the - * default clause and inserts as a literal ^M. - * - receiving regular ^J _not_ directly after a - * literal ^M (or not in Raw protocol) fails the - * if condition, leaps to the bottom of the if, - * and falls through into the default clause - * again. - * - receiving regular ^J just after a literal ^M - * in Raw protocol passes the if condition, - * deletes the literal ^M, and falls through - * into the magic-^M code - * - receiving a magic-^M empties the line buffer, - * signals end-of-line in one of the various - * entertaining ways, and _doesn't_ fall out of - * the bottom of the if and through to the - * default clause because of the break. - */ - case CTRL('J'): - if (ldisc->protocol == PROT_RAW && - ldisc->buflen > 0 && ldisc->buf[ldisc->buflen - 1] == '\r') { - if (ECHOING) - bsb(ldisc, plen(ldisc, ldisc->buf[ldisc->buflen - 1])); - ldisc->buflen--; - /* FALLTHROUGH */ - case KCTRL('M'): /* send with newline */ - if (ldisc->buflen > 0) - ldisc_to_backend_raw(ldisc, ldisc->buf, ldisc->buflen); - if (ldisc->protocol == PROT_RAW) - ldisc_to_backend_raw(ldisc, "\r\n", 2); - else if (ldisc->protocol == PROT_TELNET && ldisc->telnet_newline) - ldisc_to_backend_special(ldisc, SS_EOL, 0); - else - ldisc_to_backend_raw(ldisc, "\r", 1); - if (ECHOING) - c_write(ldisc, "\r\n", 2); - ldisc->buflen = 0; - break; - } - /* FALLTHROUGH */ - default: /* get to this label from ^V handler */ - default_case: - sgrowarray(ldisc->buf, ldisc->bufsiz, ldisc->buflen); - ldisc->buf[ldisc->buflen++] = c; - if (ECHOING) - pwrite(ldisc, (unsigned char) c); - ldisc->quotenext = false; - break; - } - } + /* + * Less than zero means null terminated special string. + */ + len = strlen(vbuf); + type = DEDICATED; } else { - if (ldisc->buflen != 0) { - ldisc_to_backend_raw(ldisc, ldisc->buf, ldisc->buflen); - while (ldisc->buflen > 0) { - bsb(ldisc, plen(ldisc, ldisc->buf[ldisc->buflen - 1])); - ldisc->buflen--; - } - } - if (len > 0) { - if (ECHOING) - c_write(ldisc, buf, len); - if (keyflag && ldisc->protocol == PROT_TELNET && len == 1) { - switch (buf[0]) { - case CTRL('M'): - if (ldisc->protocol == PROT_TELNET && ldisc->telnet_newline) - ldisc_to_backend_special(ldisc, SS_EOL, 0); - else - ldisc_to_backend_raw(ldisc, "\r", 1); - break; - case CTRL('?'): - case CTRL('H'): - if (ldisc->telnet_keyboard) { - ldisc_to_backend_special(ldisc, SS_EC, 0); - break; - } - case CTRL('C'): - if (ldisc->telnet_keyboard) { - ldisc_to_backend_special(ldisc, SS_IP, 0); - break; - } - case CTRL('Z'): - if (ldisc->telnet_keyboard) { - ldisc_to_backend_special(ldisc, SS_SUSP, 0); - break; - } + type = interactive ? NORMAL : NONINTERACTIVE; + } - default: - ldisc_to_backend_raw(ldisc, buf, len); - break; + /* + * Append our data to input_queue, and ensure it's marked with the + * right type. + */ + bufchain_add(&ldisc->input_queue, vbuf, len); + if (!(ldisc->inchunk_tail && ldisc->inchunk_tail->type == type)) { + struct input_chunk *new_chunk = snew(struct input_chunk); + + new_chunk->type = type; + new_chunk->size = 0; + + new_chunk->next = NULL; + if (ldisc->inchunk_tail) + ldisc->inchunk_tail->next = new_chunk; + else + ldisc->inchunk_head = new_chunk; + ldisc->inchunk_tail = new_chunk; + } + ldisc->inchunk_tail->size += len; + + /* + * And process as much of the data immediately as we can. + */ + ldisc_input_queue_callback(ldisc); +} + +static void ldisc_input_queue_callback(void *ctx) +{ + Ldisc *ldisc = (Ldisc *)ctx; + + /* + * Toplevel callback that is triggered whenever the input queue + * lengthens. + */ + while (bufchain_size(&ldisc->input_queue)) { + ptrlen pl = bufchain_prefix(&ldisc->input_queue); + const char *start = pl.ptr, *buf = pl.ptr; + size_t len = (pl.len < ldisc->inchunk_head->size ? + pl.len : ldisc->inchunk_head->size); + InputType type = ldisc->inchunk_head->type; + + while (len > 0 && ldisc->userpass_le) { + char c = *buf++; + len--; + + bool dedicated = is_dedicated_byte(c, type); + lineedit_input(ldisc->userpass_le, c, dedicated); + } + + if (!backend_sendok(ldisc->backend)) { + ldisc_input_queue_consume(ldisc, buf - start); + break; + } + + /* + * Either perform local editing, or just send characters. + */ + if (EDITING) { + while (len > 0) { + char c = *buf++; + len--; + + bool dedicated = is_dedicated_byte(c, type); + lineedit_input(ldisc->le, c, dedicated); + } + ldisc_input_queue_consume(ldisc, buf - start); + } else { + if (ECHOING) + seat_stdout(ldisc->seat, buf, len); + if (type == DEDICATED && ldisc->protocol == PROT_TELNET) { + while (len > 0) { + char c = *buf++; + len--; + switch (c) { + case CTRL('M'): + if (ldisc->telnet_newline) + backend_special(ldisc->backend, SS_EOL, 0); + else + backend_send(ldisc->backend, "\r", 1); + break; + case CTRL('?'): + case CTRL('H'): + if (ldisc->telnet_keyboard) { + backend_special(ldisc->backend, SS_EC, 0); + break; + } + case CTRL('C'): + if (ldisc->telnet_keyboard) { + backend_special(ldisc->backend, SS_IP, 0); + break; + } + case CTRL('Z'): + if (ldisc->telnet_keyboard) { + backend_special(ldisc->backend, SS_SUSP, 0); + break; + } + + default: + backend_send(ldisc->backend, &c, 1); + break; + } } - } else - ldisc_to_backend_raw(ldisc, buf, len); + ldisc_input_queue_consume(ldisc, buf - start); + } else { + backend_send(ldisc->backend, buf, len); + ldisc_input_queue_consume(ldisc, len); + } } } } diff --git a/putty.h b/putty.h index 96c5ab34..a6fbeb54 100644 --- a/putty.h +++ b/putty.h @@ -2369,28 +2369,7 @@ void ldisc_configure(Ldisc *, Conf *); void ldisc_free(Ldisc *); void ldisc_send(Ldisc *, const void *buf, int len, bool interactive); void ldisc_echoedit_update(Ldisc *); -typedef struct LdiscInputToken { - /* - * Structure that encodes any single item of data that Ldisc can - * buffer: either a single character of raw data, or a session - * special. - */ - bool is_special; - union { - struct { - /* if is_special == false */ - char chr; - }; - struct { - /* if is_special == true */ - SessionSpecialCode code; - int arg; - }; - }; -} LdiscInputToken; -bool ldisc_has_input_buffered(Ldisc *); -LdiscInputToken ldisc_get_input_token(Ldisc *); /* asserts there is input */ -void ldisc_enable_prompt_callback(Ldisc *, prompts_t *); +void ldisc_provide_userpass_le(Ldisc *, TermLineEditor *); void ldisc_check_sendok(Ldisc *); /* diff --git a/stubs/no-lineedit.c b/stubs/no-lineedit.c new file mode 100644 index 00000000..219572d4 --- /dev/null +++ b/stubs/no-lineedit.c @@ -0,0 +1,18 @@ +/* + * Stubs of functions in lineedit.c, for use in programs that don't + * have any use for line editing (e.g. because they don't have a + * terminal either). + */ + +#include "putty.h" +#include "terminal.h" + +TermLineEditor *lineedit_new(Terminal *term, unsigned flags, + TermLineEditorCallbackReceiver *receiver) +{ + return NULL; +} +void lineedit_free(TermLineEditor *le) {} +void lineedit_input(TermLineEditor *le, char ch, bool dedicated) {} +void lineedit_modify_flags(TermLineEditor *le, unsigned clr, unsigned flip) {} +void lineedit_send_line(TermLineEditor *le) {} diff --git a/terminal/lineedit.c b/terminal/lineedit.c new file mode 100644 index 00000000..eaaa3a18 --- /dev/null +++ b/terminal/lineedit.c @@ -0,0 +1,520 @@ +/* + * Implementation of local line editing. Used during username and + * password input at login time, and also by ldisc during the main + * session (if the session's virtual terminal is in that mode). + * + * Because we're tied into a real GUI terminal (and not a completely + * standalone line-discipline module that deals purely with byte + * streams), we can support a slightly richer input interface than + * plain bytes. + * + * In particular, the 'dedicated' flag sent along with every byte is + * used to distinguish control codes input via Ctrl+letter from the + * same code input by a dedicated key like Return or Backspace. This + * allows us to interpret the Ctrl+letter key combination as inputting + * a literal control character to go into the line buffer, and the + * dedicated-key version as performing an editing function. + */ + +#include "putty.h" +#include "terminal.h" + +typedef struct BufChar BufChar; + +struct TermLineEditor { + Terminal *term; + BufChar *head, *tail; + unsigned flags; + bool quote_next_char; + TermLineEditorCallbackReceiver *receiver; +}; + +struct BufChar { + BufChar *prev, *next; + + /* The bytes of the character, to be sent on the wire */ + char wire[6]; + uint8_t nwire; + + /* Whether this character is considered complete */ + bool complete; + + /* Width of the character when it was displayed, in terminal cells */ + uint8_t width; + + /* Whether this character counts as whitespace, for ^W purposes */ + bool space; +}; + +TermLineEditor *lineedit_new(Terminal *term, unsigned flags, + TermLineEditorCallbackReceiver *receiver) +{ + TermLineEditor *le = snew(TermLineEditor); + le->term = term; + le->head = le->tail = NULL; + le->flags = flags; + le->quote_next_char = false; + le->receiver = receiver; + return le; +} + +static void bufchar_free(BufChar *bc) +{ + smemclr(bc, sizeof(*bc)); + sfree(bc); +} + +static void lineedit_free_buffer(TermLineEditor *le) +{ + while (le->head) { + BufChar *bc = le->head; + le->head = bc->next; + bufchar_free(bc); + } + le->tail = NULL; +} + +void lineedit_free(TermLineEditor *le) +{ + lineedit_free_buffer(le); + sfree(le); +} + +void lineedit_modify_flags(TermLineEditor *le, unsigned clr, unsigned flip) +{ + le->flags &= ~clr; + le->flags ^= flip; +} + +static void lineedit_term_write(TermLineEditor *le, ptrlen data) +{ + le->receiver->vt->to_terminal(le->receiver, data); +} + +static void lineedit_term_newline(TermLineEditor *le) +{ + lineedit_term_write(le, PTRLEN_LITERAL("\x0D\x0A")); +} + +static inline void lineedit_send_data(TermLineEditor *le, ptrlen data) +{ + le->receiver->vt->to_backend(le->receiver, data); +} + +static inline void lineedit_special(TermLineEditor *le, + SessionSpecialCode code, int arg) +{ + le->receiver->vt->special(le->receiver, code, arg); +} + +static inline void lineedit_send_newline(TermLineEditor *le) +{ + le->receiver->vt->newline(le->receiver); +} + +static void lineedit_delete_char(TermLineEditor *le) +{ + if (le->tail) { + BufChar *bc = le->tail; + le->tail = bc->prev; + if (!le->tail) + le->head = NULL; + else + le->tail->next = NULL; + + for (unsigned i = 0; i < bc->width; i++) + lineedit_term_write(le, PTRLEN_LITERAL("\x08 \x08")); + + bufchar_free(bc); + } +} + +static void lineedit_delete_word(TermLineEditor *le) +{ + /* + * Deleting a word stops at the _start_ of a word, i.e. at any + * boundary with a space on the left and a non-space on the right. + */ + if (!le->tail) + return; + + while (true) { + bool deleted_char_is_space = le->tail->space; + lineedit_delete_char(le); + if (!le->tail) + break; /* we've cleared the whole line */ + if (le->tail->space && !deleted_char_is_space) + break; /* we've just reached a word boundary */ + } +} + +static void lineedit_delete_line(TermLineEditor *le) +{ + while (le->tail) + lineedit_delete_char(le); + lineedit_special(le, SS_EL, 0); +} + +void lineedit_send_line(TermLineEditor *le) +{ + for (BufChar *bc = le->head; bc; bc = bc->next) + lineedit_send_data(le, make_ptrlen(bc->wire, bc->nwire)); + lineedit_free_buffer(le); + le->quote_next_char = false; +} + +static void lineedit_complete_line(TermLineEditor *le) +{ + lineedit_term_newline(le); + lineedit_send_line(le); + lineedit_send_newline(le); +} + +/* + * Send data to the terminal to display a BufChar. As a side effect, + * update bc->width to indicate how many character cells we think were + * taken up by what we just wrote. No other change to bc is made. + */ +static void lineedit_display_bufchar(TermLineEditor *le, BufChar *bc, + unsigned chr) +{ + char buf[6]; + buffer_sink bs[1]; + buffer_sink_init(bs, buf, lenof(buf)); + + /* Handle single-byte character set translation. */ + if (!in_utf(le->term) && DIRECT_CHAR(chr)) { + /* + * If we're not in UTF-8, i.e. we're in a single-byte + * character set, then first we must feed the input byte + * through term_translate, which will tell us whether it's a + * control character or not. (That varies with the charset: + * e.g. ISO 8859-1 and Win1252 disagree on a lot of + * 0x80-0x9F). + * + * In principle, we could pass NULL as our term_utf8_decode + * pointer, on the grounds that since the terminal isn't in + * UTF-8 mode term_translate shouldn't access it. But that + * seems needlessly reckless; we'll make up an empty one. + */ + term_utf8_decode dummy_utf8 = { .state = 0, .chr = 0, .size = 0 }; + chr = term_translate( + le->term, &dummy_utf8, (unsigned char)chr); + + /* + * After that, chr will be either a control-character value + * (00-1F, 7F, 80-9F), or a byte value ORed with one of the + * CSET_FOO character set indicators. The latter indicates + * that it's a printing character in this charset, in which + * case it takes up one character cell. + */ + if (chr & CSET_MASK) { + put_byte(bs, chr); + bc->width = 1; + goto got_char; + } + } + + /* + * If we got here without taking the 'goto' above, then we're now + * holding an actual Unicode character. + */ + assert(!IS_SURROGATE(chr)); /* and it should be an _actual_ one */ + + /* + * Deal with symbolic representations of control characters. + */ + + if (chr < 0x20 || chr == 0x7F) { + /* + * Represent C0 controls as '^C' or similar, and 7F as ^?. + */ + put_byte(bs, '^'); + put_byte(bs, chr ^ 0x40); + bc->width = 2; + goto got_char; + } + + if (chr >= 0x80 && chr < 0xA0) { + /* + * Represent C1 controls as <9B> or similar. + */ + put_fmt(bs, "<%02X>", chr); + bc->width = 4; + goto got_char; + } + + /* + * And if we get _here_, we're holding a printing (or at least not + * _control_, even if zero-width) Unicode character, which _must_ + * mean that the terminal is currently in UTF-8 mode (since if it + * were not then printing characters would have gone through the + * term_translate case above). So we can just write the UTF-8 for + * the character - but we must also pay attention to its width in + * character cells, which might be 0, 1 or 2. + */ + assert(in_utf(le->term)); + put_utf8_char(bs, chr); + bc->width = term_char_width(le->term, chr); + + got_char: + lineedit_term_write(le, make_ptrlen(buf, bs->out - buf)); +} + +/* Called when we've just added a byte to a UTF-8 character and want + * to see if it's complete */ +static void lineedit_check_utf8_complete(TermLineEditor *le, BufChar *bc) +{ + BinarySource src[1]; + BinarySource_BARE_INIT(src, bc->wire, bc->nwire); + DecodeUTF8Failure err; + unsigned chr = decode_utf8(src, &err); + if (err == DUTF8_E_OUT_OF_DATA) + return; /* not complete yet */ + + /* Any other error code is regarded as complete, and we just + * display the character as the U+FFFD that decode_utf8 will have + * returned anyway */ + bc->complete = true; + bc->space = (chr == ' '); + lineedit_display_bufchar(le, bc, chr); +} + +static void lineedit_input_printing_char(TermLineEditor *le, char ch); + +static void lineedit_redraw_line(TermLineEditor *le) +{ + /* FIXME: I'm not 100% sure this is the behaviour I really want in + * this situation, but it's at least very simple to implement */ + BufChar *prevhead = le->head; + le->head = le->tail = NULL; + while (prevhead) { + BufChar *bc = prevhead; + prevhead = prevhead->next; + + for (unsigned i = 0; i < bc->nwire; i++) + lineedit_input_printing_char(le, bc->wire[i]); + bufchar_free(bc); + } +} + +#define CTRL(c) ((char) (0x40 ^ (unsigned char)c)) + +void lineedit_input(TermLineEditor *le, char ch, bool dedicated) +{ + if (le->quote_next_char) { + /* + * If the previous keypress was ^V, 'quoting' the next + * character to be treated literally, then skip all the + * editing-control processing, and clear that flag. + */ + le->quote_next_char = false; + } else { + /* + * Input events that are only valid with the 'dedicated' flag. + * These are limited to the control codes that _have_ + * dedicated keys. + * + * Any case we actually handle here ends with a 'return' + * statement, so that if we fall out of the end of this switch + * at all, it's because the byte hasn't been handled here and + * will fall into the next switch dealing with ordinary input. + */ + if (dedicated) { + switch (ch) { + /* + * The Backspace key. + * + * Since our terminal can be configured to send either + * ^H or 7F (aka ^?) via the backspace key, we accept + * both. + * + * (We could query the Terminal's configuration here + * and accept only the one of those codes that the + * terminal is currently set to. But it's pointless, + * because whichever one the terminal isn't set to, + * the front end won't be sending it with + * dedicated=true anyway.) + */ + case CTRL('H'): + case 0x7F: + lineedit_delete_char(le); + return; + + /* + * The Return key. + */ + case CTRL('M'): + lineedit_complete_line(le); + return; + } + } + + /* + * Editing and special functions in response to ordinary keys + * or Ctrl+key combinations. Many editing functions have to be + * supported in this mode, like ^W and ^U, because there are + * no dedicated keys that generate the same control codes + * anyway. + * + * Again, we return if the key was handled. The final + * processing of ordinary data to go into the input buffer + * happens if we break from this switch. + */ + switch (ch) { + case CTRL('W'): + lineedit_delete_word(le); + return; + + case CTRL('U'): + lineedit_delete_line(le); + return; + + case CTRL('['): + if (!(le->flags & LE_ESC_ERASES)) + break; /* treat as normal input */ + lineedit_delete_line(le); + return; + + case CTRL('R'): + lineedit_term_write(le, PTRLEN_LITERAL("^R")); + lineedit_term_newline(le); + lineedit_redraw_line(le); + return; + + case CTRL('V'): + le->quote_next_char = true; + return; + + case CTRL('C'): + lineedit_delete_line(le); + if (!(le->flags & LE_INTERRUPT)) + break; /* treat as normal input */ + lineedit_special(le, SS_IP, 0); + return; + + case CTRL('Z'): + lineedit_delete_line(le); + if (!(le->flags & LE_SUSPEND)) + break; /* treat as normal input */ + lineedit_special(le, SS_SUSP, 0); + return; + + case CTRL('\\'): + lineedit_delete_line(le); + if (!(le->flags & LE_ABORT)) + break; /* treat as normal input */ + lineedit_special(le, SS_ABORT, 0); + return; + + case CTRL('D'): + if (le->flags & LE_EOF_ALWAYS) { + /* Our client wants to treat ^D / EOF as a special + * character in their own way. Just send an EOF + * special. */ + lineedit_special(le, SS_EOF, 0); + return; + } + + /* + * Otherwise, ^D has the same behaviour as in Unix tty + * line editing: if the edit buffer is non-empty then it's + * sent immediately without a newline, and if it is empty + * then an EOF is sent. + */ + if (le->head) { + lineedit_send_line(le); + return; + } + + lineedit_special(le, SS_EOF, 0); + return; + + case CTRL('J'): + if (le->flags & LE_CRLF_NEWLINE) { + /* + * If the previous character in the buffer is a + * literal Ctrl-M, and now the user sends Ctrl-J, then + * we amalgamate both into a newline event. + */ + if (le->tail && le->tail->nwire == 1 && + le->tail->wire[0] == CTRL('M')) { + lineedit_delete_char(le); /* erase ^J from buffer */ + lineedit_complete_line(le); + return; + } + } + } + } + + /* + * If we get to here, we've exhausted the options for treating our + * character as an editing or special function of any kind. Treat + * it as a printing character, or part of one. + */ + lineedit_input_printing_char(le, ch); +} + +static void lineedit_input_printing_char(TermLineEditor *le, char ch) +{ + /* + * Append ch to the line buffer, either as a new BufChar or by + * adding it to a previous one containing an as yet incomplete + * UTF-8 encoding. + */ + if (le->tail && !le->tail->complete) { + BufChar *bc = le->tail; + + /* + * If we're in UTF-8 mode, and ch is a UTF-8 continuation + * byte, then we can append it to bc, which we've just checked + * is missing at least one of those. + */ + if (in_utf(le->term) && (unsigned char)ch - 0x80U < 0x40) { + assert(bc->nwire < lenof(bc->wire)); + bc->wire[bc->nwire++] = ch; + lineedit_check_utf8_complete(le, bc); + return; + } + + /* + * Otherwise, the previous incomplete character can't be + * extended. Mark it as complete, and if possible, display it + * as a replacement character indicating that something weird + * happened. + */ + bc->complete = true; + if (in_utf(le->term)) + lineedit_display_bufchar(le, bc, 0xFFFD); + + /* + * But we still haven't processed the byte we're holding. Fall + * through to the next step, where we make a fresh BufChar for + * it. + */ + } + + /* + * Make a fresh BufChar. + */ + BufChar *bc = snew(BufChar); + bc->prev = le->tail; + le->tail = bc; + if (bc->prev) + bc->prev->next = bc; + else + le->head = bc; + bc->next = NULL; + bc->complete = false; + bc->space = false; + + bc->nwire = 1; + bc->wire[0] = ch; + if (in_utf(le->term)) { + lineedit_check_utf8_complete(le, bc); + } else { + bc->complete = true; /* always, in a single-byte charset */ + bc->space = (bc->wire[0] == ' '); + lineedit_display_bufchar(le, bc, CSET_ASCII | (unsigned char)ch); + } +} diff --git a/terminal/terminal.c b/terminal/terminal.c index b6e11c3f..a90390b1 100644 --- a/terminal/terminal.c +++ b/terminal/terminal.c @@ -62,6 +62,9 @@ static const char sco2ansicolour[] = { 0, 4, 2, 6, 1, 5, 3, 7 }; #define sel_nl_sz (sizeof(sel_nl)/sizeof(wchar_t)) static const wchar_t sel_nl[] = SEL_NL; +/* forward declaration */ +static void term_userpass_state_free(struct term_userpass_state *s); + /* * Fetch the character at a particular position in a line array, * for purposes of `wordtype'. The reason this isn't just a simple @@ -2128,6 +2131,8 @@ Terminal *term_init(Conf *myconf, struct unicode_data *ucsdata, TermWin *win) term->bidi_ctx = bidi_new_context(); + term->userpass_state = NULL; + palette_reset(term, false); return term; @@ -2190,6 +2195,10 @@ void term_free(Terminal *term) bidi_free_context(term->bidi_ctx); + /* In case a term_userpass_state is still around */ + if (term->userpass_state) + term_userpass_state_free(term->userpass_state); + sfree(term); } @@ -7823,15 +7832,19 @@ char *term_get_ttymode(Terminal *term, const char *mode) } struct term_userpass_state { + prompts_t *prompts; size_t curr_prompt; - bool done_prompt; /* printed out prompt yet? */ + enum TermUserpassPromptState { + TUS_INITIAL, /* haven't even printed the prompt yet */ + TUS_ACTIVE, /* prompt is currently receiving user input */ + TUS_ABORTED, /* user pressed ^C or ^D to cancel prompt */ + } prompt_state; + Terminal *term; + TermLineEditor *le; + TermLineEditorCallbackReceiver le_rcv; }; -/* Tiny wrapper to make it easier to write lots of little strings */ -static inline void term_write(Terminal *term, ptrlen data) -{ - term_data(term, data.ptr, data.len); -} +static void term_userpass_next_prompt(struct term_userpass_state *s); /* * Signal that a prompts_t is done. This involves sending a @@ -7844,11 +7857,123 @@ static inline SeatPromptResult signal_prompts_t(Terminal *term, prompts_t *p, assert(p->callback && "Asynchronous userpass input requires a callback"); queue_toplevel_callback(p->callback, p->callback_ctx); if (term->ldisc) - ldisc_enable_prompt_callback(term->ldisc, NULL); + ldisc_provide_userpass_le(term->ldisc, NULL); p->spr = spr; + if (p->data) { + term_userpass_state_free(p->data); + p->data = NULL; + } return spr; } +/* Tiny wrapper to make it easier to write lots of little strings */ +static inline void term_write(Terminal *term, ptrlen data) +{ + term_data(term, data.ptr, data.len); +} + +static void term_lineedit_to_terminal( + TermLineEditorCallbackReceiver *rcv, ptrlen data) +{ + struct term_userpass_state *s = container_of( + rcv, struct term_userpass_state, le_rcv); + prompt_t *pr = s->prompts->prompts[s->curr_prompt]; + if (pr->echo) + term_write(s->term, data); +} + +static void term_lineedit_to_backend( + TermLineEditorCallbackReceiver *rcv, ptrlen data) +{ + struct term_userpass_state *s = container_of( + rcv, struct term_userpass_state, le_rcv); + prompt_t *pr = s->prompts->prompts[s->curr_prompt]; + put_datapl(pr->result, data); +} + +static void term_lineedit_newline(TermLineEditorCallbackReceiver *rcv) +{ + struct term_userpass_state *s = container_of( + rcv, struct term_userpass_state, le_rcv); + + prompt_t *pr = s->prompts->prompts[s->curr_prompt]; + if (!pr->echo) { + /* If echo is disabled, we won't have printed the newline in + * term_lineedit_to_terminal, so print it now */ + term_write(s->term, PTRLEN_LITERAL("\x0D\x0A")); + } + + ldisc_provide_userpass_le(s->term->ldisc, NULL); + s->curr_prompt++; + s->prompt_state = TUS_INITIAL; + term_userpass_next_prompt(s); +} + +static void term_lineedit_special( + TermLineEditorCallbackReceiver *rcv, SessionSpecialCode code, int arg) +{ + struct term_userpass_state *s = container_of( + rcv, struct term_userpass_state, le_rcv); + switch (code) { + case SS_IP: + case SS_EOF: + ldisc_provide_userpass_le(s->term->ldisc, NULL); + s->prompt_state = TUS_ABORTED; + signal_prompts_t(s->term, s->prompts, SPR_USER_ABORT); + default: + break; + } +} + +static const TermLineEditorCallbackReceiverVtable +term_userpass_lineedit_receiver_vt = { + .to_terminal = term_lineedit_to_terminal, + .to_backend = term_lineedit_to_backend, + .special = term_lineedit_special, + .newline = term_lineedit_newline, +}; + +static struct term_userpass_state *term_userpass_state_new( + Terminal *term, prompts_t *prompts) +{ + struct term_userpass_state *s = snew(struct term_userpass_state); + s->prompts = prompts; + s->curr_prompt = 0; + s->prompt_state = TUS_INITIAL; + s->term = term; + s->le_rcv.vt = &term_userpass_lineedit_receiver_vt; + s->le = lineedit_new(term, LE_INTERRUPT | LE_EOF_ALWAYS | LE_ESC_ERASES, + &s->le_rcv); + assert(!term->userpass_state); + term->userpass_state = s; + return s; +} + +static void term_userpass_state_free(struct term_userpass_state *s) +{ + assert(s->term->userpass_state == s); + s->term->userpass_state = NULL; + lineedit_free(s->le); + sfree(s); +} + +static void term_userpass_next_prompt(struct term_userpass_state *s) +{ + if (s->prompt_state != TUS_INITIAL) + return; + if (s->curr_prompt < s->prompts->n_prompts) { + prompt_t *pr = s->prompts->prompts[s->curr_prompt]; + term_write(s->term, ptrlen_from_asciz(pr->prompt)); + s->prompt_state = TUS_ACTIVE; + ldisc_provide_userpass_le(s->term->ldisc, s->le); + } else { + /* This triggers the callback provided by the userpass client, + * which will call term_userpass_state to fetch the result + * we're storing here */ + signal_prompts_t(s->term, s->prompts, SPR_OK); + } +} + /* * Process some terminal data in the course of username/password * input. @@ -7873,10 +7998,8 @@ SeatPromptResult term_get_userpass_input(Terminal *term, prompts_t *p) /* * First call. Set some stuff up. */ - p->data = s = snew(struct term_userpass_state); + p->data = s = term_userpass_state_new(term, p); p->spr = SPR_INCOMPLETE; - s->curr_prompt = 0; - s->done_prompt = false; /* We only print the `name' caption if we have to... */ if (p->name_reqd && p->name) { ptrlen plname = ptrlen_from_asciz(p->name); @@ -7899,98 +8022,11 @@ SeatPromptResult term_get_userpass_input(Terminal *term, prompts_t *p) for (i = 0; i < (int)p->n_prompts; i++) prompt_set_result(p->prompts[i], ""); } + /* And print the first prompt. */ + term_userpass_next_prompt(s); } - while (s->curr_prompt < p->n_prompts) { - - prompt_t *pr = p->prompts[s->curr_prompt]; - bool finished_prompt = false; - - if (!s->done_prompt) { - term_write(term, ptrlen_from_asciz(pr->prompt)); - s->done_prompt = true; - } - - /* Breaking out here ensures that the prompt is printed even - * if we're now waiting for user data. */ - if (!ldisc_has_input_buffered(term->ldisc)) - break; - - /* FIXME: should we be using local-line-editing code instead? */ - while (!finished_prompt && ldisc_has_input_buffered(term->ldisc)) { - LdiscInputToken tok = ldisc_get_input_token(term->ldisc); - - char c; - if (tok.is_special) { - switch (tok.code) { - case SS_EOL: c = 13; break; - case SS_EC: c = 8; break; - case SS_IP: c = 3; break; - case SS_EOF: c = 3; break; - default: continue; - } - } else { - c = tok.chr; - } - - switch (c) { - case 10: - case 13: - term_write(term, PTRLEN_LITERAL("\r\n")); - /* go to next prompt, if any */ - s->curr_prompt++; - s->done_prompt = false; - finished_prompt = true; /* break out */ - break; - case 8: - case 127: - if (pr->result->len > 0) { - if (pr->echo) - term_write(term, PTRLEN_LITERAL("\b \b")); - strbuf_shrink_by(pr->result, 1); - } - break; - case 21: - case 27: - while (pr->result->len > 0) { - if (pr->echo) - term_write(term, PTRLEN_LITERAL("\b \b")); - strbuf_shrink_by(pr->result, 1); - } - break; - case 3: - case 4: - /* Immediate abort. */ - term_write(term, PTRLEN_LITERAL("\r\n")); - sfree(s); - p->data = NULL; - return signal_prompts_t(term, p, SPR_USER_ABORT); - default: - /* - * This simplistic check for printability is disabled - * when we're doing password input, because some people - * have control characters in their passwords. - */ - if (!pr->echo || (c >= ' ' && c <= '~') || - ((unsigned char) c >= 160)) { - put_byte(pr->result, c); - if (pr->echo) - term_write(term, make_ptrlen(&c, 1)); - } - break; - } - } - - } - - if (s->curr_prompt < p->n_prompts) { - ldisc_enable_prompt_callback(term->ldisc, p); - return SPR_INCOMPLETE; - } else { - sfree(s); - p->data = NULL; - return signal_prompts_t(term, p, SPR_OK); - } + return SPR_INCOMPLETE; } void term_notify_minimised(Terminal *term, bool minimised) diff --git a/terminal/terminal.h b/terminal/terminal.h index 66d57d8b..95c504ec 100644 --- a/terminal/terminal.h +++ b/terminal/terminal.h @@ -74,6 +74,8 @@ struct term_utf8_decode { int size; /* The size of the UTF character. */ }; +struct term_userpass_state; + struct terminal_tag { int compatibility_level; @@ -428,6 +430,12 @@ struct terminal_tag { WIN_RESIZE_NO, WIN_RESIZE_NEED_SEND, WIN_RESIZE_AWAIT_REPLY } win_resize_pending; int win_resize_pending_w, win_resize_pending_h; + + /* + * Indicates whether term_get_userpass_input is currently using + * the terminal to present a password prompt or similar. + */ + struct term_userpass_state *userpass_state; }; static inline bool in_utf(Terminal *term) @@ -561,4 +569,44 @@ static inline bool decpos_fn(pos *p, int cols) #define incpos(p) incpos_fn(&(p), GET_TERM_COLS) #define decpos(p) decpos_fn(&(p), GET_TERM_COLS) +struct TermLineEditorCallbackReceiverVtable { + void (*to_terminal)(TermLineEditorCallbackReceiver *rcv, ptrlen data); + void (*to_backend)(TermLineEditorCallbackReceiver *rcv, ptrlen data); + void (*special)(TermLineEditorCallbackReceiver *rcv, + SessionSpecialCode code, int arg); + void (*newline)(TermLineEditorCallbackReceiver *rcv); +}; +struct TermLineEditorCallbackReceiver { + const TermLineEditorCallbackReceiverVtable *vt; +}; +TermLineEditor *lineedit_new(Terminal *term, unsigned flags, + TermLineEditorCallbackReceiver *receiver); +void lineedit_free(TermLineEditor *le); +void lineedit_input(TermLineEditor *le, char ch, bool dedicated); +void lineedit_modify_flags(TermLineEditor *le, unsigned clr, unsigned flip); +void lineedit_send_line(TermLineEditor *le); + +/* + * Flags controlling the behaviour of TermLineEditor. + */ +#define LINEEDIT_FLAGS(X) \ + X(LE_INTERRUPT) /* pass SS_IP back to client on ^C */ \ + X(LE_SUSPEND) /* pass SS_SUSP back to client on ^Z */ \ + X(LE_ABORT) /* pass SS_ABORT back to client on ^\ */ \ + X(LE_EOF_ALWAYS) /* pass SS_EOF to client on *any* ^Z + * (X(not)just if the line buffer is empty) */ \ + X(LE_ESC_ERASES) /* make ESC erase the line, as well as ^U */ \ + X(LE_CRLF_NEWLINE) /* interpret manual ^M^J the same as Return */ \ + /* end of list */ +enum { + #define ALLOCATE_BIT_POSITION(flag) flag ## _bitpos, + LINEEDIT_FLAGS(ALLOCATE_BIT_POSITION) + #undef ALLOCATE_BIT_POSITION +}; +enum { + #define DEFINE_FLAG_BIT(flag) flag = 1 << flag ## _bitpos, + LINEEDIT_FLAGS(DEFINE_FLAG_BIT) + #undef DEFINE_FLAG_BIT +}; + #endif diff --git a/test/fuzzterm.c b/test/fuzzterm.c index 291ded6c..45ba91dd 100644 --- a/test/fuzzterm.c +++ b/test/fuzzterm.c @@ -119,10 +119,7 @@ static const TermWinVtable fuzz_termwin_vt = { void ldisc_send(Ldisc *ldisc, const void *buf, int len, bool interactive) {} void ldisc_echoedit_update(Ldisc *ldisc) {} -bool ldisc_has_input_buffered(Ldisc *ldisc) { return false; } -LdiscInputToken ldisc_get_input_token(Ldisc *ldisc) -{ unreachable("This fake ldisc never has any buffered input"); } -void ldisc_enable_prompt_callback(Ldisc *ldisc, prompts_t *p) +void ldisc_provide_userpass_le(Ldisc *ldisc, TermLineEditor *le) { unreachable("This fake ldisc should never be used for user/pass prompts"); } void modalfatalbox(const char *fmt, ...) { exit(0); } void nonfatal(const char *fmt, ...) { } diff --git a/test/test_lineedit.c b/test/test_lineedit.c index 7259a54e..488eca56 100644 --- a/test/test_lineedit.c +++ b/test/test_lineedit.c @@ -656,9 +656,10 @@ static void test_edit(Mock *mk, bool echo) conf_set_bool(mk->conf, CONF_telnet_keyboard, false); ldisc_configure(mk->ldisc, mk->conf); - /* Test UTF-8 characters of various lengths and ensure deleting one - * deletes the whole character from the buffer (by pressing Return and - * seeing what gets sent) but only sends one BSB */ + /* Test UTF-8 characters of various lengths and ensure deleting + * one deletes the whole character from the buffer (by pressing + * Return and seeing what gets sent) but sends a number of BSBs + * corresponding to the character's terminal width */ mk->term->utf = true; ldisc_send(mk->ldisc, "\xC2\xA0\xC2\xA1", 4, false); @@ -692,6 +693,29 @@ static void test_edit(Mock *mk, bool echo) EXPECT(mk, backend, PTRLEN_LITERAL("\xF0\x90\x80\x80\x0D\x0A")); reset(mk); + /* Double-width characters (Hangul, as it happens) */ + ldisc_send(mk->ldisc, "\xEA\xB0\x80\xEA\xB0\x81", 6, false); + EXPECT(mk, backend, PTRLEN_LITERAL("")); + EXPECT_TERMINAL(mk, PTRLEN_LITERAL("\xEA\xB0\x80\xEA\xB0\x81")); + ldisc_send(mk->ldisc, "\x08", -1, false); + EXPECT(mk, backend, PTRLEN_LITERAL("")); + EXPECT_TERMINAL(mk, PTRLEN_LITERAL("\xEA\xB0\x80\xEA\xB0\x81" + "\x08 \x08\x08 \x08")); + ldisc_send(mk->ldisc, "\x0D", -1, false); + EXPECT(mk, backend, PTRLEN_LITERAL("\xEA\xB0\x80\x0D\x0A")); + reset(mk); + + /* Zero-width characters */ + ldisc_send(mk->ldisc, "\xE2\x80\x8B\xE2\x80\x8B", 6, false); + EXPECT(mk, backend, PTRLEN_LITERAL("")); + EXPECT_TERMINAL(mk, PTRLEN_LITERAL("\xE2\x80\x8B\xE2\x80\x8B")); + ldisc_send(mk->ldisc, "\x08", -1, false); + EXPECT(mk, backend, PTRLEN_LITERAL("")); + EXPECT_TERMINAL(mk, PTRLEN_LITERAL("\xE2\x80\x8B\xE2\x80\x8B")); + ldisc_send(mk->ldisc, "\x0D", -1, false); + EXPECT(mk, backend, PTRLEN_LITERAL("\xE2\x80\x8B\x0D\x0A")); + reset(mk); + /* And reset back to non-UTF-8 mode and expect high-bit-set bytes * to be treated individually, as characters in a single-byte * charset. (In our case, given the test config, that will be