|
26 | 26 | class GetPassWarning(UserWarning): pass |
27 | 27 |
|
28 | 28 |
|
| 29 | +# Default POSIX control character mappings |
| 30 | +_POSIX_CTRL_CHARS = { |
| 31 | + 'ERASE': b'\x7f', # DEL/Backspace |
| 32 | + 'KILL': b'\x15', # Ctrl+U - kill line |
| 33 | + 'WERASE': b'\x17', # Ctrl+W - erase word |
| 34 | + 'LNEXT': b'\x16', # Ctrl+V - literal next |
| 35 | + 'EOF': b'\x04', # Ctrl+D - EOF |
| 36 | + 'INTR': b'\x03', # Ctrl+C - interrupt |
| 37 | + 'SOH': b'\x01', # Ctrl+A - start of heading (beginning of line) |
| 38 | + 'ENQ': b'\x05', # Ctrl+E - enquiry (end of line) |
| 39 | + 'VT': b'\x0b', # Ctrl+K - vertical tab (kill forward) |
| 40 | +} |
| 41 | + |
| 42 | + |
| 43 | +def _get_terminal_ctrl_chars(fd): |
| 44 | + """Extract control characters from terminal settings. |
| 45 | +
|
| 46 | + Returns a dict mapping control char names to their byte values. |
| 47 | + Falls back to POSIX defaults if termios isn't available. |
| 48 | + """ |
| 49 | + try: |
| 50 | + old = termios.tcgetattr(fd) |
| 51 | + cc = old[6] # Index 6 is the control characters array |
| 52 | + return { |
| 53 | + 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'], |
| 54 | + 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'], |
| 55 | + 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], |
| 56 | + 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], |
| 57 | + 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], |
| 58 | + 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], |
| 59 | + # Ctrl+A/E/K are not in termios, use POSIX defaults |
| 60 | + 'SOH': _POSIX_CTRL_CHARS['SOH'], |
| 61 | + 'ENQ': _POSIX_CTRL_CHARS['ENQ'], |
| 62 | + 'VT': _POSIX_CTRL_CHARS['VT'], |
| 63 | + } |
| 64 | + except (termios.error, OSError): |
| 65 | + return _POSIX_CTRL_CHARS.copy() |
| 66 | + |
| 67 | + |
| 68 | +def _decode_ctrl_char(char_value): |
| 69 | + """Convert a control character from bytes to str.""" |
| 70 | + if isinstance(char_value, bytes): |
| 71 | + return char_value.decode('latin-1') |
| 72 | + return char_value |
| 73 | + |
| 74 | + |
29 | 75 | def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): |
30 | 76 | """Prompt for a password, with echo turned off. |
31 | 77 |
|
@@ -77,15 +123,7 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): |
77 | 123 | term_ctrl_chars = None |
78 | 124 | if echo_char: |
79 | 125 | 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 | | - } |
| 126 | + term_ctrl_chars = _get_terminal_ctrl_chars(fd) |
89 | 127 | tcsetattr_flags = termios.TCSAFLUSH |
90 | 128 | if hasattr(termios, 'TCSASOFT'): |
91 | 129 | tcsetattr_flags |= termios.TCSASOFT |
@@ -200,84 +238,160 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None, |
200 | 238 | return line |
201 | 239 |
|
202 | 240 |
|
| 241 | +class _PasswordLineEditor: |
| 242 | + """Handles line editing for password input with echo character.""" |
| 243 | + |
| 244 | + def __init__(self, stream, echo_char, ctrl_chars): |
| 245 | + self.stream = stream |
| 246 | + self.echo_char = echo_char |
| 247 | + self.passwd = "" |
| 248 | + self.cursor_pos = 0 |
| 249 | + self.eof_pressed = False |
| 250 | + self.literal_next = False |
| 251 | + self.ctrl = {name: _decode_ctrl_char(value) |
| 252 | + for name, value in ctrl_chars.items()} |
| 253 | + |
| 254 | + def refresh_display(self): |
| 255 | + """Redraw the entire password line with asterisks.""" |
| 256 | + self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r') |
| 257 | + self.stream.write(self.echo_char * len(self.passwd)) |
| 258 | + if self.cursor_pos < len(self.passwd): |
| 259 | + self.stream.write('\b' * (len(self.passwd) - self.cursor_pos)) |
| 260 | + self.stream.flush() |
| 261 | + |
| 262 | + def erase_chars(self, count): |
| 263 | + """Erase count asterisks from display.""" |
| 264 | + self.stream.write("\b \b" * count) |
| 265 | + |
| 266 | + def insert_char(self, char): |
| 267 | + """Insert character at cursor position.""" |
| 268 | + self.passwd = self.passwd[:self.cursor_pos] + char + self.passwd[self.cursor_pos:] |
| 269 | + self.cursor_pos += 1 |
| 270 | + # Only refresh if inserting in middle |
| 271 | + if self.cursor_pos < len(self.passwd): |
| 272 | + self.refresh_display() |
| 273 | + else: |
| 274 | + self.stream.write(self.echo_char) |
| 275 | + self.stream.flush() |
| 276 | + |
| 277 | + def handle_literal_next(self, char): |
| 278 | + """Insert next character literally (Ctrl+V).""" |
| 279 | + self.insert_char(char) |
| 280 | + self.literal_next = False |
| 281 | + self.eof_pressed = False |
| 282 | + |
| 283 | + def handle_move_start(self): |
| 284 | + """Move cursor to beginning (Ctrl+A).""" |
| 285 | + self.cursor_pos = 0 |
| 286 | + self.eof_pressed = False |
| 287 | + |
| 288 | + def handle_move_end(self): |
| 289 | + """Move cursor to end (Ctrl+E).""" |
| 290 | + self.cursor_pos = len(self.passwd) |
| 291 | + self.eof_pressed = False |
| 292 | + |
| 293 | + def handle_erase(self): |
| 294 | + """Delete character before cursor (Backspace/DEL).""" |
| 295 | + if self.cursor_pos > 0: |
| 296 | + self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:] |
| 297 | + self.cursor_pos -= 1 |
| 298 | + # Only refresh if deleting from middle |
| 299 | + if self.cursor_pos < len(self.passwd): |
| 300 | + self.refresh_display() |
| 301 | + else: |
| 302 | + self.stream.write("\b \b") |
| 303 | + self.stream.flush() |
| 304 | + self.eof_pressed = False |
| 305 | + |
| 306 | + def handle_kill_line(self): |
| 307 | + """Erase entire line (Ctrl+U).""" |
| 308 | + self.erase_chars(len(self.passwd)) |
| 309 | + self.passwd = "" |
| 310 | + self.cursor_pos = 0 |
| 311 | + self.stream.flush() |
| 312 | + self.eof_pressed = False |
| 313 | + |
| 314 | + def handle_kill_forward(self): |
| 315 | + """Kill from cursor to end (Ctrl+K).""" |
| 316 | + chars_to_delete = len(self.passwd) - self.cursor_pos |
| 317 | + self.passwd = self.passwd[:self.cursor_pos] |
| 318 | + self.erase_chars(chars_to_delete) |
| 319 | + self.stream.flush() |
| 320 | + self.eof_pressed = False |
| 321 | + |
| 322 | + def handle_erase_word(self): |
| 323 | + """Erase previous word (Ctrl+W).""" |
| 324 | + old_cursor = self.cursor_pos |
| 325 | + # Skip trailing spaces |
| 326 | + while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] == ' ': |
| 327 | + self.cursor_pos -= 1 |
| 328 | + # Delete the word |
| 329 | + while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] != ' ': |
| 330 | + self.cursor_pos -= 1 |
| 331 | + # Remove the deleted portion |
| 332 | + self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:] |
| 333 | + self.refresh_display() |
| 334 | + self.eof_pressed = False |
| 335 | + |
| 336 | + def build_dispatch_table(self): |
| 337 | + """Build dispatch table mapping control chars to handlers.""" |
| 338 | + return { |
| 339 | + self.ctrl['SOH']: self.handle_move_start, # Ctrl+A |
| 340 | + self.ctrl['ENQ']: self.handle_move_end, # Ctrl+E |
| 341 | + self.ctrl['VT']: self.handle_kill_forward, # Ctrl+K |
| 342 | + self.ctrl['KILL']: self.handle_kill_line, # Ctrl+U |
| 343 | + self.ctrl['WERASE']: self.handle_erase_word, # Ctrl+W |
| 344 | + self.ctrl['ERASE']: self.handle_erase, # DEL |
| 345 | + '\b': self.handle_erase, # Backspace |
| 346 | + } |
| 347 | + |
| 348 | + |
203 | 349 | def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): |
204 | | - passwd = "" |
205 | | - 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 |
| 350 | + """Read password with echo character and line editing support.""" |
| 351 | + if term_ctrl_chars is None: |
| 352 | + term_ctrl_chars = _POSIX_CTRL_CHARS.copy() |
| 353 | + |
| 354 | + editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars) |
| 355 | + dispatch = editor.build_dispatch_table() |
222 | 356 |
|
223 | 357 | while True: |
224 | 358 | char = input.read(1) |
225 | 359 |
|
226 | | - if char == '\n' or char == '\r': |
| 360 | + # Check for line terminators |
| 361 | + if char in ('\n', '\r'): |
227 | 362 | break |
228 | | - elif char == '\x03': |
| 363 | + |
| 364 | + # Handle literal next mode FIRST (Ctrl+V quotes next char) |
| 365 | + if editor.literal_next: |
| 366 | + editor.handle_literal_next(char) |
| 367 | + continue |
| 368 | + |
| 369 | + # Check if it's the LNEXT character |
| 370 | + if char == editor.ctrl['LNEXT']: |
| 371 | + editor.literal_next = True |
| 372 | + editor.eof_pressed = False |
| 373 | + continue |
| 374 | + |
| 375 | + # Check for special control characters |
| 376 | + if char == editor.ctrl['INTR']: |
229 | 377 | raise KeyboardInterrupt |
230 | | - elif char == '\x04': |
231 | | - if eof_pressed: |
| 378 | + if char == editor.ctrl['EOF']: |
| 379 | + if editor.eof_pressed: |
232 | 380 | break |
233 | | - else: |
234 | | - eof_pressed = True |
235 | | - elif char == '\x00': |
| 381 | + editor.eof_pressed = True |
| 382 | + continue |
| 383 | + if char == '\x00': |
236 | 384 | 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 |
| 385 | + |
| 386 | + # Dispatch to handler or insert as normal character |
| 387 | + handler = dispatch.get(char) |
| 388 | + if handler: |
| 389 | + handler() |
275 | 390 | else: |
276 | | - passwd += char |
277 | | - stream.write(echo_char) |
278 | | - stream.flush() |
279 | | - eof_pressed = False |
280 | | - return passwd |
| 391 | + editor.insert_char(char) |
| 392 | + editor.eof_pressed = False |
| 393 | + |
| 394 | + return editor.passwd |
281 | 395 |
|
282 | 396 |
|
283 | 397 | def getuser(): |
|
0 commit comments