Skip to content

Commit bb60653

Browse files
gh-138577: Fix keyboard shortcuts in getpass with echo_char
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.
1 parent ed81baf commit bb60653

File tree

4 files changed

+123
-13
lines changed

4 files changed

+123
-13
lines changed

Doc/library/getpass.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,18 @@ The :mod:`getpass` module provides two functions:
4343
On Unix systems, when *echo_char* is set, the terminal will be
4444
configured to operate in
4545
:manpage:`noncanonical mode <termios(3)#Canonical_and_noncanonical_mode>`.
46-
In particular, this means that line editing shortcuts such as
47-
:kbd:`Ctrl+U` will not work and may insert unexpected characters into
48-
the input.
46+
Common terminal control characters like :kbd:`Ctrl+U` (kill line),
47+
:kbd:`Ctrl+W` (erase word), and :kbd:`Ctrl+V` (literal next) are
48+
supported by reading the terminal's configured control character
49+
mappings.
4950

5051
.. versionchanged:: 3.14
5152
Added the *echo_char* parameter for keyboard feedback.
5253

54+
.. versionchanged:: 3.15
55+
When using *echo_char* on Unix, keyboard shortcuts are now properly
56+
handled using the terminal's control character configuration.
57+
5358
.. exception:: GetPassWarning
5459

5560
A :exc:`UserWarning` subclass issued when password input may be echoed.

Lib/getpass.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,27 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
7373
old = termios.tcgetattr(fd) # a copy to save
7474
new = old[:]
7575
new[3] &= ~termios.ECHO # 3 == 'lflags'
76+
# Extract control characters before changing terminal mode
77+
term_ctrl_chars = None
7678
if echo_char:
7779
new[3] &= ~termios.ICANON
80+
# Get control characters from terminal settings
81+
# Index 6 is cc (control characters array)
82+
cc = old[6]
83+
term_ctrl_chars = {
84+
'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else b'\x7f',
85+
'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else b'\x15',
86+
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else b'\x17',
87+
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else b'\x16',
88+
}
7889
tcsetattr_flags = termios.TCSAFLUSH
7990
if hasattr(termios, 'TCSASOFT'):
8091
tcsetattr_flags |= termios.TCSASOFT
8192
try:
8293
termios.tcsetattr(fd, tcsetattr_flags, new)
8394
passwd = _raw_input(prompt, stream, input=input,
84-
echo_char=echo_char)
95+
echo_char=echo_char,
96+
term_ctrl_chars=term_ctrl_chars)
8597

8698
finally:
8799
termios.tcsetattr(fd, tcsetattr_flags, old)
@@ -159,7 +171,8 @@ def _check_echo_char(echo_char):
159171
f"character, got: {echo_char!r}")
160172

161173

162-
def _raw_input(prompt="", stream=None, input=None, echo_char=None):
174+
def _raw_input(prompt="", stream=None, input=None, echo_char=None,
175+
term_ctrl_chars=None):
163176
# This doesn't save the string in the GNU readline history.
164177
if not stream:
165178
stream = sys.stderr
@@ -177,7 +190,8 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
177190
stream.flush()
178191
# NOTE: The Python C API calls flockfile() (and unlock) during readline.
179192
if echo_char:
180-
return _readline_with_echo_char(stream, input, echo_char)
193+
return _readline_with_echo_char(stream, input, echo_char,
194+
term_ctrl_chars)
181195
line = input.readline()
182196
if not line:
183197
raise EOFError
@@ -186,27 +200,78 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
186200
return line
187201

188202

