From d7547810fee40cd31abc0c163d165ceaaae404db Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 16 Jul 2025 03:32:06 -0400 Subject: [PATCH] Add cross-platform masked input utility with tests --- src/tests/test_seed_prompt.py | 30 ++++++++++++++ src/utils/__init__.py | 2 + src/utils/seed_prompt.py | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/tests/test_seed_prompt.py create mode 100644 src/utils/seed_prompt.py diff --git a/src/tests/test_seed_prompt.py b/src/tests/test_seed_prompt.py new file mode 100644 index 0000000..68876d3 --- /dev/null +++ b/src/tests/test_seed_prompt.py @@ -0,0 +1,30 @@ +import types +from utils import seed_prompt + + +def test_masked_input_posix_backspace(monkeypatch, capsys): + seq = iter(["a", "b", "\x7f", "c", "\n"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq)) + monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0) + monkeypatch.setattr(seed_prompt.termios, "tcgetattr", lambda fd: None) + monkeypatch.setattr(seed_prompt.termios, "tcsetattr", lambda fd, *_: None) + monkeypatch.setattr(seed_prompt.tty, "setraw", lambda fd: None) + + result = seed_prompt.masked_input("Enter: ") + assert result == "ac" + out = capsys.readouterr().out + assert out.startswith("Enter: ") + assert out.count("*") == 3 + + +def test_masked_input_windows_space(monkeypatch, capsys): + seq = iter(["x", "y", " ", "z", "\r"]) + fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq)) + monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt) + monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False) + + result = seed_prompt.masked_input("Password: ") + assert result == "xy z" + out = capsys.readouterr().out + assert out.startswith("Password: ") + assert out.count("*") == 4 diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 01e058c..1a94070 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -25,6 +25,7 @@ try: update_checksum_file, ) from .password_prompt import prompt_for_password + from .seed_prompt import masked_input from .input_utils import timed_input from .memory_protection import InMemorySecret from .clipboard import copy_to_clipboard @@ -58,6 +59,7 @@ __all__ = [ "exclusive_lock", "shared_lock", "prompt_for_password", + "masked_input", "timed_input", "InMemorySecret", "copy_to_clipboard", diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py new file mode 100644 index 0000000..80ff91f --- /dev/null +++ b/src/utils/seed_prompt.py @@ -0,0 +1,73 @@ +import os +import sys + +try: + import msvcrt # type: ignore +except ImportError: # pragma: no cover - Windows only + msvcrt = None # type: ignore + +try: + import termios + import tty +except ImportError: # pragma: no cover - POSIX only + termios = None # type: ignore + tty = None # type: ignore + + +def _masked_input_windows(prompt: str) -> str: + """Windows implementation using ``msvcrt``.""" + if msvcrt is None: # pragma: no cover - should not happen + return input(prompt) + + sys.stdout.write(prompt) + sys.stdout.flush() + buffer: list[str] = [] + while True: + ch = msvcrt.getwch() + if ch in ("\r", "\n"): + sys.stdout.write("\n") + return "".join(buffer) + if ch in ("\b", "\x7f"): + if buffer: + buffer.pop() + sys.stdout.write("\b \b") + else: + buffer.append(ch) + sys.stdout.write("*") + sys.stdout.flush() + + +def _masked_input_posix(prompt: str) -> str: + """POSIX implementation using ``termios`` and ``tty``.""" + if termios is None or tty is None: # pragma: no cover - should not happen + return input(prompt) + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + sys.stdout.write(prompt) + sys.stdout.flush() + buffer: list[str] = [] + try: + tty.setraw(fd) + while True: + ch = sys.stdin.read(1) + if ch in ("\r", "\n"): + sys.stdout.write("\n") + return "".join(buffer) + if ch in ("\x7f", "\b"): + if buffer: + buffer.pop() + sys.stdout.write("\b \b") + else: + buffer.append(ch) + sys.stdout.write("*") + sys.stdout.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def masked_input(prompt: str) -> str: + """Return input from the user while masking typed characters.""" + if sys.platform == "win32": + return _masked_input_windows(prompt) + return _masked_input_posix(prompt)