restores working "paging" feature in DatumTool, (#722)
What changed, and why it matters
This commit fixes a text-paging bug in a tool called DatumTool on the Krux hardware wallet. Previously, when a page of text ended exactly at a space, the display logic could drop or misplace a character (showing an ellipsis and losing the second-to-last character). The fix changes how line breaks and ellipses are calculated so text is not silently lost across pages. It also restores working next/previous page navigation. There is no indication this bug was exploitable as a security attack; it is a reliability/correctness fix for user-facing text display.
Treat as a normal bug-fix commit. Reviewers may optionally verify that the new paging logic does not skip or repeat content on real device screen sizes, but no security-specific action is required.
Security signals we found
Off-by-one in display line wrapping could truncate or duplicate text across pages
Fix is framed by the author as restoring a broken paging feature, not as a security fix
No input validation, memory safety, cryptographic, or privilege changes present
Evidence from the diff
The patch renames Display._to_lines_endpos() to Display.to_lines_endpos() and makes it public. It adjusts the line-wrapping condition from line_pixels + FONT_WIDTH >= usable_pixels to line_pixels + FONT_WIDTH > usable_pixels, preventing an off-by-one that replaced the second-to-last character with an ellipsis when the last character was a breakable space. DatumTool._show_contents() is rewritten to build an actual page-index list (pages) using the returned endpos, instead of estimating total pages with math.ceil(content_len / (ascii_chars_per_line * max_lines)). Page navigation now moves through recorded start indices rather than recomputing them, fixing multi-page display. Tests are added/updated for the new behavior.
Changed components
src/krux/display.pysrc/krux/pages/datum_tool.pyInspect captured patch +95 / −37
diff --git a/src/krux/display.py b/src/krux/display.py
index 148cbdf..d6c65be 100644
--- a/src/krux/display.py
+++ b/src/krux/display.py
@@ -258,9 +258,9 @@ class Display:
return self.usable_width() if not kboard.is_m5stickv else self.width()
def to_lines(self, text, max_lines=TOTAL_LINES):
- """Maintains original API while using ._to_lines_endpos()"""
+ """Maintains original API while using .to_lines_endpos()"""
- return self._to_lines_endpos(text, max_lines)[0]
+ return self.to_lines_endpos(text, max_lines)[0]
def ascii_chars_per_line(self):
"""Returns the qtd of non wide chars that fit on one line (columns)"""
@@ -270,7 +270,7 @@ class Display:
"""Returns the qtd of wide chars that fit on one line (columns)"""
return self._usable_pixels_in_line() // FONT_WIDTH_WIDE
- def _to_lines_endpos(self, text, max_lines=TOTAL_LINES):
+ def to_lines_endpos(self, text, max_lines=TOTAL_LINES):
"""Takes a string of text and returns tuple(lines, end) to display on
the screen and know how far into text it read; next page starts there.
"""
@@ -346,7 +346,7 @@ class Display:
FONT_WIDTH_WIDE if ord(c) >= ASIAN_MIN_CODEPOINT else FONT_WIDTH
)
char_count += 1
- if line_pixels + FONT_WIDTH >= usable_pixels:
+ if line_pixels + FONT_WIDTH > usable_pixels:
lines[-1] = lines[-1][: char_count - 1] + ELLIPSIS
end -= 1
else:
diff --git a/src/krux/pages/datum_tool.py b/src/krux/pages/datum_tool.py
index 215c09b..902b1e6 100644
--- a/src/krux/pages/datum_tool.py
+++ b/src/krux/pages/datum_tool.py
@@ -576,12 +576,10 @@ class DatumTool(Page):
def _show_contents(self):
"""Displays infobox and contents"""
from binascii import hexlify
- from ..settings import THIN_SPACE
from ..kboard import kboard
- import math
import time
- page_indicator = "p" + THIN_SPACE + "%d/%s"
+ page_indicator = "p.%d"
max_lines = 0
offset_x = (
DEFAULT_PADDING
@@ -595,23 +593,25 @@ class DatumTool(Page):
)
content_len = len(contents)
- def _update_infobox(curr_page, total="?"):
+ def _update_infobox(curr_page):
info_len = self._info_box(
- preview=False, about_suffix=page_indicator % (curr_page, total)
+ preview=False, about_suffix=page_indicator % (curr_page)
)
max_lines = TOTAL_LINES - (info_len + 1)
- total = math.ceil(
- content_len / (self.ctx.display.ascii_chars_per_line() * max_lines)
- )
- return info_len, total, max_lines
+ return info_len, max_lines
- curr_page = 0
+ curr_page, pages = 0, [0]
start_index = 0
- info_len, last_page, max_lines = _update_infobox(curr_page + 1)
+ info_len, max_lines = _update_infobox(curr_page + 1)
while True:
- info_len, last_page, max_lines = _update_infobox(curr_page + 1, last_page)
- lines = self.ctx.display.to_lines(contents[start_index:], max_lines)
+ info_len, max_lines = _update_infobox(curr_page + 1)
+ lines, endpos = self.ctx.display.to_lines_endpos(
+ contents[start_index:], max_lines
+ )
+ endpos += start_index
+ if pages[-1] < endpos < content_len:
+ pages.append(endpos)
offset_y = DEFAULT_PADDING + (info_len) * FONT_HEIGHT + 1
for line in lines:
@@ -627,16 +627,14 @@ class DatumTool(Page):
else:
btn = self.ctx.input.wait_for_button()
if btn in (BUTTON_PAGE, FAST_FORWARD, SWIPE_UP, SWIPE_LEFT):
- curr_page = (curr_page + 1) % last_page
+ if curr_page + 1 < len(pages):
+ curr_page += 1
elif btn in (BUTTON_PAGE_PREV, FAST_BACKWARD, SWIPE_DOWN, SWIPE_RIGHT):
- curr_page = (curr_page - 1) % last_page
+ if curr_page > 0:
+ curr_page -= 1
elif btn in (BUTTON_ENTER, BUTTON_TOUCH):
break
- start_index = (
- 0
- if curr_page == 0
- else curr_page * self.ctx.display.ascii_chars_per_line() * max_lines - 1
- )
+ start_index = pages[curr_page]
def _analyze_contents(self):
"""
diff --git a/tests/pages/test_datum_tool.py b/tests/pages/test_datum_tool.py
index aec716a..b635a3d 100644
--- a/tests/pages/test_datum_tool.py
+++ b/tests/pages/test_datum_tool.py
@@ -657,7 +657,6 @@ def test_datumtool__show_contents(m5stickv, mocker):
"""With DatumTool already initialized, test ._show_contents()"""
from krux.pages.datum_tool import DatumTool
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
- from krux.settings import THIN_SPACE
# call with text
ctx = create_ctx(mocker, [BUTTON_PAGE, BUTTON_PAGE_PREV, BUTTON_ENTER])
@@ -669,7 +668,7 @@ def test_datumtool__show_contents(m5stickv, mocker):
page._show_contents()
assert ctx.input.wait_for_button.call_count == 3
ctx.display.draw_hcentered_text.assert_called_with(
- "Text\nabout p" + THIN_SPACE + "1/1", info_box=True, highlight_prefix=":"
+ "Text\nabout p.1", info_box=True, highlight_prefix=":"
)
# call with bytes
@@ -682,7 +681,7 @@ def test_datumtool__show_contents(m5stickv, mocker):
page._show_contents()
assert ctx.input.wait_for_button.call_count == 1
ctx.display.draw_hcentered_text.assert_called_with(
- "Bytes\nabout p" + THIN_SPACE + "1/1", info_box=True, highlight_prefix=":"
+ "Bytes\nabout p.1", info_box=True, highlight_prefix=":"
)
@@ -1049,6 +1048,35 @@ def test_datumtool_view_contents(m5stickv, mocker, mock_file_operations):
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+def test_datumtool_view_contents_multi_page(m5stickv, mocker):
+ """simply to cover building of `pages` index, moving to `next page`, and `prev page`"""
+ from krux.pages.datum_tool import DatumTool
+ from krux.input import PRESSED, BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+
+ # call with text that will span more than one page
+ BTN_SEQUENCE = [
+ BUTTON_ENTER, # go Show Datum
+ BUTTON_PAGE, # page
+ BUTTON_PAGE_PREV, # page_prev
+ BUTTON_ENTER, # escape Show Datum
+ BUTTON_PAGE_PREV, # to Back
+ BUTTON_ENTER, # go Back
+ ]
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ ctx.display.to_lines_endpos = mocker.MagicMock(
+ side_effect=[
+ ([str(x) for x in range(15)] + ["15…"], 22),
+ ([str(x) for x in range(16, 26)], 20),
+ ([str(x) for x in range(15)] + ["15…"], 22),
+ ]
+ )
+ page = DatumTool(ctx)
+ page.contents = "\n".join([str(x) for x in range(26)])
+ page.title = "title"
+ page.view_contents()
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+
def test_datumtool_show_contents_button_turbo(mocker, m5stickv):
from krux.pages.datum_tool import DatumTool
from krux.input import PRESSED, BUTTON_ENTER, KEY_REPEAT_DELAY_MS
diff --git a/tests/shared_mocks.py b/tests/shared_mocks.py
index d890215..592067a 100644
--- a/tests/shared_mocks.py
+++ b/tests/shared_mocks.py
@@ -675,6 +675,7 @@ def mock_context(mocker):
usable_pixels_in_line=mocker.MagicMock(return_value=135),
ascii_chars_per_line=mocker.MagicMock(return_value=135 // 8),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=7),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
@@ -698,6 +699,7 @@ def mock_context(mocker):
usable_pixels_in_line=mocker.MagicMock(return_value=(240 - 2 * 10)),
ascii_chars_per_line=mocker.MagicMock(return_value=(240 - 2 * 10) // 8),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=9),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
@@ -724,6 +726,7 @@ def mock_context(mocker):
return_value=(320 - 2 * 10) // 12
),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=9),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
@@ -747,6 +750,7 @@ def mock_context(mocker):
usable_pixels_in_line=mocker.MagicMock(return_value=(240 - 2 * 10)),
ascii_chars_per_line=mocker.MagicMock(return_value=(240 - 2 * 10) // 8),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=7),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
@@ -771,6 +775,7 @@ def mock_context(mocker):
usable_pixels_in_line=mocker.MagicMock(return_value=(240 - 2 * 10)),
ascii_chars_per_line=mocker.MagicMock(return_value=(240 - 2 * 10) // 8),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=9),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
@@ -794,6 +799,7 @@ def mock_context(mocker):
usable_pixels_in_line=mocker.MagicMock(return_value=(240 - 2 * 10)),
ascii_chars_per_line=mocker.MagicMock(return_value=(240 - 2 * 10) // 8),
to_lines=mocker.MagicMock(return_value=[""]),
+ to_lines_endpos=mocker.MagicMock(return_value=([""], 0)),
max_menu_lines=mocker.MagicMock(return_value=9),
draw_hcentered_text=mocker.MagicMock(return_value=1),
),
diff --git a/tests/test_display.py b/tests/test_display.py
index a0e84e8..a814c52 100644
--- a/tests/test_display.py
+++ b/tests/test_display.py
@@ -377,6 +377,32 @@ def test_to_lines_exact_match_amigo(mocker, amigo):
"01 345 0123456789012345678\n01234 0123456789012345678",
["01 345", "0123456789012345678", "01234 0123456789012345678"],
),
+ (
+ 320,
+ "events witnessed and proof that it came from the largest pool of CPU power. As long as a majority of CPU power is controlled by nodes that are not cooperating to attack the network, they'll generate the longest chain and outpace attackers. The network itself requires minimal structure, with messages broadcast on a best effort basis and nodes able to leave and rejoin at will, accepting the longest proof-of-work chain as proof of what happened while they were gone.",
+ [
+ "events witnessed and",
+ "proof that it came from",
+ "the largest pool of CPU",
+ "power. As long as a",
+ "majority of CPU power is",
+ "controlled by nodes that",
+ "are not cooperating to",
+ "attack the network,",
+ "they'll generate the",
+ "longest chain and outpace",
+ "attackers. The network",
+ "itself requires minimal",
+ "structure, with messages",
+ "broadcast on a best",
+ "effort basis and nodes",
+ "able to leave and rejoin",
+ "at will, accepting the",
+ "longest proof-of-work",
+ "chain as proof of what",
+ "happened while they were…",
+ ],
+ ),
]
for i, case in enumerate(cases):
print("case:", i)
@@ -458,7 +484,7 @@ def test_to_lines_endpos(mocker, m5stickv):
text = "I am a long line of text, and I will be repeated." * 30
d = Display()
d.to_portrait()
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
assert len(lines) == max_lines
assert endpos == 249
@@ -470,18 +496,18 @@ def test_to_lines_endpos(mocker, m5stickv):
)
d = Display()
d.to_portrait()
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
assert len(lines) == max_lines
assert endpos == len(text)
assert lines[-1][-1] != "\u2026" # no ellipsis
# ... and that one char too big would span a page w/ ellipsis
text += "+"
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
assert len(lines) == max_lines
assert endpos == len(text) - len("chars+")
assert lines[-1][-1] == "\u2026" # has ellipsis
- lines, endpos = d._to_lines_endpos(text[endpos:], max_lines)
+ lines, endpos = d.to_lines_endpos(text[endpos:], max_lines)
assert len(lines) == 1
assert lines[-1] == "chars+" # space gets stripped
@@ -489,19 +515,19 @@ def test_to_lines_endpos(mocker, m5stickv):
text = "".join(["line_{:02d}_16_chars".format(x) for x in range(1, max_lines + 1)])
d = Display()
d.to_portrait()
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
assert len(lines) == max_lines
assert endpos == len(text)
assert lines[-1][-1] != "\u2026" # no ellipsis
# ... and that one char too big would span a page w/ ellipsis
text += "+"
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
old_end_pos = endpos
assert len(lines) == max_lines
assert endpos == len(text) - len("s+")
assert lines[-1][-1] == "\u2026" # has ellipsis
- lines, endpos = d._to_lines_endpos(text[endpos:], max_lines)
+ lines, endpos = d.to_lines_endpos(text[endpos:], max_lines)
assert len(lines) == 1
assert lines[-1] == "s+"
@@ -516,7 +542,7 @@ def test_to_lines_endpos(mocker, m5stickv):
text = "0123456789abc"
max_lines = 1
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
print(lines, endpos, text[endpos:])
assert len(lines) == max_lines
assert len(lines[0]) == chars_per_line
@@ -528,12 +554,12 @@ def test_to_lines_endpos(mocker, m5stickv):
text += "x" # 169 + 1 = 170
max_lines = TOTAL_LINES # 17
print(TOTAL_LINES)
- lines, endpos = d._to_lines_endpos(text, max_lines)
+ lines, endpos = d.to_lines_endpos(text, max_lines)
print(lines, len(lines), endpos)
for i in range(max_lines):
assert len(lines[i]) == chars_per_line
- lines, _ = d._to_lines_endpos(text[endpos:])
+ lines, _ = d.to_lines_endpos(text[endpos:])
assert lines == [""] # vazio
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.