diff --git a/docs/release/7.14.2.md b/docs/release/7.14.2.md index 2f8d5a301..e4f2cd4c4 100644 --- a/docs/release/7.14.2.md +++ b/docs/release/7.14.2.md @@ -157,7 +157,7 @@ landed, so the table below is the current state, not what #440 originally set. | Path | Default device | Why | |---|---|---| -| ETH `personal_sign` | **works** | `confirm_bytes()` paginates every signed byte, so #432's gate was dropped | +| ETH `personal_sign` | **works** | `confirm_bytes()` paginates every signed byte; hold scrolls quickly, release pauses, and a fresh final hold approves, so #432's gate was dropped | | TRON `SignMessage` | **works** | same — pager, gate dropped | | Bitcoin-family `SignMessage` | **works** | same | | ETH **structured EIP-712** | **DISABLED** | withdrawn pending complete canonical hardening | diff --git a/docs/security/7.14.2-signing-hardening.md b/docs/security/7.14.2-signing-hardening.md index 1d148c20c..edad39c3d 100644 --- a/docs/security/7.14.2-signing-hardening.md +++ b/docs/security/7.14.2-signing-hardening.md @@ -21,7 +21,7 @@ what the device signs. | Ethereum EIP-712 and TRON TIP-712 precomputed hashes | Require AdvancedMode, validate digest lengths, and show an explicit blind-signing warning before the existing address/hash confirmations. | | Structured Ethereum EIP-712 | Fail closed at the endpoint. The legacy parser/display path is disabled until canonical types, values, JSON shape, addresses, integers, booleans, and primary type are fully bound to the hash. | | EOS unknown actions | Require AdvancedMode and include `EOS_NewAccount` in the structured-action allowlist. The gate does not depend on `review()`, which discards `confirm_helper()`'s result on this line. | -| Exact signed-content review | Add an exact-byte OLED pager. Spaces, backslashes, controls, and non-ASCII bytes are rendered as `\xNN`; page boundaries are calculated from the actual OLED font metrics; every page requires confirmation. This closes the #428 leading-whitespace and clipped-suffix pattern without changing unrelated generic dialogs. | +| Exact signed-content review | Add an exact-byte OLED pager. Spaces, backslashes, controls, and non-ASCII bytes are rendered as `\xNN`; page boundaries are calculated from the actual OLED renderer, including a rejected final glyph. Holding the button advances at 120 ms per page, releasing pauses on the current page, and reaching the final page requires a release plus a fresh hold before approval. The same renderer-backed scrolling replaces the generic `Cut Off` warning for complete formatted bodies. This closes the #428 leading-whitespace and clipped-suffix pattern without approving a prefix. | | Binance transfers | Validate the complete transfer shape, positive/equal amounts, canonical bounded denomination grammar, message/session state, and serialization results before signing. | | Cosmos IBC | Retain receiver disclosure, require the signed `uatom` denomination, and review sender/receiver through the exact-byte pager. | | Generic Tendermint | Bind every ACK to the initialized protocol and the original chain name, denomination, and message-type prefix; use those bound values for display and hashing. | @@ -39,11 +39,11 @@ structured EIP-712 endpoint. ## Regression evidence - Pinned `kktech/firmware:v15` emulator build: passed. -- Board unit tests: 6/6 passed, including exact rendering of whitespace, - newlines, backslashes, embedded NULs, and an oversized whitespace-prefix - payload whose suffix must appear on a later page. -- Focused firmware tests: 16/16 passed across Binance, Cosmos/Tendermint, EOS, - and Ethereum policy/parser behavior. +- Board unit tests: 11/11 passed, including exact rendering of whitespace, + newlines, backslashes, embedded NULs, complete long-hex pagination at both + standard and icon widths, and an oversized whitespace-prefix payload whose + suffix must appear on a later page. +- Firmware unit tests: 78/78 passed across the complete emulator unit target. - Crypto unit tests: 4/4 passed. - Release-style `arm-none-eabi` build: `firmware.keepkey.elf` linked successfully. diff --git a/include/keepkey/board/confirm_sm.h b/include/keepkey/board/confirm_sm.h index 69e252cf5..b4a7f0716 100644 --- a/include/keepkey/board/confirm_sm.h +++ b/include/keepkey/board/confirm_sm.h @@ -25,6 +25,8 @@ #include "keepkey/board/layout.h" #include +#include +#include /* implement a means to display debug information */ #ifdef DEBUG_ON @@ -53,7 +55,19 @@ /* The number of milliseconds to wait for a confirmation */ #define CONFIRM_TIMEOUT_MS 1200 -typedef enum { HOME, CONFIRM_WAIT, CONFIRMED, FINISHED } DisplayState; +/* Long confirmations advance quickly while the button remains down. The + * initial delay prevents an ordinary tap from skipping the first page; later + * pages move at roughly eight screens per second. */ +#define CONFIRM_SCROLL_INITIAL_MS 300 +#define CONFIRM_SCROLL_PERIOD_MS 120 + +typedef enum { + HOME, + SCROLLING, + CONFIRM_WAIT, + CONFIRMED, + FINISHED +} DisplayState; typedef enum { LAYOUT_REQUEST, @@ -86,17 +100,40 @@ typedef struct { typedef void (*layout_notification_t)(const char* str1, const char* str2, NotificationType type); +/** Format the largest displayable prefix of data into a NUL-terminated page. + * + * The return value is the number of input bytes represented by the page. + */ +typedef size_t (*confirm_page_formatter_t)(const uint8_t* data, size_t size, + char* out, size_t out_len, + uint16_t body_width); + /// \brief Will a confirmation body fit on the screen it is drawn on? /// /// draw_string() stops drawing once a glyph no longer fits the canvas and /// reports nothing, so a body taller than BODY_ROWS rows is shown in part with -/// nothing on screen to say so. Callers that measure first can say so. +/// nothing on screen to say so. Callers use this boundary to paginate first. /// /// \param body The body text as it will be drawn (NULL reads as ""). /// \param body_width Wrap width: BODY_WIDTH, or BODY_WIDTH_WITH_ICON. /// \returns true iff the whole body will be on screen. bool confirm_body_fits(const char* body, uint16_t body_width); +/** Split ordinary confirmation text using the real OLED renderer boundary. */ +size_t confirm_body_format_page(const uint8_t* data, size_t size, char* out, + size_t out_len, uint16_t body_width); + +/** + * Confirm a formatter-backed, potentially multi-page value. + * + * A long value starts on page one. Holding advances pages automatically; + * releasing pauses on the current page. After the final page has been shown, + * the user must release and perform a fresh hold to approve. + */ +bool confirm_paged(ButtonRequestType type, const char* request_title, + const uint8_t* data, size_t size, + confirm_page_formatter_t formatter); + /// User confirmation. /// \param type The kind of button request to send to the host. /// \param request_title Title of confirm message. diff --git a/lib/board/confirm_sm.c b/lib/board/confirm_sm.c index b27a3222e..475dc29dd 100644 --- a/lib/board/confirm_sm.c +++ b/lib/board/confirm_sm.c @@ -45,6 +45,26 @@ static bool button_request_acked = false; extern bool reset_msg_stack; static CONFIDENTIAL char strbuf[BODY_CHAR_MAX]; +static CONFIDENTIAL char scroll_body[BODY_CHAR_MAX]; +static CONFIDENTIAL char scroll_title[TITLE_CHAR_MAX]; + +typedef struct { + bool enabled; + volatile bool advance; + const uint8_t* data; + size_t size; + size_t offset; + size_t page; + size_t pages; + uint16_t body_width; + confirm_page_formatter_t formatter; + const char* title; +} ScrollInfo; + +typedef struct { + volatile StateInfo* state; + volatile ScrollInfo* scroll; +} ScreenContext; /* Set by format_body() when the formatted body did not fit strbuf, i.e. when * characters were lost before any screen existed to show them. Read and @@ -66,13 +86,20 @@ static void format_body(const char* request_body, va_list vl) { static void handle_screen_press(void* context) { assert(context != NULL); - StateInfo* si = (StateInfo*)context; + ScreenContext* screen = (ScreenContext*)context; if (button_request_acked) { + volatile StateInfo* si = screen->state; + const volatile ScrollInfo* scroll = screen->scroll; switch (si->display_state) { case HOME: - si->active_layout = LAYOUT_CONFIRM_ANIMATION; - si->display_state = CONFIRM_WAIT; + if (scroll->enabled && scroll->offset < scroll->size) { + si->active_layout = LAYOUT_REQUEST_NO_ANIMATION; + si->display_state = SCROLLING; + } else { + si->active_layout = LAYOUT_CONFIRM_ANIMATION; + si->display_state = CONFIRM_WAIT; + } break; default: @@ -86,9 +113,17 @@ static void handle_screen_press(void* context) { static void handle_screen_release(void* context) { assert(context != NULL); - StateInfo* si = (StateInfo*)context; + ScreenContext* screen = (ScreenContext*)context; + volatile StateInfo* si = screen->state; switch (si->display_state) { + case SCROLLING: + /* Pause on exactly the page that is currently visible. Reaching the end + * still comes back through HOME, so approval requires a fresh press. */ + si->active_layout = LAYOUT_REQUEST_NO_ANIMATION; + si->display_state = HOME; + break; + case CONFIRM_WAIT: si->active_layout = LAYOUT_REQUEST_NO_ANIMATION; si->display_state = HOME; @@ -104,6 +139,17 @@ static void handle_screen_release(void* context) { } } +/// Ask the main loop to advance one page. Formatting and drawing stay out of +/// the timer interrupt so the OLED never reads a page buffer while it changes. +static void handle_scroll_timeout(void* context) { + assert(context != NULL); + + ScreenContext* screen = (ScreenContext*)context; + if (screen->state->display_state == SCROLLING) { + screen->scroll->advance = true; + } +} + /// User has held down the push button for duration as requested. /// \param context current state context. static void handle_confirm_timeout(void* context) { @@ -166,6 +212,54 @@ static void swap_layout(ActiveLayout active_layout, volatile StateInfo* si, }; } +static size_t count_scroll_pages(const uint8_t* data, size_t size, + confirm_page_formatter_t formatter, + uint16_t body_width) { + size_t pages = 0; + size_t offset = 0; + + while (offset < size) { + const size_t take = formatter(data + offset, size - offset, scroll_body, + sizeof(scroll_body), body_width); + if (take == 0 || take > size - offset) return 0; + offset += take; + pages++; + } + + return pages; +} + +static bool prepare_scroll_page(volatile ScrollInfo* scroll, + volatile StateInfo* state) { + if (scroll->offset >= scroll->size) return false; + + const size_t take = scroll->formatter( + scroll->data + scroll->offset, scroll->size - scroll->offset, scroll_body, + sizeof(scroll_body), scroll->body_width); + if (take == 0 || take > scroll->size - scroll->offset) return false; + + scroll->offset += take; + scroll->page++; + + const int title_len = + (scroll->pages > 1) + ? snprintf(scroll_title, sizeof(scroll_title), "%s %u/%u", + scroll->title, (unsigned)scroll->page, + (unsigned)scroll->pages) + : snprintf(scroll_title, sizeof(scroll_title), "%s", scroll->title); + const char* page_title = scroll->title; + if (title_len >= 0 && (size_t)title_len < sizeof(scroll_title)) { + page_title = scroll_title; + } + + for (size_t layout = LAYOUT_REQUEST; layout <= LAYOUT_CONFIRMED; layout++) { + state->lines[layout].request_title = page_title; + state->lines[layout].request_body = scroll_body; + } + + return true; +} + /// Run one confirmation screen: draw it, then wait for either the user's hold /// or the host's Cancel. Callers go through confirm_helper() below, which is /// what the public confirm()/review() wrappers use. @@ -177,11 +271,18 @@ static bool confirm_screen(const char* request_title_param, const char* request_body, layout_notification_t layout_notification_func, bool constant_power, IconType iconNum, - bool immediate) { + bool immediate, const uint8_t* page_data, + size_t page_size, + confirm_page_formatter_t page_formatter, + uint16_t page_body_width) { bool ret_stat = false; volatile StateInfo state_info; + volatile ScrollInfo scroll_info; + ScreenContext screen_context = {&state_info, &scroll_info}; ActiveLayout new_layout, cur_layout; DisplayState new_ds; + bool scroll_timer_pending = false; + bool scroll_advance; uint16_t tiny_msg; static CONFIDENTIAL uint8_t msg_tiny_buf[MSG_TINY_BFR_SZ]; const char* request_title; @@ -197,6 +298,7 @@ static bool confirm_screen(const char* request_title_param, reset_msg_stack = false; memset((void*)&state_info, 0, sizeof(state_info)); + memset((void*)&scroll_info, 0, sizeof(scroll_info)); state_info.immediate = immediate; state_info.display_state = HOME; state_info.active_layout = LAYOUT_REQUEST; @@ -215,9 +317,25 @@ static bool confirm_screen(const char* request_title_param, state_info.lines[LAYOUT_CONFIRMED].request_title = request_title; state_info.lines[LAYOUT_CONFIRMED].request_body = request_body; - keepkey_button_set_on_press_handler(&handle_screen_press, (void*)&state_info); + if (page_formatter != NULL) { + scroll_info.data = page_data; + scroll_info.size = page_size; + scroll_info.formatter = page_formatter; + scroll_info.body_width = page_body_width; + scroll_info.title = request_title; + scroll_info.pages = count_scroll_pages(page_data, page_size, page_formatter, + page_body_width); + if (scroll_info.pages == 0 || + !prepare_scroll_page(&scroll_info, &state_info)) { + goto confirm_screen_exit; + } + scroll_info.enabled = scroll_info.pages > 1; + } + + keepkey_button_set_on_press_handler(&handle_screen_press, + (void*)&screen_context); keepkey_button_set_on_release_handler(&handle_screen_release, - (void*)&state_info); + (void*)&screen_context); cur_layout = LAYOUT_INVALID; @@ -227,6 +345,7 @@ static bool confirm_screen(const char* request_title_param, #endif new_layout = state_info.active_layout; new_ds = state_info.display_state; + scroll_advance = scroll_info.advance; #ifndef EMULATOR svc_enable_interrupts(); #endif @@ -273,6 +392,39 @@ static bool confirm_screen(const char* request_title_param, } } + if (new_ds != SCROLLING) { + if (scroll_timer_pending) { + remove_runnable(&handle_scroll_timeout); + scroll_timer_pending = false; + } + scroll_info.advance = false; + } else { + bool page_advanced = false; + + if (scroll_advance) { + scroll_info.advance = false; + scroll_timer_pending = false; + if (!prepare_scroll_page(&scroll_info, &state_info)) { + ret_stat = false; + goto confirm_screen_exit; + } + + (*layout_notification_func)( + state_info.lines[LAYOUT_REQUEST_NO_ANIMATION].request_title, + state_info.lines[LAYOUT_REQUEST_NO_ANIMATION].request_body, + NOTIFICATION_REQUEST_NO_ANIMATION); + cur_layout = LAYOUT_REQUEST_NO_ANIMATION; + page_advanced = true; + } + + if (scroll_info.offset < scroll_info.size && !scroll_timer_pending) { + post_delayed(&handle_scroll_timeout, (void*)&screen_context, + page_advanced ? CONFIRM_SCROLL_PERIOD_MS + : CONFIRM_SCROLL_INITIAL_MS); + scroll_timer_pending = true; + } + } + if (new_ds == FINISHED) { ret_stat = true; break; /* confirmation done. Exiting function */ @@ -303,8 +455,11 @@ static bool confirm_screen(const char* request_title_param, confirm_screen_exit: + remove_runnable(&handle_scroll_timeout); keepkey_button_set_on_press_handler(NULL, NULL); keepkey_button_set_on_release_handler(NULL, NULL); + memzero(scroll_body, sizeof(scroll_body)); + memzero(scroll_title, sizeof(scroll_title)); return (ret_stat); } @@ -353,22 +508,37 @@ bool confirm_body_fits(const char* body, uint16_t body_width) { font_height(body_font) + BODY_FONT_LINE_PADDING); } -/// Show a confirmation, warning first when its body will not fit the screen. -/// -/// draw_string() draws until a glyph no longer fits the canvas and then simply -/// stops: a body taller than BODY_ROWS is drawn in part, with no ellipsis and -/// nothing to tell the user that the tail of an address, an amount or a -/// warning was dropped. The vsnprintf() into strbuf[BODY_CHAR_MAX] below cuts -/// long host strings a second time, just as quietly. -/// -/// So when the body will not fit, put an explicit screen in front of it. That -/// screen costs its own hold, and the hold is a real consent signal: a host -/// Cancel breaks it and the caller reports ActionCancelled, exactly as it -/// would for the body screen. A body that is only partly shown is now never -/// shown without saying so. -/// -/// Bodies that fit take exactly the path they took before: one screen, one -/// ButtonRequest, one hold. +size_t confirm_body_format_page(const uint8_t* data, size_t size, char* out, + size_t out_len, uint16_t body_width) { + if ((!data && size != 0) || !out || out_len < 2 || body_width == 0) return 0; + + const size_t limit = size < out_len - 1 ? size : out_len - 1; + size_t best = 0; + + /* Prefix fit is normally monotonic, but the standard layout vertically + * re-centres one- and two-line bodies. Scan the complete bounded buffer so a + * future font/layout adjustment cannot make an early non-fit hide a later, + * valid three-line prefix. */ + for (size_t take = 1; take <= limit; take++) { + memcpy(out, data, take); + out[take] = '\0'; + if (confirm_body_fits(out, body_width)) best = take; + } + + if (best == 0) { + out[0] = '\0'; + return 0; + } + + memcpy(out, data, best); + out[best] = '\0'; + return best; +} + +/// Show a confirmation, scrolling ordinary text when its complete formatted +/// body does not fit. Source truncation cannot be repaired after vsnprintf() +/// has discarded bytes, so that case fails closed without showing an +/// approve-able prefix. static bool confirm_helper(const char* request_title, const char* request_body, layout_notification_t layout_notification_func, bool constant_power, IconType iconNum, @@ -382,44 +552,40 @@ static bool confirm_helper(const char* request_title, const char* request_body, const bool truncated = body_truncated; body_truncated = false; - /* Two independent ways the user can be shown less than what is being - * approved, and they need separate measurements because they happen at - * different times: - * - * SOURCE the formatted body did not fit strbuf. Characters were lost - * before the renderer ever saw them, so no amount of looking - * at the screen can detect it -- only vsnprintf()'s return - * value could, and format_body() kept it. - * RENDER the body reached the renderer intact but did not fit the - * canvas. draw_string_fits() replays the real placement and - * reports whether the last character landed. - * - * Only layout_standard_notification is known to wrap the body at BODY_WIDTH - * over BODY_ROWS rows. Custom layouts place and size their own body, and - * layout_constant_power_notification draws from x = 128 + LEFT_MARGIN where - * the canvas edge, not BODY_WIDTH, is the limit. Measuring either of those - * against BODY_WIDTH would be wrong, so leave them exactly as they were -- - * but a SOURCE truncation is layout-independent and must warn regardless. */ + /* Custom layouts own their geometry. Only the standard notification can use + * this renderer-backed pager; source truncation is layout-independent. */ const bool render_incomplete = (layout_notification_func == &layout_standard_notification) && !confirm_body_fits(request_body, body_width); - if (truncated || render_incomplete) { - /* No second ButtonRequest is written: the host already sent one and its - * ButtonAck armed button_request_acked, which stays armed for the body - * screen below. The wire dialogue is unchanged; only the number of holds - * is not. */ - if (!confirm_screen("Cut Off", - "This text is too long for the screen. Only part " - "of it is shown. Hold to view it anyway.", - &layout_standard_notification, constant_power, NO_ICON, - immediate)) { - return false; - } + if (truncated) return false; + + if (render_incomplete) { + return confirm_screen(request_title, request_body, layout_notification_func, + constant_power, iconNum, immediate, + (const uint8_t*)request_body, strlen(request_body), + &confirm_body_format_page, body_width); } return confirm_screen(request_title, request_body, layout_notification_func, - constant_power, iconNum, immediate); + constant_power, iconNum, immediate, NULL, 0, NULL, 0); +} + +bool confirm_paged(ButtonRequestType type, const char* request_title, + const uint8_t* data, size_t size, + confirm_page_formatter_t formatter) { + if (!request_title || !data || size == 0 || !formatter) return false; + + button_request_acked = false; + + ButtonRequest resp; + memset(&resp, 0, sizeof(ButtonRequest)); + resp.has_code = true; + resp.code = type; + msg_write(MessageType_MessageType_ButtonRequest, &resp); + + return confirm_screen(request_title, "", &layout_standard_notification, false, + NO_ICON, false, data, size, formatter, BODY_WIDTH); } bool confirm(ButtonRequestType type, const char* request_title, diff --git a/lib/board/draw.c b/lib/board/draw.c index a303c7ef2..cb45fb39b 100644 --- a/lib/board/draw.c +++ b/lib/board/draw.c @@ -212,7 +212,11 @@ static bool draw_string_walk(Canvas* canvas, const Font* font, char_params.x = x_offset + p->x; have_space = draw_char_impl(canvas, &char_params, &x_offset, NULL, img, measure); - str_write++; + /* A rejected glyph was not drawn. Leave str_write on it so the caller's + * completeness result cannot report that a clipped final glyph fitted. */ + if (have_space) { + str_write++; + } } if (!measure) { diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index df97d0143..76d9acb8c 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -417,7 +417,7 @@ size_t confirm_bytes_format_page(const uint8_t* data, size_t size, char* out, if (used >= out_len - 1 || token_len > (out_len - 1) - used) break; memcpy(out + used, token, token_len + 1); - if (calc_str_line(get_body_font(), out, BODY_WIDTH) > BODY_ROWS) { + if (!confirm_body_fits(out, BODY_WIDTH)) { out[used] = '\0'; break; } @@ -429,50 +429,19 @@ size_t confirm_bytes_format_page(const uint8_t* data, size_t size, char* out, return consumed; } +static size_t confirm_bytes_scroll_page(const uint8_t* data, size_t size, + char* out, size_t out_len, + uint16_t body_width) { + (void)body_width; + return confirm_bytes_format_page(data, size, out, out_len); +} + bool confirm_bytes(ButtonRequestType button_request, const char* title, const uint8_t* data, size_t size) { if (!title || (!data && size != 0)) return false; if (size == 0) return confirm(button_request, title, "(empty)"); - - static char page_body[BODY_CHAR_MAX]; - static char page_title[TITLE_CHAR_MAX]; - bool approved = false; - - size_t pages = 0; - size_t offset = 0; - while (offset < size) { - const size_t take = confirm_bytes_format_page(data + offset, size - offset, - page_body, sizeof(page_body)); - if (take == 0) goto cleanup; - offset += take; - pages++; - } - - offset = 0; - for (size_t page = 0; page < pages; page++) { - const size_t take = confirm_bytes_format_page(data + offset, size - offset, - page_body, sizeof(page_body)); - if (take == 0) goto cleanup; - - int title_len; - if (pages == 1) { - title_len = snprintf(page_title, sizeof(page_title), "%s", title); - } else { - title_len = snprintf(page_title, sizeof(page_title), "%s %u/%u", title, - (unsigned)(page + 1), (unsigned)pages); - } - if (title_len < 0 || (size_t)title_len >= sizeof(page_title)) goto cleanup; - - if (!confirm(button_request, page_title, "%s", page_body)) goto cleanup; - offset += take; - } - - approved = true; - -cleanup: - memzero(page_body, sizeof(page_body)); - memzero(page_title, sizeof(page_title)); - return approved; + return confirm_paged(button_request, title, data, size, + &confirm_bytes_scroll_page); } bool confirm_omni(ButtonRequestType button_request, const char* title, diff --git a/unittests/board/board.cpp b/unittests/board/board.cpp index b10cfdb81..3ab7fec4c 100644 --- a/unittests/board/board.cpp +++ b/unittests/board/board.cpp @@ -65,6 +65,14 @@ TEST_F(BodyFits, ConfirmBodyFits) { const std::string eighty_w(80, 'W'); EXPECT_TRUE(confirm_body_fits(eighty_w.c_str(), BODY_WIDTH)); EXPECT_FALSE(confirm_body_fits(eighty_w.c_str(), BODY_WIDTH_WITH_ICON)); + + // A failed final glyph used to be consumed before draw_string_fits() + // checked whether input remained. The 118th digit is the first glyph past + // this three-row boundary; it must start a second page, not disappear. + std::string digits; + for (size_t i = 0; i < 118; i++) digits += "0123456789"[i % 10]; + EXPECT_TRUE(confirm_body_fits(digits.substr(0, 117).c_str(), BODY_WIDTH)); + EXPECT_FALSE(confirm_body_fits(digits.c_str(), BODY_WIDTH)); } // Regression: calc_str_line() accumulated into a uint8_t while returning @@ -172,7 +180,7 @@ static std::string FormatEveryPage(const std::string& input, size_t* pages) { return rendered; } -TEST(Board, ExactBytePagesEscapeRendererWhitespaceAndNul) { +TEST_F(BodyFits, ExactBytePagesEscapeRendererWhitespaceAndNul) { static const char raw[] = " benign\nlogin\\\0authorization"; const std::string payload(raw, sizeof(raw) - 1); size_t pages = 0; @@ -181,7 +189,7 @@ TEST(Board, ExactBytePagesEscapeRendererWhitespaceAndNul) { EXPECT_EQ(pages, 1u); } -TEST(Board, ExactBytePagesNeverApproveOnlyAPrefix) { +TEST_F(BodyFits, ExactBytePagesNeverApproveOnlyPrefix) { const std::string long_message = "Authorize transfer" + std::string(900, ' ') + "DENY"; size_t pages = 0; @@ -193,6 +201,47 @@ TEST(Board, ExactBytePagesNeverApproveOnlyAPrefix) { EXPECT_EQ(rendered.size(), 21u + 900u * 4u + 4u); } +static std::string FormatEveryBodyPage(const std::string& input, + uint16_t body_width, size_t* pages) { + std::string rendered; + size_t offset = 0; + *pages = 0; + while (offset < input.size()) { + char page[BODY_CHAR_MAX]; + const size_t take = confirm_body_format_page( + reinterpret_cast(input.data()) + offset, + input.size() - offset, page, sizeof(page), body_width); + EXPECT_GT(take, 0u); + if (take == 0) break; + EXPECT_TRUE(confirm_body_fits(page, body_width)); + rendered += page; + offset += take; + (*pages)++; + } + EXPECT_EQ(offset, input.size()); + return rendered; +} + +TEST_F(BodyFits, LongConfirmationPagesShowTheCompleteHex) { + std::string hex; + for (size_t i = 0; i < 384; i++) { + static const char digits[] = "0123456789abcdef"; + hex += digits[i % (sizeof(digits) - 1)]; + } + + size_t pages = 0; + EXPECT_EQ(FormatEveryBodyPage(hex, BODY_WIDTH, &pages), hex); + EXPECT_GT(pages, 1u); +} + +TEST_F(BodyFits, LongConfirmationPagesRespectIconWidthWithoutDroppingText) { + const std::string body = "abcdef0123456789" + std::string(320, 'a'); + size_t pages = 0; + + EXPECT_EQ(FormatEveryBodyPage(body, BODY_WIDTH_WITH_ICON, &pages), body); + EXPECT_GT(pages, 1u); +} + // base_to_precision() previously used strlcpy(dst, src, n) to copy n DIGITS. // strlcpy's third argument is the total destination size including the NUL, so // it copied n-1 and dropped the last digit: a signed "1" rendered 0.00000 and @@ -203,22 +252,25 @@ TEST(Board, BaseToPrecisionKeepsEveryDigit) { // Fewer digits than the precision: zero-padded fraction, no digit lost. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"1", sizeof(out), 1, 6)); - EXPECT_EQ(std::string((char *)out), "0.000001"); + ASSERT_EQ(0, base_to_precision(out, (const uint8_t*)"1", sizeof(out), 1, 6)); + EXPECT_EQ(std::string((char*)out), "0.000001"); // Exactly at the boundary. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"123456", sizeof(out), 6, 6)); - EXPECT_EQ(std::string((char *)out), "0.123456"); + ASSERT_EQ( + 0, base_to_precision(out, (const uint8_t*)"123456", sizeof(out), 6, 6)); + EXPECT_EQ(std::string((char*)out), "0.123456"); // One past the boundary: the last digit must survive. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"1234567", sizeof(out), 7, 6)); - EXPECT_EQ(std::string((char *)out), "1.234567"); + ASSERT_EQ( + 0, base_to_precision(out, (const uint8_t*)"1234567", sizeof(out), 7, 6)); + EXPECT_EQ(std::string((char*)out), "1.234567"); memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"100000000", sizeof(out), 9, 6)); - EXPECT_EQ(std::string((char *)out), "100.000000"); + ASSERT_EQ(0, base_to_precision(out, (const uint8_t*)"100000000", sizeof(out), + 9, 6)); + EXPECT_EQ(std::string((char*)out), "100.000000"); } // The NUL must land inside the supplied capacity, never at dest[dest_len]. @@ -227,15 +279,15 @@ TEST(Board, BaseToPrecisionRespectsCapacity) { // "1.234567" is 8 chars + NUL = 9; a capacity of 9 is exactly enough. memset(buf, 0xAA, sizeof(buf)); - ASSERT_EQ(0, base_to_precision(buf, (const uint8_t *)"1234567", 9, 7, 6)); - EXPECT_EQ(std::string((char *)buf), "1.234567"); + ASSERT_EQ(0, base_to_precision(buf, (const uint8_t*)"1234567", 9, 7, 6)); + EXPECT_EQ(std::string((char*)buf), "1.234567"); EXPECT_EQ(buf[9], 0xAA) << "wrote past the supplied capacity"; // One byte short must be refused, not truncated. memset(buf, 0xAA, sizeof(buf)); - EXPECT_EQ(-1, base_to_precision(buf, (const uint8_t *)"1234567", 8, 7, 6)); + EXPECT_EQ(-1, base_to_precision(buf, (const uint8_t*)"1234567", 8, 7, 6)); EXPECT_EQ(buf[0], 0xAA) << "buffer touched on the refusal path"; - EXPECT_EQ(-1, base_to_precision(NULL, (const uint8_t *)"1", 16, 1, 6)); + EXPECT_EQ(-1, base_to_precision(NULL, (const uint8_t*)"1", 16, 1, 6)); EXPECT_EQ(-1, base_to_precision(buf, NULL, 16, 1, 6)); }