189-
def _readline_with_echo_char(stream, input, echo_char):
203+
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None):
190204
passwd = ""
191205
eof_pressed = False
206+
literal_next = False # For LNEXT (Ctrl+V)
207+
208+
# Convert terminal control characters to strings for comparison
209+
# Default to standard POSIX values if not provided
210+
if term_ctrl_chars:
211+
# Control chars from termios are bytes, convert to str
212+
erase_char = term_ctrl_chars['ERASE'].decode('latin-1') if isinstance(term_ctrl_chars['ERASE'], bytes) else term_ctrl_chars['ERASE']
213+
kill_char = term_ctrl_chars['KILL'].decode('latin-1') if isinstance(term_ctrl_chars['KILL'], bytes) else term_ctrl_chars['KILL']
214+
werase_char = term_ctrl_chars['WERASE'].decode('latin-1') if isinstance(term_ctrl_chars['WERASE'], bytes) else term_ctrl_chars['WERASE']
215+
lnext_char = term_ctrl_chars['LNEXT'].decode('latin-1') if isinstance(term_ctrl_chars['LNEXT'], bytes) else term_ctrl_chars['LNEXT']
216+
else:
217+
# Standard POSIX defaults
218+
erase_char = '\x7f' # DEL
219+
kill_char = '\x15' # Ctrl+U
220+
werase_char = '\x17' # Ctrl+W
221+
lnext_char = '\x16' # Ctrl+V
222+
192223
while True:
193224
char = input.read(1)
225+
194226
if char == '\n' or char == '\r':
195227
break
196228
elif char == '\x03':
197229
raise KeyboardInterrupt
198-
elif char == '\x7f' or char == '\b':
199-
if passwd:
200-
stream.write("\b \b")
201-
stream.flush()
202-
passwd = passwd[:-1]
203230
elif char == '\x04':
204231
if eof_pressed:
205232
break
206233
else:
207234
eof_pressed = True
208235
elif char == '\x00':
209236
continue
237+
# Handle LNEXT (Ctrl+V) - insert next character literally
238+
elif literal_next:
239+
passwd += char
240+
stream.write(echo_char)
241+
stream.flush()
242+
literal_next = False
243+
eof_pressed = False
244+
elif char == lnext_char:
245+
literal_next = True
246+
eof_pressed = False
247+
# Handle ERASE (Backspace/DEL) - delete one character
248+
elif char == erase_char or char == '\b':
249+
if passwd:
250+
stream.write("\b \b")
251+
stream.flush()
252+
passwd = passwd[:-1]
253+
eof_pressed = False
254+
# Handle KILL (Ctrl+U) - erase entire line
255+
elif char == kill_char:
256+
# Clear all echoed characters
257+
while passwd:
258+
stream.write("\b \b")
259+
passwd = passwd[:-1]
260+
stream.flush()
261+
eof_pressed = False
262+
# Handle WERASE (Ctrl+W) - erase previous word
263+
elif char == werase_char:
264+
# Delete backwards until we find a space or reach the beginning
265+
# First, skip any trailing spaces
266+
while passwd and passwd[-1] == ' ':
267+
stream.write("\b \b")
268+
passwd = passwd[:-1]
269+
# Then delete the word
270+
while passwd and passwd[-1] != ' ':
271+
stream.write("\b \b")
272+
passwd = passwd[:-1]
273+
stream.flush()
274+
eof_pressed = False
210275
else:
211276
passwd += char
212277
stream.write(echo_char)

Lib/test/test_getpass.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ def test_echo_char_replaces_input_with_asterisks(self):
174174

175175
result = getpass.unix_getpass(echo_char='*')
176176
mock_input.assert_called_once_with('Password: ', textio(),
177-
input=textio(), echo_char='*')
177+
input=textio(), echo_char='*',
178+
term_ctrl_chars=mock.ANY)
178179
self.assertEqual(result, mock_result)
179180

180181
def test_raw_input_with_echo_char(self):
@@ -200,6 +201,41 @@ def test_control_chars_with_echo_char(self):
200201
self.assertEqual(result, expect_result)
201202
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
202203

204+
def test_kill_ctrl_u_with_echo_char(self):
205+
# Ctrl+U (KILL) should clear the entire line
206+
passwd = 'foo\x15bar' # Type "foo", hit Ctrl+U, type "bar"
207+
expect_result = 'bar'
208+
mock_input = StringIO(f'{passwd}\n')
209+
mock_output = StringIO()
210+
result = getpass._raw_input('Password: ', mock_output, mock_input,
211+
'*')
212+
self.assertEqual(result, expect_result)
213+
# Should show "***" then clear all 3, then show "***" for "bar"
214+
output = mock_output.getvalue()
215+
self.assertIn('***', output)
216+
# Should have backspaces to clear the "foo" part
217+
self.assertIn('\b', output)
218+
219+
def test_werase_ctrl_w_with_echo_char(self):
220+
# Ctrl+W (WERASE) should delete the previous word
221+
passwd = 'hello world\x17end' # Type "hello world", hit Ctrl+W, type "end"
222+
expect_result = 'hello end'
223+
mock_input = StringIO(f'{passwd}\n')
224+
mock_output = StringIO()
225+
result = getpass._raw_input('Password: ', mock_output, mock_input,
226+
'*')
227+
self.assertEqual(result, expect_result)
228+
229+
def test_lnext_ctrl_v_with_echo_char(self):
230+
# Ctrl+V (LNEXT) should insert the next character literally
231+
passwd = 'test\x16\x15more' # Type "test", hit Ctrl+V, then Ctrl+U (literal), type "more"
232+
expect_result = 'test\x15more' # Should contain literal Ctrl+U
233+
mock_input = StringIO(f'{passwd}\n')
234+
mock_output = StringIO()
235+
result = getpass._raw_input('Password: ', mock_output, mock_input,
236+
'*')
237+
self.assertEqual(result, expect_result)
238+
203239

204240
class GetpassEchoCharTest(unittest.TestCase):
205241

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:func:`getpass.getpass` with ``echo_char`` now handles keyboard shortcuts like
2+
Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next) by reading
3+
the terminal's control character settings and processing them appropriately in
4+
non-canonical mode. Patch by Sanyam Khurana.

0 commit comments

Comments
 (0)