Skip to content

Commit 7f0c4f6

Browse files
CuriousLearnervstinnerpicnixz
authored
gh-138577: Fix keyboard shortcuts in getpass with echo_char (#141597)
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 <[email protected]> Co-authored-by: Bénédikt Tran <[email protected]>
1 parent 2cf6a68 commit 7f0c4f6

File tree

4 files changed

+386
-56
lines changed

4 files changed

+386
-56
lines changed

Doc/library/getpass.rst

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,27 @@ The :mod:`!getpass` module provides two functions:
3939
On Unix systems, when *echo_char* is set, the terminal will be
4040
configured to operate in
4141
:manpage:`noncanonical mode <termios(3)#Canonical_and_noncanonical_mode>`.
42-
In particular, this means that line editing shortcuts such as
43-
:kbd:`Ctrl+U` will not work and may insert unexpected characters into
44-
the input.
42+
Common terminal control characters are supported:
43+
44+
* :kbd:`Ctrl+A` - Move cursor to beginning of line
45+
* :kbd:`Ctrl+E` - Move cursor to end of line
46+
* :kbd:`Ctrl+K` - Kill (delete) from cursor to end of line
47+
* :kbd:`Ctrl+U` - Kill (delete) entire line
48+
* :kbd:`Ctrl+W` - Erase previous word
49+
* :kbd:`Ctrl+V` - Insert next character literally (quote)
50+
* :kbd:`Backspace`/:kbd:`DEL` - Delete character before cursor
51+
52+
These shortcuts work by reading the terminal's configured control
53+
character mappings from termios settings.
4554

4655
.. versionchanged:: 3.14
4756
Added the *echo_char* parameter for keyboard feedback.
4857

58+
.. versionchanged:: next
59+
When using non-empty *echo_char* on Unix, keyboard shortcuts (including
60+
cursor movement and line editing) are now properly handled using the
61+
terminal's control character configuration.
62+
4963
.. exception:: GetPassWarning
5064

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

Lib/getpass.py

Lines changed: 222 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,45 @@
2626
class GetPassWarning(UserWarning): pass
2727

2828

29+
# Default POSIX control character mappings
30+
_POSIX_CTRL_CHARS = frozendict({
31+
'BS': '\x08', # Backspace
32+
'ERASE': '\x7f', # DEL
33+
'KILL': '\x15', # Ctrl+U - kill line
34+
'WERASE': '\x17', # Ctrl+W - erase word
35+
'LNEXT': '\x16', # Ctrl+V - literal next
36+
'EOF': '\x04', # Ctrl+D - EOF
37+
'INTR': '\x03', # Ctrl+C - interrupt
38+
'SOH': '\x01', # Ctrl+A - start of heading (beginning of line)
39+
'ENQ': '\x05', # Ctrl+E - enquiry (end of line)
40+
'VT': '\x0b', # Ctrl+K - vertical tab (kill forward)
41+
})
42+
43+
44+
def _get_terminal_ctrl_chars(fd):
45+
"""Extract control characters from terminal settings.
46+
47+
Returns a dict mapping control char names to their str values.
48+
49+
Falls back to POSIX defaults if termios is not available
50+
or if the control character is not supported by termios.
51+
"""
52+
ctrl = dict(_POSIX_CTRL_CHARS)
53+
try:
54+
old = termios.tcgetattr(fd)
55+
cc = old[6] # Index 6 is the control characters array
56+
except (termios.error, OSError):
57+
return ctrl
58+
59+
# Use defaults for Backspace (BS) and Ctrl+A/E/K (SOH/ENQ/VT)
60+
# as they are not in the termios control characters array.
61+
for name in ('ERASE', 'KILL', 'WERASE', 'LNEXT', 'EOF', 'INTR'):
62+
cap = getattr(termios, f'V{name}')
63+
if cap < len(cc):
64+
ctrl[name] = cc[cap].decode('latin-1')
65+
return ctrl
66+
67+
2968
def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
3069
"""Prompt for a password, with echo turned off.
3170
@@ -73,15 +112,27 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
73112
old = termios.tcgetattr(fd) # a copy to save
74113
new = old[:]
75114
new[3] &= ~termios.ECHO # 3 == 'lflags'
115+
# Extract control characters before changing terminal mode.
116+
term_ctrl_chars = None
76117
if echo_char:
118+
# ICANON enables canonical (line-buffered) mode where
119+
# the terminal handles line editing. Disable it so we
120+
# can read input char by char and handle editing ourselves.
77121
new[3] &= ~termios.ICANON
122+
# IEXTEN enables implementation-defined input processing
123+
# such as LNEXT (Ctrl+V). Disable it so the terminal
124+
# driver does not intercept these characters before our
125+
# code can handle them.
126+
new[3] &= ~termios.IEXTEN
127+
term_ctrl_chars = _get_terminal_ctrl_chars(fd)
78128
tcsetattr_flags = termios.TCSAFLUSH
79129
if hasattr(termios, 'TCSASOFT'):
80130
tcsetattr_flags |= termios.TCSASOFT
81131
try:
82132
termios.tcsetattr(fd, tcsetattr_flags, new)
83133
passwd = _raw_input(prompt, stream, input=input,
84-
echo_char=echo_char)
134+
echo_char=echo_char,
135+
term_ctrl_chars=term_ctrl_chars)
85136

86137
finally:
87138
termios.tcsetattr(fd, tcsetattr_flags, old)
@@ -159,7 +210,8 @@ def _check_echo_char(echo_char):
159210
f"character, got: {echo_char!r}")
160211

161212

162-
def _raw_input(prompt="", stream=None, input=None, echo_char=None):
213+
def _raw_input(prompt="", stream=None, input=None, echo_char=None,
214+
term_ctrl_chars=None):
163215
# This doesn't save the string in the GNU readline history.
164216
if not stream:
165217
stream = sys.stderr
@@ -177,7 +229,8 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
177229
stream.flush()
178230
# NOTE: The Python C API calls flockfile() (and unlock) during readline.
179231
if echo_char:
180-
return _readline_with_echo_char(stream, input, echo_char)
232+
return _readline_with_echo_char(stream, input, echo_char,
233+
term_ctrl_chars, prompt)
181234
line = input.readline()
182235
if not line:
183236
raise EOFError
@@ -186,33 +239,174 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None):
186239
return line
187240

188241

189-
def _readline_with_echo_char(stream, input, echo_char):
190-
passwd = ""
191-
eof_pressed = False
192-
while True:
193-
char = input.read(1)
194-
if char == '\n' or char == '\r':
195-
break
196-
elif char == '\x03':
197-
raise KeyboardInterrupt
198-
elif char == '\x7f' or char == '\b':
199-
if passwd:
200-
stream.write("\b \b")
201-
stream.flush()
202-
passwd = passwd[:-1]
203-
elif char == '\x04':
204-
if eof_pressed:
242+
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None,
243+
prompt=""):
244+
"""Read password with echo character and line editing support."""
245+
if term_ctrl_chars is None:
246+
term_ctrl_chars = _POSIX_CTRL_CHARS
247+
248+
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)
249+
return editor.readline(input)
250+
251+
252+
class _PasswordLineEditor:
253+
"""Handles line editing for password input with echo character."""
254+
255+
def __init__(self, stream, echo_char, ctrl_chars, prompt=""):
256+
self.stream = stream
257+
self.echo_char = echo_char
258+
self.prompt = prompt
259+
self.password = []
260+
self.cursor_pos = 0
261+
self.eof_pressed = False
262+
self.literal_next = False
263+
self.ctrl = ctrl_chars
264+
self.dispatch = {
265+
ctrl_chars['SOH']: self.handle_move_start, # Ctrl+A
266+
ctrl_chars['ENQ']: self.handle_move_end, # Ctrl+E
267+
ctrl_chars['VT']: self.handle_kill_forward, # Ctrl+K
268+
ctrl_chars['KILL']: self.handle_kill_line, # Ctrl+U
269+
ctrl_chars['WERASE']: self.handle_erase_word, # Ctrl+W
270+
ctrl_chars['ERASE']: self.handle_erase, # DEL
271+
ctrl_chars['BS']: self.handle_erase, # Backspace
272+
# special characters
273+
ctrl_chars['LNEXT']: self.handle_literal_next, # Ctrl+V
274+
ctrl_chars['EOF']: self.handle_eof, # Ctrl+D
275+
ctrl_chars['INTR']: self.handle_interrupt, # Ctrl+C
276+
'\x00': self.handle_nop, # ignore NUL
277+
}
278+
279+
def refresh_display(self, prev_len=None):
280+
"""Redraw the entire password line with *echo_char*.
281+
282+
If *prev_len* is not specified, the current password length is used.
283+
"""
284+
prompt_len = len(self.prompt)
285+
clear_len = prev_len if prev_len is not None else len(self.password)
286+
# Clear the entire line (prompt + password) and rewrite.
287+
self.stream.write('\r' + ' ' * (prompt_len + clear_len) + '\r')
288+
self.stream.write(self.prompt + self.echo_char * len(self.password))
289+
if self.cursor_pos < len(self.password):
290+
self.stream.write('\b' * (len(self.password) - self.cursor_pos))
291+
self.stream.flush()
292+
293+
def insert_char(self, char):
294+
"""Insert *char* at cursor position."""
295+
self.password.insert(self.cursor_pos, char)
296+
self.cursor_pos += 1
297+
# Only refresh if inserting in middle.
298+
if self.cursor_pos < len(self.password):
299+
self.refresh_display()
300+
else:
301+
self.stream.write(self.echo_char)
302+
self.stream.flush()
303+
304+
def is_eol(self, char):
305+
"""Check if *char* is a line terminator."""
306+
return char in ('\r', '\n')
307+
308+
def is_eof(self, char):
309+
"""Check if *char* is a file terminator."""
310+
return char == self.ctrl['EOF']
311+
312+
def handle_move_start(self):
313+
"""Move cursor to beginning (Ctrl+A)."""
314+
self.cursor_pos = 0
315+
self.refresh_display()
316+
317+
def handle_move_end(self):
318+
"""Move cursor to end (Ctrl+E)."""
319+
self.cursor_pos = len(self.password)
320+
self.refresh_display()
321+
322+
def handle_erase(self):
323+
"""Delete character before cursor (Backspace/DEL)."""
324+
if self.cursor_pos == 0:
325+
return
326+
assert self.cursor_pos > 0
327+
self.cursor_pos -= 1
328+
prev_len = len(self.password)
329+
del self.password[self.cursor_pos]
330+
self.refresh_display(prev_len)
331+
332+
def handle_kill_line(self):
333+
"""Erase entire line (Ctrl+U)."""
334+
prev_len = len(self.password)
335+
self.password.clear()
336+
self.cursor_pos = 0
337+
self.refresh_display(prev_len)
338+
339+
def handle_kill_forward(self):
340+
"""Kill from cursor to end (Ctrl+K)."""
341+
prev_len = len(self.password)
342+
del self.password[self.cursor_pos:]
343+
self.refresh_display(prev_len)
344+
345+
def handle_erase_word(self):
346+
"""Erase previous word (Ctrl+W)."""
347+
old_cursor = self.cursor_pos
348+
# Calculate the starting position of the previous word,
349+
# ignoring trailing whitespaces.
350+
while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] == ' ':
351+
self.cursor_pos -= 1
352+
while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ':
353+
self.cursor_pos -= 1
354+
# Delete the previous word and refresh the screen.
355+
prev_len = len(self.password)
356+
del self.password[self.cursor_pos:old_cursor]
357+
self.refresh_display(prev_len)
358+
359+
def handle_literal_next(self):
360+
"""State transition to indicate that the next character is literal."""
361+
assert self.literal_next is False
362+
self.literal_next = True
363+
364+
def handle_eof(self):
365+
"""State transition to indicate that the pressed character was EOF."""
366+
assert self.eof_pressed is False
367+
self.eof_pressed = True
368+
369+
def handle_interrupt(self):
370+
"""Raise a KeyboardInterrupt after Ctrl+C has been received."""
371+
raise KeyboardInterrupt
372+
373+
def handle_nop(self):
374+
"""Handler for an ignored character."""
375+
376+
def handle(self, char):
377+
"""Handle a single character input. Returns True if handled."""
378+
handler = self.dispatch.get(char)
379+
if handler:
380+
handler()
381+
return True
382+
return False
383+
384+
def readline(self, input):
385+
"""Read a line of password input with echo character support."""
386+
while True:
387+
assert self.cursor_pos >= 0
388+
char = input.read(1)
389+
if self.is_eol(char):
205390
break
391+
# Handle literal next mode first as Ctrl+V quotes characters.
392+
elif self.literal_next:
393+
self.insert_char(char)
394+
self.literal_next = False
395+
# Handle EOF now as Ctrl+D must be pressed twice
396+
# consecutively to stop reading from the input.
397+
elif self.is_eof(char):
398+
if self.eof_pressed:
399+
break
400+
elif self.handle(char):
401+
# Dispatched to handler.
402+
pass
206403
else:
207-
eof_pressed = True
208-
elif char == '\x00':
209-
continue
210-
else:
211-
passwd += char
212-
stream.write(echo_char)
213-
stream.flush()
214-
eof_pressed = False
215-
return passwd
404+
# Insert as normal character.
405+
self.insert_char(char)
406+
407+
self.eof_pressed = self.is_eof(char)
408+
409+
return ''.join(self.password)
216410

217411

218412
def getuser():

0 commit comments

Comments
 (0)