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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user