Skip to content

Commit d280ce9

Browse files
fix: prevent prompt corruption during getpass echo_char line editing
1 parent 6a59f3b commit d280ce9

2 files changed

Lines changed: 57 additions & 24 deletions

File tree

Lib/getpass.py

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
217217
# NOTE: The Python C API calls flockfile() (and unlock) during readline.
218218
if echo_char:
219219
return _readline_with_echo_char(stream, input, echo_char,
220-
term_ctrl_chars)
220+
term_ctrl_chars, prompt)
221221
line = input.readline()
222222
if not line:
223223
raise EOFError
@@ -229,9 +229,10 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
229229
class _PasswordLineEditor:
230230
"""Handles line editing for password input with echo character."""
231231

232-
def __init__(self, stream, echo_char, ctrl_chars):
232+
def __init__(self, stream, echo_char, ctrl_chars, prompt=""):
233233
self.stream = stream
234234
self.echo_char = echo_char
235+
self.prompt = prompt
235236
self.passwd = []
236237
self.cursor_pos = 0
237238
self.eof_pressed = False
@@ -247,18 +248,18 @@ def __init__(self, stream, echo_char, ctrl_chars):
247248
'\b': self._handle_erase, # Backspace
248249
}
249250

250-
def _refresh_display(self):
251+
def _refresh_display(self, prev_len=None):
251252
"""Redraw the entire password line with *echo_char*."""
252-
self.stream.write('\r' + ' ' * len(self.passwd) + '\r')
253-
self.stream.write(self.echo_char * len(self.passwd))
253+
prompt_len = len(self.prompt)
254+
# Use prev_len if given, otherwise current password length
255+
clear_len = prev_len if prev_len is not None else len(self.passwd)
256+
# Clear the entire line (prompt + password) and rewrite
257+
self.stream.write('\r' + ' ' * (prompt_len + clear_len) + '\r')
258+
self.stream.write(self.prompt + self.echo_char * len(self.passwd))
254259
if self.cursor_pos < len(self.passwd):
255260
self.stream.write('\b' * (len(self.passwd) - self.cursor_pos))
256261
self.stream.flush()
257262

258-
def _erase_chars(self, count):
259-
"""Erase *count* echo characters from display."""
260-
self.stream.write("\b \b" * count)
261-
262263
def _insert_char(self, char):
263264
"""Insert *char* at cursor position."""
264265
self.passwd.insert(self.cursor_pos, char)
@@ -282,28 +283,23 @@ def _handle_erase(self):
282283
"""Delete character before cursor (Backspace/DEL)."""
283284
if self.cursor_pos <= 0:
284285
return
286+
prev_len = len(self.passwd)
285287
del self.passwd[self.cursor_pos - 1]
286288
self.cursor_pos -= 1
287-
# Only refresh if deleting from middle
288-
if self.cursor_pos < len(self.passwd):
289-
self._refresh_display()
290-
else:
291-
self.stream.write("\b \b")
292-
self.stream.flush()
289+
self._refresh_display(prev_len)
293290

294291
def _handle_kill_line(self):
295292
"""Erase entire line (Ctrl+U)."""
296-
self._erase_chars(len(self.passwd))
293+
prev_len = len(self.passwd)
297294
self.passwd.clear()
298295
self.cursor_pos = 0
299-
self.stream.flush()
296+
self._refresh_display(prev_len)
300297

301298
def _handle_kill_forward(self):
302299
"""Kill from cursor to end (Ctrl+K)."""
303-
chars_to_delete = len(self.passwd) - self.cursor_pos
300+
prev_len = len(self.passwd)
304301
del self.passwd[self.cursor_pos:]
305-
self._erase_chars(chars_to_delete)
306-
self.stream.flush()
302+
self._refresh_display(prev_len)
307303

308304
def _handle_erase_word(self):
309305
"""Erase previous word (Ctrl+W)."""
@@ -315,8 +311,9 @@ def _handle_erase_word(self):
315311
while self.cursor_pos > 0 and self.passwd[self.cursor_pos - 1] != ' ':
316312
self.cursor_pos -= 1
317313
# Remove the deleted portion
314+
prev_len = len(self.passwd)
318315
del self.passwd[self.cursor_pos:old_cursor]
319-
self._refresh_display()
316+
self._refresh_display(prev_len)
320317

321318
def handle(self, char):
322319
"""Handle a single character input. Returns True if handled."""
@@ -328,12 +325,13 @@ def handle(self, char):
328325
return False
329326

330327

331-
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None):
328+
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None,
329+
prompt=""):
332330
"""Read password with echo character and line editing support."""
333331
if term_ctrl_chars is None:
334332
term_ctrl_chars = _POSIX_CTRL_CHARS.copy()
335333

336-
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars)
334+
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)
337335

338336
while True:
339337
char = input.read(1)

Lib/test/test_getpass.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,13 @@ def test_control_chars_with_echo_char(self):
199199
result = getpass._raw_input('Password: ', mock_output, mock_input,
200200
'*')
201201
self.assertEqual(result, expect_result)
202-
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
202+
# After backspace: refresh rewrites prompt + 6 echo chars
203+
self.assertEqual(
204+
'Password: *******' # initial prompt + 7 echo chars
205+
'\r' + ' ' * 17 + '\r' # clear line (10 prompt + 7 prev)
206+
'Password: ******', # rewrite prompt + 6 echo chars
207+
mock_output.getvalue()
208+
)
203209

204210
def test_kill_ctrl_u_with_echo_char(self):
205211
# Ctrl+U (KILL) should clear the entire line
@@ -226,6 +232,35 @@ def test_werase_ctrl_w_with_echo_char(self):
226232
'*')
227233
self.assertEqual(result, expect_result)
228234

235+
def test_ctrl_w_display_preserves_prompt(self):
236+
# Reproducer from gh-138577: type "hello world", Ctrl+W
237+
# Display must show "Password: ******" not "******rd: ***********"
238+
passwd = 'hello world\x17'
239+
mock_input = StringIO(f'{passwd}\n')
240+
mock_output = StringIO()
241+
result = getpass._raw_input('Password: ', mock_output, mock_input,
242+
'*')
243+
self.assertEqual(result, 'hello ')
244+
output = mock_output.getvalue()
245+
# The final visible state should be "Password: ******"
246+
# Verify prompt is rewritten during refresh, not overwritten by stars
247+
self.assertTrue(output.endswith('Password: ******'),
248+
f'Prompt corrupted in display: {output!r}')
249+
250+
def test_ctrl_a_insert_display_preserves_prompt(self):
251+
# Reproducer from gh-138577: type "abc", Ctrl+A, type "x"
252+
# Display must show "Password: ****" not "****word: ***"
253+
passwd = 'abc\x01x'
254+
mock_input = StringIO(f'{passwd}\n')
255+
mock_output = StringIO()
256+
result = getpass._raw_input('Password: ', mock_output, mock_input,
257+
'*')
258+
self.assertEqual(result, 'xabc')
259+
output = mock_output.getvalue()
260+
# The final visible state should be "Password: ****"
261+
self.assertTrue(output.endswith('Password: ****\x08\x08\x08'),
262+
f'Prompt corrupted in display: {output!r}')
263+
229264
def test_lnext_ctrl_v_with_echo_char(self):
230265
# Ctrl+V (LNEXT) should insert the next character literally
231266
passwd = 'test\x16\x15more' # Type "test", hit Ctrl+V, then Ctrl+U (literal), type "more"

0 commit comments

Comments
 (0)