]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-138577: Fix keyboard shortcuts in getpass with echo_char (#141597)
authorSanyam Khurana <8039608+CuriousLearner@users.noreply.github.com>
Mon, 30 Mar 2026 09:11:13 +0000 (05:11 -0400)
committerGitHub <noreply@github.com>
Mon, 30 Mar 2026 09:11:13 +0000 (11:11 +0200)
When using getpass.getpass(echo_char='*'), keyboard shortcuts like
Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next)
now work correctly by reading the terminal's control character
settings and processing them in non-canonical mode.

Co-authored-by: Victor Stinner <vstinner@python.org>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Doc/library/getpass.rst
Lib/getpass.py
Lib/test/test_getpass.py
Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst [new file with mode: 0644]

index 1fb34d14d8b007d27f37fcd3cf1749dca34860a6..a6ca230d5e81a848d087f93841941ec7a18ff893 100644 (file)
@@ -39,13 +39,27 @@ The :mod:`!getpass` module provides two functions:
       On Unix systems, when *echo_char* is set, the terminal will be
       configured to operate in
       :manpage:`noncanonical mode <termios(3)#Canonical_and_noncanonical_mode>`.
-      In particular, this means that line editing shortcuts such as
-      :kbd:`Ctrl+U` will not work and may insert unexpected characters into
-      the input.
+      Common terminal control characters are supported:
+
+      * :kbd:`Ctrl+A` - Move cursor to beginning of line
+      * :kbd:`Ctrl+E` - Move cursor to end of line
+      * :kbd:`Ctrl+K` - Kill (delete) from cursor to end of line
+      * :kbd:`Ctrl+U` - Kill (delete) entire line
+      * :kbd:`Ctrl+W` - Erase previous word
+      * :kbd:`Ctrl+V` - Insert next character literally (quote)
+      * :kbd:`Backspace`/:kbd:`DEL` - Delete character before cursor
+
+      These shortcuts work by reading the terminal's configured control
+      character mappings from termios settings.
 
    .. versionchanged:: 3.14
       Added the *echo_char* parameter for keyboard feedback.
 
+   .. versionchanged:: next
+      When using non-empty *echo_char* on Unix, keyboard shortcuts (including
+      cursor movement and line editing) are now properly handled using the
+      terminal's control character configuration.
+
 .. exception:: GetPassWarning
 
    A :exc:`UserWarning` subclass issued when password input may be echoed.
index 3d9bb1f0d146a48ba9622f84b008e7f69f7584b9..cfbd63dded6cc19630191915790792baab210053 100644 (file)
@@ -26,6 +26,45 @@ __all__ = ["getpass","getuser","GetPassWarning"]
 class GetPassWarning(UserWarning): pass
 
 
