fix(core): correct Rust UI chunkification logic for non-ASCII text
What changed, and why it matters
This commit fixes a bug in the Trezor hardware wallet's Rust UI code that splits text into chunks for display. The old code used byte positions instead of character positions when handling non-ASCII text (like accented letters or non-Latin scripts). This could cause the device to crash with a panic when trying to display certain international characters, because Rust would reject a slice that cut through the middle of a multi-byte character. The fix makes the chunking logic respect character boundaries. There is no direct evidence in the commit that this is exploitable as a security attack, but any device panic during rendering of user-facing text is at minimum a denial-of-service concern.
Treat as a reliability/DoS hardening fix. Include in routine firmware updates. If the device displays user-supplied or externally sourced text (e.g., transaction memos, account labels, token names), verify that the fixed code is exercised by such inputs and consider fuzzing the text layout with multi-byte UTF-8 strings. No immediate incident response is indicated absent evidence of active exploitation.
Security signals we found
Potential denial-of-service via device panic when rendering non-ASCII text
String slicing not aligned to UTF-8 character boundaries
Fix located in UI rendering path, not in crypto or authentication logic
No changelog entry provided by vendor
Evidence from the diff
In core/embed/rust/src/ui/component/text/layout.rs, the Span::chunkify logic previously computed final_index as text.len().min(chunk_size). For strings containing multi-byte UTF-8 characters, this byte index can land in the middle of a character. The subsequent text[..final_index] slice would then panic at runtime due to Rust’s str slice boundary checks. The patch replaces the byte-index calculation with char_indices().nth(chunk_size), yielding a valid character boundary. Tests are updated to exercise the chunkify path with non-ASCII input (Some(Chunks::new(4, 0))). The change is localized to text layout/rendering and does not alter cryptographic, storage, or communication code.
Changed components
core/embed/rust/src/ui/component/text/layout.rsTrezor Core Rust UI text layout/span renderingInspect captured patch +20 / −10
### core/embed/rust/src/ui/component/text/layout.rs
@@ -739,7 +739,13 @@ impl Span {
// All chunkification logic goes here.
if let Some(chunkify_config) = chunks {
- let final_index = text.len().min(usize::from(chunkify_config.chunk_size));
+ let final_index = match text
+ .char_indices()
+ .nth(usize::from(chunkify_config.chunk_size))
+ {
+ Some((index, _char)) => index,
+ None => text.len(),
+ };
let chunk_width = text_font.text_width(&text[..final_index]);
if chunk_width <= max_width {
return Self {
@@ -845,14 +851,14 @@ mod tests {
#[test]
fn test_span() {
- assert_eq!(spans_from("hello", 5), vec![("hello", false)]);
- assert_eq!(spans_from("", 5), vec![("", false)]);
+ assert_eq!(spans_from("hello", 5, None), vec![("hello", false)]);
+ assert_eq!(spans_from("", 5, None), vec![("", false)]);
assert_eq!(
- spans_from("hello world", 5),
+ spans_from("hello world", 5, None),
vec![("hello", false), ("world", false)]
);
assert_eq!(
- spans_from("hello\nworld", 5),
+ spans_from("hello\nworld", 5, None),
vec![("hello", false), ("world", false)]
);
}
@@ -861,15 +867,15 @@ mod tests {
#[ignore]
fn test_leading_trailing() {
assert_eq!(
- spans_from("\nhello\nworld\n", 5),
+ spans_from("\nhello\nworld\n", 5, None),
vec![("", false), ("hello", false), ("world", false), ("", false)]
);
}
#[test]
fn test_long_word() {
assert_eq!(
- spans_from("Down with the establishment!", 5),
+ spans_from("Down with the establishment!", 5, None),
vec![
("Down", false),
("with", false),
@@ -885,12 +891,16 @@ mod tests {
#[test]
fn test_char_boundary() {
assert_eq!(
- spans_from("+ěščřžýáíé", 5),
+ spans_from("+ěščřžýáíé", 5, None),
vec![("+ěšč", true), ("řžýá", true), ("íé", false)]
);
+ assert_eq!(
+ spans_from("+ěščřžýáíé", 100, Some(Chunks::new(4, 0))),
+ vec![("+ěšč", false), ("řžýá", false), ("íé", false)]
+ );
}
- fn spans_from(text: &str, max_width: i16) -> Vec<(&str, bool)> {
+ fn spans_from(text: &str, max_width: i16, chunks: Option<Chunks>) -> Vec<(&str, bool)> {
let mut spans = vec![];
let mut remaining_text = text;
loop {
@@ -900,7 +910,7 @@ mod tests {
FIXED_FONT,
LineBreaking::BreakAtWhitespace,
0,
- None,
+ chunks,
);
spans.push((
&remaining_text[..span.length],Why this scored 44/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.