2626class 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+
2968def 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
218412def getuser ():
0 commit comments