+# Default POSIX control character mappings
+_POSIX_CTRL_CHARS = frozendict({
+    'BS': '\x08',      # Backspace
+    'ERASE': '\x7f',   # DEL
+    'KILL': '\x15',    # Ctrl+U - kill line
+    'WERASE': '\x17',  # Ctrl+W - erase word
+    'LNEXT': '\x16',   # Ctrl+V - literal next
+    'EOF': '\x04',     # Ctrl+D - EOF
+    'INTR': '\x03',    # Ctrl+C - interrupt
+    'SOH': '\x01',     # Ctrl+A - start of heading (beginning of line)
+    'ENQ': '\x05',     # Ctrl+E - enquiry (end of line)
+    'VT': '\x0b',      # Ctrl+K - vertical tab (kill forward)
+})
+
+
+def _get_terminal_ctrl_chars(fd):
+    """Extract control characters from terminal settings.
+
+    Returns a dict mapping control char names to their str values.
+
+    Falls back to POSIX defaults if termios is not available
+    or if the control character is not supported by termios.
+    """
+    ctrl = dict(_POSIX_CTRL_CHARS)
+    try:
+        old = termios.tcgetattr(fd)
+        cc = old[6]  # Index 6 is the control characters array
+    except (termios.error, OSError):
+        return ctrl
+
+    # Use defaults for Backspace (BS) and Ctrl+A/E/K (SOH/ENQ/VT)
+    # as they are not in the termios control characters array.
+    for name in ('ERASE', 'KILL', 'WERASE', 'LNEXT', 'EOF', 'INTR'):
+        cap = getattr(termios, f'V{name}')
+        if cap < len(cc):
+            ctrl[name] = cc[cap].decode('latin-1')
+    return ctrl
+
+
 def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
     """Prompt for a password, with echo turned off.
 
@@ -73,15 +112,27 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
                 old = termios.tcgetattr(fd)     # a copy to save
                 new = old[:]
                 new[3] &= ~termios.ECHO  # 3 == 'lflags'
+                # Extract control characters before changing terminal mode.
+                term_ctrl_chars = None
                 if echo_char:
+                    # ICANON enables canonical (line-buffered) mode where
+                    # the terminal handles line editing. Disable it so we
+                    # can read input char by char and handle editing ourselves.
                     new[3] &= ~termios.ICANON
+                    # IEXTEN enables implementation-defined input processing
+                    # such as LNEXT (Ctrl+V). Disable it so the terminal
+                    # driver does not intercept these characters before our
+                    # code can handle them.
+                    new[3] &= ~termios.IEXTEN
+                    term_ctrl_chars = _get_terminal_ctrl_chars(fd)
                 tcsetattr_flags = termios.TCSAFLUSH
                 if hasattr(termios, 'TCSASOFT'):
                     tcsetattr_flags |= termios.TCSASOFT
                 try:
                     termios.tcsetattr(fd, tcsetattr_flags, new)
                     passwd = _raw_input(prompt, stream, input=input,
-                                        echo_char=echo_char)
+                                        echo_char=echo_char,
+                                        term_ctrl_chars=term_ctrl_chars)
 
                 finally:
                     termios.tcsetattr(fd, tcsetattr_flags, old)
@@ -159,7 +210,8 @@ def _check_echo_char(echo_char):
                          f"character, got: {echo_char!r}")
 
 
-def _raw_input(prompt="", stream=None, input=None, echo_char=None):
+def _raw_input(prompt="", stream=None, input=None, echo_char=None,
+               term_ctrl_chars=None):
     # This doesn't save the string in the GNU readline history.
     if not stream:
         stream = sys.stderr
@@ -177,7 +229,8 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
         stream.flush()
     # NOTE: The Python C API calls flockfile() (and unlock) during readline.
     if echo_char:
-        return _readline_with_echo_char(stream, input, echo_char)
+        return _readline_with_echo_char(stream, input, echo_char,
+                                        term_ctrl_chars, prompt)
     line = input.readline()
     if not line:
         raise EOFError
@@ -186,33 +239,174 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
     return line
 
 
-def _readline_with_echo_char(stream, input, echo_char):
-    passwd = ""
-    eof_pressed = False
-    while True:
-        char = input.read(1)
-        if char == '\n' or char == '\r':
-            break
-        elif char == '\x03':
-            raise KeyboardInterrupt
-        elif char == '\x7f' or char == '\b':
-            if passwd:
-                stream.write("\b \b")
-                stream.flush()
-            passwd = passwd[:-1]
-        elif char == '\x04':
-            if eof_pressed:
+def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None,
+                             prompt=""):
+    """Read password with echo character and line editing support."""
+    if term_ctrl_chars is None:
+        term_ctrl_chars = _POSIX_CTRL_CHARS
+
+    editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)
+    return editor.readline(input)
+
+
+class _PasswordLineEditor:
+    """Handles line editing for password input with echo character."""
+
+    def __init__(self, stream, echo_char, ctrl_chars, prompt=""):
+        self.stream = stream
+        self.echo_char = echo_char
+        self.prompt = prompt
+        self.password = []
+        self.cursor_pos = 0
+        self.eof_pressed = False
+        self.literal_next = False
+        self.ctrl = ctrl_chars
+        self.dispatch = {
+            ctrl_chars['SOH']: self.handle_move_start,      # Ctrl+A
+            ctrl_chars['ENQ']: self.handle_move_end,        # Ctrl+E
+            ctrl_chars['VT']: self.handle_kill_forward,     # Ctrl+K
+            ctrl_chars['KILL']: self.handle_kill_line,      # Ctrl+U
+            ctrl_chars['WERASE']: self.handle_erase_word,   # Ctrl+W
+            ctrl_chars['ERASE']: self.handle_erase,         # DEL
+            ctrl_chars['BS']: self.handle_erase,            # Backspace
+            # special characters
+            ctrl_chars['LNEXT']: self.handle_literal_next,  # Ctrl+V
+            ctrl_chars['EOF']: self.handle_eof,             # Ctrl+D
+            ctrl_chars['INTR']: self.handle_interrupt,      # Ctrl+C
+            '\x00': self.handle_nop,                        # ignore NUL
+        }
+
+    def refresh_display(self, prev_len=None):
+        """Redraw the entire password line with *echo_char*.
+
+        If *prev_len* is not specified, the current password length is used.
+        """
+        prompt_len = len(self.prompt)
+        clear_len = prev_len if prev_len is not None else len(self.password)
+        # Clear the entire line (prompt + password) and rewrite.
+        self.stream.write('\r' + ' ' * (prompt_len + clear_len) + '\r')
+        self.stream.write(self.prompt + self.echo_char * len(self.password))
+        if self.cursor_pos < len(self.password):
+            self.stream.write('\b' * (len(self.password) - self.cursor_pos))
+        self.stream.flush()
+
+    def insert_char(self, char):
+        """Insert *char* at cursor position."""
+        self.password.insert(self.cursor_pos, char)
+        self.cursor_pos += 1
+        # Only refresh if inserting in middle.
+        if self.cursor_pos < len(self.password):
+            self.refresh_display()
+        else:
+            self.stream.write(self.echo_char)
+            self.stream.flush()
+
+    def is_eol(self, char):
+        """Check if *char* is a line terminator."""
+        return char in ('\r', '\n')
+
+    def is_eof(self, char):
+        """Check if *char* is a file terminator."""
+        return char == self.ctrl['EOF']
+
+    def handle_move_start(self):
+        """Move cursor to beginning (Ctrl+A)."""
+        self.cursor_pos = 0
+        self.refresh_display()
+
+    def handle_move_end(self):
+        """Move cursor to end (Ctrl+E)."""
+        self.cursor_pos = len(self.password)
+        self.refresh_display()
+
+    def handle_erase(self):
+        """Delete character before cursor (Backspace/DEL)."""
+        if self.cursor_pos == 0:
+            return
+        assert self.cursor_pos > 0
+        self.cursor_pos -= 1
+        prev_len = len(self.password)
+        del self.password[self.cursor_pos]
+        self.refresh_display(prev_len)
+
+    def handle_kill_line(self):
+        """Erase entire line (Ctrl+U)."""
+        prev_len = len(self.password)
+        self.password.clear()
+        self.cursor_pos = 0
+        self.refresh_display(prev_len)
+
+    def handle_kill_forward(self):
+        """Kill from cursor to end (Ctrl+K)."""
+        prev_len = len(self.password)
+        del self.password[self.cursor_pos:]
+        self.refresh_display(prev_len)
+
+    def handle_erase_word(self):
+        """Erase previous word (Ctrl+W)."""
+        old_cursor = self.cursor_pos
+        # Calculate the starting position of the previous word,
+        # ignoring trailing whitespaces.
+        while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] == ' ':
+            self.cursor_pos -= 1
+        while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ':
+            self.cursor_pos -= 1
+        # Delete the previous word and refresh the screen.
+        prev_len = len(self.password)
+        del self.password[self.cursor_pos:old_cursor]
+        self.refresh_display(prev_len)
+
+    def handle_literal_next(self):
+        """State transition to indicate that the next character is literal."""
+        assert self.literal_next is False
+        self.literal_next = True
+
+    def handle_eof(self):
+        """State transition to indicate that the pressed character was EOF."""
+        assert self.eof_pressed is False
+        self.eof_pressed = True
+
+    def handle_interrupt(self):
+        """Raise a KeyboardInterrupt after Ctrl+C has been received."""
+        raise KeyboardInterrupt
+
+    def handle_nop(self):
+        """Handler for an ignored character."""
+
+    def handle(self, char):
+        """Handle a single character input. Returns True if handled."""
+        handler = self.dispatch.get(char)
+        if handler:
+            handler()
+            return True
+        return False
+
+    def readline(self, input):
+        """Read a line of password input with echo character support."""
+        while True:
+            assert self.cursor_pos >= 0
+            char = input.read(1)
+            if self.is_eol(char):
                 break
+            # Handle literal next mode first as Ctrl+V quotes characters.
+            elif self.literal_next:
+                self.insert_char(char)
+                self.literal_next = False
+            # Handle EOF now as Ctrl+D must be pressed twice
+            # consecutively to stop reading from the input.
+            elif self.is_eof(char):
+                if self.eof_pressed:
+                    break
+            elif self.handle(char):
+                # Dispatched to handler.
+                pass
             else:
-                eof_pressed = True
-        elif char == '\x00':
-            continue
-        else:
-            passwd += char
-            stream.write(echo_char)
-            stream.flush()
-            eof_pressed = False
-    return passwd
+                # Insert as normal character.
+                self.insert_char(char)
+
+            self.eof_pressed = self.is_eof(char)
+
+        return ''.join(self.password)
 
 
 def getuser():
index 9c3def2c3be59b1c9d29c5684fbe67b0b8674072..272414a62048561bc561fcbe9e013828dbc1137f 100644 (file)
@@ -88,6 +88,147 @@ class GetpassRawinputTest(unittest.TestCase):
         input = StringIO('test\n')
         self.assertEqual('test', getpass._raw_input(input=input))
 
+    def check_raw_input(self, inputs, expect_result, prompt='Password: '):
+        mock_input = StringIO(inputs)
+        mock_output = StringIO()
+        result = getpass._raw_input(prompt, mock_output, mock_input, '*')
+        self.assertEqual(result, expect_result)
+        return mock_output.getvalue()
+
+    def test_null_char(self):
+        self.check_raw_input('pass\x00word\n', 'password')
+
+    def test_raw_input_with_echo_char(self):
+        output = self.check_raw_input('my1pa$$word!\n', 'my1pa$$word!')
+        self.assertEqual('Password: ************', output)
+
+    def test_control_chars_with_echo_char(self):
+        output = self.check_raw_input('pass\twd\b\n', 'pass\tw')
+        # After backspace: refresh rewrites prompt + 6 echo chars
+        self.assertEqual(
+            'Password: *******'           # initial prompt + 7 echo chars
+            '\r' + ' ' * 17 + '\r'        # clear line (10 prompt + 7 prev)
+            'Password: ******',           # rewrite prompt + 6 echo chars
+            output
+        )
+
+    def test_kill_ctrl_u_with_echo_char(self):
+        # Ctrl+U (KILL) should clear the entire line
+        output = self.check_raw_input('foo\x15bar\n', 'bar')
+        # Should show "***" then refresh to clear, then show "***" for "bar"
+        self.assertIn('***', output)
+        # Display refresh uses \r to rewrite the line including prompt
+        self.assertIn('\r', output)
+
+    def test_werase_ctrl_w_with_echo_char(self):
+        # Ctrl+W (WERASE) should delete the previous word
+        self.check_raw_input('hello world\x17end\n', 'hello end')
+
+    def test_ctrl_w_display_preserves_prompt(self):
+        # Reproducer from gh-138577: type "hello world", Ctrl+W
+        # Display must show "Password: ******" not "******rd: ***********"
+        output = self.check_raw_input('hello world\x17\n', 'hello ')
+        # The final visible state should be "Password: ******"
+        # Verify prompt is rewritten during refresh, not overwritten by stars
+        self.assertEndsWith(output, 'Password: ******')
+
+    def test_ctrl_a_insert_display_preserves_prompt(self):
+        # Reproducer from gh-138577: type "abc", Ctrl+A, type "x"
+        # Display must show "Password: ****" not "****word: ***"
+        output = self.check_raw_input('abc\x01x\n', 'xabc')
+        # The final visible state should be "Password: ****"
+        self.assertEndsWith(output, 'Password: ****\x08\x08\x08')
+
+    def test_lnext_ctrl_v_with_echo_char(self):
+        # Ctrl+V (LNEXT) should insert the next character literally
+        self.check_raw_input('test\x16\x15more\n', 'test\x15more')
+
+    def test_ctrl_a_move_to_start_with_echo_char(self):
+        # Ctrl+A should move cursor to start
+        self.check_raw_input('end\x01start\n', 'startend')
+
+    def test_ctrl_a_cursor_position(self):
+        # After Ctrl+A, cursor is at position 0.
+        # Refresh writes backspaces to move cursor from end to start.
+        output = self.check_raw_input('abc\x01\n', 'abc')
+        self.assertEndsWith(output, 'Password: ***\x08\x08\x08')
+
+    def test_ctrl_a_on_empty(self):
+        # Ctrl+A on empty line should be a no-op
+        self.check_raw_input('\x01hello\n', 'hello')
+
+    def test_ctrl_a_already_at_start(self):
+        # Double Ctrl+A should be same as single Ctrl+A
+        self.check_raw_input('abc\x01\x01start\n', 'startabc')
+
+    def test_ctrl_a_then_backspace(self):
+        # Backspace after Ctrl+A should do nothing (cursor at 0)
+        self.check_raw_input('abc\x01\x7f\n', 'abc')
+
+    def test_ctrl_e_move_to_end_with_echo_char(self):
+        # Ctrl+E should move cursor to end
+        self.check_raw_input('start\x01X\x05end\n', 'Xstartend')
+
+    def test_ctrl_e_cursor_position(self):
+        # After Ctrl+A then Ctrl+E, cursor is back at end.
+        # Refresh has no backspaces since cursor is at end.
+        output = self.check_raw_input('abc\x01\x05\n', 'abc')
+        self.assertEndsWith(output, 'Password: ***')
+
+    def test_ctrl_e_on_empty(self):
+        # Ctrl+E on empty line should be a no-op
+        self.check_raw_input('\x05hello\n', 'hello')
+
+    def test_ctrl_e_already_at_end(self):
+        # Ctrl+E when already at end should be a no-op
+        self.check_raw_input('abc\x05more\n', 'abcmore')
+
+    def test_ctrl_a_then_ctrl_e(self):
+        # Ctrl+A then Ctrl+E should return cursor to end, typing appends
+        self.check_raw_input('abc\x01\x05def\n', 'abcdef')
+
+    def test_ctrl_k_kill_forward_with_echo_char(self):
+        # Ctrl+K should kill from cursor to end
+        self.check_raw_input('delete\x01\x0bkeep\n', 'keep')
+
+    def test_ctrl_c_interrupt_with_echo_char(self):
+        # Ctrl+C should raise KeyboardInterrupt
+        with self.assertRaises(KeyboardInterrupt):
+            self.check_raw_input('test\x03more', '')
+
+    def test_ctrl_d_eof_with_echo_char(self):
+        # Ctrl+D twice should cause EOF
+        self.check_raw_input('test\x04\x04', 'test')
+
+    def test_backspace_at_start_with_echo_char(self):
+        # Backspace at start should do nothing
+        self.check_raw_input('\x7fhello\n', 'hello')
+
+    def test_ctrl_k_at_end_with_echo_char(self):
+        # Ctrl+K at end should do nothing
+        self.check_raw_input('hello\x0b\n', 'hello')
+
+    def test_ctrl_w_on_empty_with_echo_char(self):
+        # Ctrl+W on empty line should do nothing
+        self.check_raw_input('\x17hello\n', 'hello')
+
+    def test_ctrl_u_on_empty_with_echo_char(self):
+        # Ctrl+U on empty line should do nothing
+        self.check_raw_input('\x15hello\n', 'hello')
+
+    def test_multiple_ctrl_operations_with_echo_char(self):
+        # Test combination: type, move, insert, delete
+        # "world", Ctrl+A, "hello ", Ctrl+E, "!", Ctrl+A, Ctrl+K, "start"
+        self.check_raw_input('world\x01hello \x05!\x01\x0bstart\n', 'start')
+
+    def test_ctrl_w_multiple_words_with_echo_char(self):
+        # Ctrl+W should delete only the last word
+        self.check_raw_input('one two three\x17\n', 'one two ')
+
+    def test_ctrl_v_then_ctrl_c_with_echo_char(self):
+        # Ctrl+V should make Ctrl+C literal (not interrupt)
+        self.check_raw_input('test\x16\x03end\n', 'test\x03end')
+
 
 # Some of these tests are a bit white-box.  The functional requirement is that
 # the password input be taken directly from the tty, and that it not be echoed
@@ -174,33 +315,10 @@ class UnixGetpassTest(unittest.TestCase):
 
             result = getpass.unix_getpass(echo_char='*')
             mock_input.assert_called_once_with('Password: ', textio(),
-                                               input=textio(), echo_char='*')
+                                               input=textio(), echo_char='*',
+                                               term_ctrl_chars=mock.ANY)
             self.assertEqual(result, mock_result)
 
-    def test_raw_input_with_echo_char(self):
-        passwd = 'my1pa$$word!'
-        mock_input = StringIO(f'{passwd}\n')
-        mock_output = StringIO()
-        with mock.patch('sys.stdin', mock_input), \
-                mock.patch('sys.stdout', mock_output):
-            result = getpass._raw_input('Password: ', mock_output, mock_input,
-                                        '*')
-        self.assertEqual(result, passwd)
-        self.assertEqual('Password: ************', mock_output.getvalue())
-
-    def test_control_chars_with_echo_char(self):
-        passwd = 'pass\twd\b'
-        expect_result = 'pass\tw'
-        mock_input = StringIO(f'{passwd}\n')
-        mock_output = StringIO()
-        with mock.patch('sys.stdin', mock_input), \
-                mock.patch('sys.stdout', mock_output):
-            result = getpass._raw_input('Password: ', mock_output, mock_input,
-                                        '*')
-        self.assertEqual(result, expect_result)
-        self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
-
-
 class GetpassEchoCharTest(unittest.TestCase):
 
     def test_accept_none(self):
diff --git a/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst b/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst
new file mode 100644 (file)
index 0000000..df24f62
--- /dev/null
@@ -0,0 +1,4 @@
+:func:`getpass.getpass` with non-empty ``echo_char`` now handles keyboard shortcuts
+including Ctrl+A/E (cursor movement), Ctrl+K/U (kill line), Ctrl+W (erase word),
+and Ctrl+V (literal next) by reading the terminal's control character settings
+and processing them appropriately in non-canonical mode. Patch by Sanyam Khurana.