diff --git a/openvpncertupdate.py b/openvpncertupdate.py index cc6ecaa..592f801 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -651,9 +651,15 @@ def show_confirm(stdscr, message: str) -> bool: """Y/N modal dialog. Returns True if user presses y/Y.""" sh, sw = stdscr.getmaxyx() lines = message.splitlines() - h = len(lines) + 6 - w = max(max(len(l) for l in lines) + 6, 38) - win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) + # Clamp to the screen: an unbounded width (e.g. a long Cryptgeon URL line) + # makes curses.newwin() raise "curses function returned NULL" outright on + # a narrow terminal (e.g. a serial console) instead of just wrapping badly. + h = min(len(lines) + 6, sh) + w = min(max(max(len(l) for l in lines) + 6, 38), sw) + try: + win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) + except curses.error: + return False win.keypad(True) draw_box(win, "Confirm") for i, line in enumerate(lines): @@ -704,8 +710,14 @@ def show_cert_form( Tab / Shift-Tab / Up / Down cycle fields and buttons. F5 regenerates password. Enter or Space activates the focused button. Ctrl-G confirms immediately. Escape cancels.""" sh, sw = stdscr.getmaxyx() - h, w = 13, 62 - win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) + # Clamp to the screen so curses.newwin() can't raise "curses function + # returned NULL" outright on a terminal narrower/shorter than the usual + # 80x24 (e.g. a serial console with an unusually small viewport). + h, w = min(13, sh), min(62, sw) + try: + win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) + except curses.error: + return CertFormResult(cn=cn, email="", password="", confirmed=False) win.keypad(True) fw = w - 6 # field width diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 2b9a8df..ad18bb2 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -62,6 +62,31 @@ def test_show_confirm_esc(): assert result is False +def test_show_confirm_clamps_width_to_screen_for_long_line(): + """A long message line (e.g. a Cryptgeon password URL) must not make the + requested newwin() width exceed the screen — that's what made + curses.newwin() raise 'curses function returned NULL' on a narrow + terminal such as a serial console.""" + stdscr = _make_stdscr(rows=24, cols=80) + win = _make_win() + win.getch.side_effect = [ord("y")] + long_url = "https://cryptgeon.example.com/note/" + "a" * 100 + "#" + "b" * 64 + with patch("curses.newwin", return_value=win) as mock_newwin: + show_confirm(stdscr, f"Send email?\n\nPassword URL: {long_url}") + h, w, y, x = mock_newwin.call_args.args + assert w <= 80 + assert x >= 0 + + +def test_show_confirm_newwin_failure_returns_false(): + """If curses.newwin() still fails despite clamping, show_confirm must + degrade to 'declined' rather than crash the whole app.""" + stdscr = _make_stdscr() + with patch("curses.newwin", side_effect=_curses.error("returned NULL")): + result = show_confirm(stdscr, "Do you want to continue?") + assert result is False + + # --------------------------------------------------------------------------- # show_cert_form tests # --------------------------------------------------------------------------- @@ -102,3 +127,28 @@ def test_show_cert_form_cancel_button(): result = show_cert_form(stdscr, cn="alice", email="alice@example.com") assert isinstance(result, CertFormResult) assert result.confirmed is False + + +def test_show_cert_form_clamps_to_narrow_screen(): + """The form's fixed 13x62 size must not exceed a smaller-than-usual + screen, which would make curses.newwin() raise.""" + stdscr = _make_stdscr(rows=10, cols=40) + win = _make_win() + win.getch.side_effect = [27] + with patch("curses.newwin", return_value=win) as mock_newwin: + show_cert_form(stdscr, cn="alice", email="alice@example.com") + h, w, y, x = mock_newwin.call_args.args + assert h <= 10 + assert w <= 40 + assert y >= 0 + assert x >= 0 + + +def test_show_cert_form_newwin_failure_returns_cancelled(): + """If curses.newwin() still fails despite clamping, show_cert_form must + degrade to an unconfirmed result rather than crash the whole app.""" + stdscr = _make_stdscr() + with patch("curses.newwin", side_effect=_curses.error("returned NULL")): + result = show_cert_form(stdscr, cn="alice", email="alice@example.com") + assert isinstance(result, CertFormResult) + assert result.confirmed is False