fix curses.newwin() crash on narrow terminals in TUI dialogs

show_confirm() sized its window purely from message content, with no
upper bound — a long line (the Cryptgeon password URL in the
post-issuance "send email?" prompt) could easily exceed a narrow
terminal's width (e.g. a serial console), making curses.newwin()
raise "curses function returned NULL" and crash the whole app.
show_cert_form() had the same unguarded newwin() call, just with a
fixed 13x62 size instead of content-driven.

Clamp both windows' height/width to the actual screen size, and wrap
newwin() itself in try/except as a last-resort fallback (declined
confirm / cancelled form) in case clamping still isn't enough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-07-08 16:33:38 +03:00
parent 702dc2fe14
commit f7ce56a6d4
2 changed files with 67 additions and 5 deletions

View File

@@ -651,9 +651,15 @@ def show_confirm(stdscr, message: str) -> bool:
"""Y/N modal dialog. Returns True if user presses y/Y.""" """Y/N modal dialog. Returns True if user presses y/Y."""
sh, sw = stdscr.getmaxyx() sh, sw = stdscr.getmaxyx()
lines = message.splitlines() lines = message.splitlines()
h = len(lines) + 6 # Clamp to the screen: an unbounded width (e.g. a long Cryptgeon URL line)
w = max(max(len(l) for l in lines) + 6, 38) # makes curses.newwin() raise "curses function returned NULL" outright on
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) # 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) win.keypad(True)
draw_box(win, "Confirm") draw_box(win, "Confirm")
for i, line in enumerate(lines): 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. 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.""" Enter or Space activates the focused button. Ctrl-G confirms immediately. Escape cancels."""
sh, sw = stdscr.getmaxyx() sh, sw = stdscr.getmaxyx()
h, w = 13, 62 # Clamp to the screen so curses.newwin() can't raise "curses function
win = curses.newwin(h, w, (sh - h) // 2, (sw - w) // 2) # 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) win.keypad(True)
fw = w - 6 # field width fw = w - 6 # field width

View File

@@ -62,6 +62,31 @@ def test_show_confirm_esc():
assert result is False 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 # 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") result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult) assert isinstance(result, CertFormResult)
assert result.confirmed is False 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