What changed, and why it matters
This commit hardens the BitBox02 firmware so it stops trusting that incoming text strings are valid UTF-8 or plain ASCII. It replaces risky C string copies with length-checked, UTF-8-aware helpers, rejects non-ASCII characters at UI boundaries, and prevents malformed multi-byte characters from being cut in half when labels or device names are truncated. The changes reduce the chance that an attacker could crash the device or hide parts of a message by sending carefully crafted strings.
Treat this as a security-hardening patch and include it in the next firmware release. Review remaining `from_utf8_unchecked` call sites to confirm their safety invariants are actually upheld at runtime, and continue fuzzing the new `util_utf8_copy`/`util_utf8_strlcpy` helpers with null pointers, zero lengths, and invalid UTF-8 sequences.
Security signals we found
Replaced snprintf-based string copies with length-bounded UTF-8-aware copies
Added explicit length parameter to memory_set_device_name and reject embedded/invalid nulls
Added printable-ASCII enforcement at Rust UI boundary before C rendering
Added UTF-8-safe truncation that does not split multi-byte code points
Replaced panic-prone CStr::to_str().unwrap() with error-returning paths in U2F workflow
Added safety comments justifying remaining unsafe from_utf8_unchecked usage
Added unit tests covering invalid UTF-8, embedded nulls, and truncation behavior
Evidence from the diff
The patch is a defensive hardening merge focused on safe UTF-8/ASCII handling across the C and Rust codebase. Key changes: (1) new util_utf8_copy, util_utf8_strlcpy, and util_is_printable_ascii helpers in src/util.c; (2) replacement of snprintf/memcpy string copies with these helpers in memory, UI labels, buttons, lockscreen, logger, and UGUI text slicing; (3) Rust-side truncate_str now truncates only at UTF-8 character boundaries; (4) UI Rust code asserts printable ASCII before passing text to the C renderer; (5) memory_set_device_name now takes an explicit length and validates embedded nulls and UTF-8 validity; (6) pairing-code formatting avoids snprintf entirely; (7) several from_utf8_unchecked calls are kept but documented with safety comments. The commit also adds unit tests for the new helpers and for device-name validation.
Changed components
src/util.c / src/util.hsrc/memory/memory.c / src/memory/memory.hsrc/ui/components/label.csrc/ui/components/button.csrc/ui/components/lockscreen.csrc/ui/ugui/ugui.csrc/screen.csrc/optiga/pal/pal_logger.csrc/da14531/da14531_handler.csrc/rust/bitbox02/src/ui/ui.rssrc/rust/util/src/strings.rssrc/rust/bitbox02/src/memory.rssrc/rust/bitbox02-rust-c/src/u2f_c_api.rssrc/rust/erc20_params/build.rs / src/lib.rsInspect captured patch +338 / −96
### src/da14531/da14531_handler.c
@@ -181,7 +181,10 @@ static void _ctrl_handler(const struct da14531_ctrl_frame* frame, struct RustByt
memcpy(&pairing_code_int, &frame->cmd_data[0], sizeof(pairing_code_int));
pairing_code_int %= 1000000;
char pairing_code[7] = {0};
- snprintf(pairing_code, sizeof(pairing_code), "%06lu", (long unsigned int)pairing_code_int);
+ for (size_t i = sizeof(pairing_code) - 1; i > 0; i--) {
+ pairing_code[i - 1] = '0' + pairing_code_int % 10;
+ pairing_code_int /= 10;
+ }
// util_log("da14531: show/confirm pairing code: %s", pairing_code);
const confirm_params_t confirm_params = {
.title = "Pairing code",
### src/memory/memory.c
@@ -310,18 +310,22 @@ static const memory_interface_functions_t* _interface_functions = NULL;
/********* Exposed functions ****************/
-bool memory_set_device_name(const char* name)
+bool memory_set_device_name(const char* name, size_t name_len)
{
- if (name[0] == (char)0xFF || name[0] == 0x0) {
+ if (name == NULL || name_len == 0 || name[0] == (char)0xFF || name[0] == 0x0 ||
+ memchr(name, '\0', name_len) != NULL || name[name_len] != '\0') {
// utf8 string can't start with 0xFF or be an empty string.
return false;
}
-
chunk_1_t chunk = {0};
CLEANUP_CHUNK(chunk);
_read_chunk(CHUNK_1, chunk_bytes);
util_zero(chunk.fields.device_name, sizeof(chunk.fields.device_name));
- snprintf((char*)&chunk.fields.device_name, MEMORY_DEVICE_MAX_LEN_WITH_NULL, "%s", name);
+ if (util_utf8_copy(
+ (char*)&chunk.fields.device_name, MEMORY_DEVICE_MAX_LEN_WITH_NULL, name, name_len) <
+ 0) {
+ return false;
+ }
if (!rust_util_is_name_valid(chunk.fields.device_name, MEMORY_DEVICE_MAX_LEN_WITH_NULL)) {
return false;
@@ -993,7 +997,9 @@ memory_result_t memory_multisig_set_by_hash(const uint8_t* hash, const char* nam
multisig_configuration_t* multisig = &chunk.fields.multisig_configs[write_index];
memcpy(multisig->hash, hash, sizeof(multisig->hash));
memset(multisig->name, '\0', sizeof(multisig->name));
- snprintf(multisig->name, sizeof(multisig->name), "%s", name);
+ if (util_utf8_strlcpy(multisig->name, name, sizeof(multisig->name)) < 0) {
+ return MEMORY_ERR_INVALID_INPUT;
+ }
if (!_write_chunk(CHUNK_2, chunk.bytes)) {
return MEMORY_ERR_UNKNOWN;
}
### src/memory/memory.h
@@ -68,11 +68,11 @@ USE_RESULT bool memory_cleanup_smarteeprom(void);
// Default device name if no name was set by the user.
extern const char* MEMORY_DEFAULT_DEVICE_NAME;
-// set device name. name is null terminated. The name must be smaller or equal to
-// MEMORY_DEVICE_MAX_LEN_WITH_NULL (including the null terminator) and larger than 0 in size,
-// consist of printable ASCII characters only (and space), not start or end with whitespace, and
-// contain no whitespace other than space.
-USE_RESULT bool memory_set_device_name(const char* name);
+// Set device name. `name` is null terminated and `name_len` is its length excluding the null
+// terminator. The name must be larger than 0 in size, consist of printable ASCII characters only
+// (and space), not start or end with whitespace, and contain no whitespace other than space. Names
+// longer than MEMORY_DEVICE_MAX_LEN_WITH_NULL - 1 are truncated.
+USE_RESULT bool memory_set_device_name(const char* name, size_t name_len);
// name_out must have MEMORY_DEVICE_MAX_LEN_WITH_NULL bytes in size. If no device name is set, or if
// it is invalid, we return:
### src/optiga/pal/pal_logger.c
@@ -74,8 +74,9 @@ pal_status_t pal_logger_write(
{
(void)p_logger_context;
char s[10000];
- snprintf(s, sizeof(s), "%s", p_log_data);
- s[log_data_length] = 0;
+ const size_t copy_len = MIN((size_t)log_data_length, sizeof(s) - 1);
+ memcpy(s, p_log_data, copy_len);
+ s[copy_len] = 0;
util_log("%s", s);
return PAL_STATUS_SUCCESS;
}
### src/rust/bitbox02-rust-c/src/u2f_c_api.rs
@@ -137,8 +137,22 @@ pub unsafe extern "C" fn rust_workflow_spawn_confirm(
title: *const core::ffi::c_char,
body: *const core::ffi::c_char,
) -> bool {
- let title: String = unsafe { CStr::from_ptr(title).to_str().unwrap().into() };
- let body: String = unsafe { CStr::from_ptr(body).to_str().unwrap().into() };
+ if title.is_null() || body.is_null() {
+ return false;
+ }
+ let (Ok(title), Ok(body)) = (
+ unsafe { CStr::from_ptr(title) }.to_str(),
+ unsafe { CStr::from_ptr(body) }.to_str(),
+ ) else {
+ return false;
+ };
+ if !util::ascii::is_printable_ascii(title, util::ascii::Charset::AllNewline)
+ || !util::ascii::is_printable_ascii(body, util::ascii::Charset::AllNewline)
+ {
+ return false;
+ }
+ let title: String = title.into();
+ let body: String = body.into();
let Some(active_workflow_guard) = (unsafe { try_start_workflow() }) else {
return false;
};
### src/rust/bitbox02-rust/src/backup.rs
@@ -134,7 +134,6 @@ fn load_from_buffer(buf: &[u8]) -> Result<(Zeroizing<BackupData>, pb_backup::Bac
return Err(());
}
let metadata = content.metadata.ok_or(())?;
-
let checksum = compute_checksum(&metadata, &backup_data.0, content.length)?;
if checksum != content.checksum {
Err(())
### src/rust/bitbox02-rust/src/hww/api/ethereum/address.rs
@@ -24,10 +24,8 @@ pub fn from_pubkey_hash(recipient: &[u8; 20], address_case: pb::EthAddressCase)
*e -= 32; // convert to uppercase
}
}
- format!("0x{}", unsafe {
- // valid utf8 because hex and the uppercasing above is correct.
- core::str::from_utf8_unchecked(&hex[..])
- })
+ // SAFETY: hex encoding and the uppercasing above produce only ASCII.
+ format!("0x{}", unsafe { core::str::from_utf8_unchecked(&hex) })
}
pb::EthAddressCase::Upper => {
format!("0x{}", hex::encode_upper(recipient))
### src/rust/bitbox02-rust/src/workflow/pairing.rs
@@ -9,7 +9,7 @@ use alloc::string::String;
pub fn format_hash(hash: &[u8; 32]) -> String {
let mut encoded = [0u8; 60];
let encoded = binascii::b32encode(&hash[..], &mut encoded).unwrap();
- // Base32 contains only utf-8 valid chars.
+ // SAFETY: Base32 output contains only ASCII characters.
let encoded = unsafe { core::str::from_utf8_unchecked(encoded) };
format!(
"{} {}\n{} {}",
### src/rust/bitbox02-rust/src/workflow/verify_message.rs
@@ -36,7 +36,8 @@ pub async fn verify(
) -> Result<(), Error> {
if ascii::is_printable_ascii(msg, ascii::Charset::AllNewline) {
// The message is all ascii and printable.
- let msg = core::str::from_utf8(msg).unwrap();
+ // SAFETY: `is_printable_ascii()` accepted every byte above.
+ let msg = unsafe { core::str::from_utf8_unchecked(msg) };
if msg.is_empty() {
return Err(Error::InvalidInput);
}
@@ -295,4 +296,22 @@ mod tests {
_ => panic!("unexpected screen"),
}
}
+
+ #[async_test::test]
+ async fn test_verify_non_ascii_utf8_as_hex() {
+ let mut hal = TestingHal::new();
+ assert!(
+ verify(&mut hal, "Sign message", "Sign", "tä".as_bytes(), true)
+ .await
+ .is_ok()
+ );
+ assert_eq!(
+ hal.ui.screens,
+ vec![Screen::Confirm {
+ title: "Sign message\ndata (hex)".into(),
+ body: "74c3a4".into(),
+ longtouch: true,
+ }]
+ );
+ }
}
### src/rust/bitbox02/src/memory.rs
@@ -20,14 +20,8 @@ pub fn get_device_name() -> String {
}
pub fn set_device_name(name: &str) -> Result<(), MemoryError> {
- match unsafe {
- bitbox02_sys::memory_set_device_name(
- util::strings::str_to_cstr_vec(name)
- .or(Err(MemoryError::MEMORY_ERR_UNKNOWN))?
- .as_ptr()
- .cast(),
- )
- } {
+ let c_name = util::strings::str_to_cstr_vec(name).or(Err(MemoryError::MEMORY_ERR_UNKNOWN))?;
+ match unsafe { bitbox02_sys::memory_set_device_name(c_name.as_ptr().cast(), name.len()) } {
true => Ok(()),
false => Err(MemoryError::MEMORY_ERR_UNKNOWN),
}
### src/rust/bitbox02/src/platform.rs
@@ -5,6 +5,7 @@ pub fn product() -> &'static str {
let mut len = 0;
let s = bitbox02_sys::platform_product(&mut len as *mut _) as *const u8;
let s = core::slice::from_raw_parts(s, len);
- str::from_utf8_unchecked(s)
+ // SAFETY: `platform_product()` returns a compile-time ASCII JSON string literal.
+ core::str::from_utf8_unchecked(s)
}
}
### src/rust/bitbox02/src/ui/ui.rs
@@ -15,8 +15,24 @@ use alloc::vec::Vec;
use core::cell::RefCell;
use core::task::{Poll, Waker};
+// Keep enough bytes beyond the C label limit to prove that truncation is needed even when the
+// Rust-side cut moves back to a UTF-8 boundary.
+const LABEL_TRUNCATE_SIZE: usize = super::types::MAX_LABEL_SIZE + 4;
+
+/// BitBox02 fonts contain glyphs for printable ASCII only. Keep this check at the common UI
+/// boundary so unsupported text cannot be silently omitted by the renderer.
+fn display_str_to_cstr_vec(text: &str) -> Vec<c_char> {
+ assert!(
+ util::ascii::is_printable_ascii(text, util::ascii::Charset::AllNewline),
+ "BitBox02 UI text contains unsupported characters"
+ );
+ let mut result: Vec<c_char> = text.bytes().map(|byte| byte as c_char).collect();
+ result.push(0);
+ result
+}
+
fn label_fits_width(text: &str, font: *const bitbox02_sys::UG_FONT) -> bool {
- let text = util::strings::str_to_cstr_vec(text).unwrap();
+ let text = display_str_to_cstr_vec(text);
unsafe { bitbox02_sys::label_fits_width(text.as_ptr(), font, bitbox02_sys::SCREEN_WIDTH as _) }
}
@@ -150,12 +166,10 @@ pub async fn trinary_input_string(
(None, core::ptr::null_mut())
};
- // We truncate at a bit higher than MAX_LABEL_SIZE, so the label component will correctly
- // truncate and append '...'.
- const TRUNCATE_SIZE: usize = super::types::MAX_LABEL_SIZE + 1;
- let title =
- util::strings::str_to_cstr_vec(util::strings::truncate_str(params.title, TRUNCATE_SIZE))
- .unwrap();
+ let title = display_str_to_cstr_vec(util::strings::truncate_str(
+ params.title,
+ LABEL_TRUNCATE_SIZE,
+ ));
let c_params = bitbox02_sys::trinary_input_string_params_t {
title: title.as_ptr().cast(),
wordlist: match params.wordlist {
@@ -186,7 +200,7 @@ pub async fn trinary_input_string(
unsafe {
bitbox02_sys::trinary_input_string_set_input(
component,
- util::strings::str_to_cstr_vec(preset).unwrap().as_ptr(),
+ display_str_to_cstr_vec(preset).as_ptr(),
)
}
}
@@ -245,15 +259,14 @@ pub async fn confirm(params: &ConfirmParams<'_>) -> ConfirmResponse {
}
}
- // We truncate at a bit higher than MAX_LABEL_SIZE, so the label component will correctly
- // truncate and append '...'.
- const TRUNCATE_SIZE: usize = super::types::MAX_LABEL_SIZE + 1;
- let title =
- util::strings::str_to_cstr_vec(util::strings::truncate_str(params.title, TRUNCATE_SIZE))
- .unwrap();
- let body =
- util::strings::str_to_cstr_vec(util::strings::truncate_str(params.body, TRUNCATE_SIZE))
- .unwrap();
+ let title = display_str_to_cstr_vec(util::strings::truncate_str(
+ params.title,
+ LABEL_TRUNCATE_SIZE,
+ ));
+ let body = display_str_to_cstr_vec(util::strings::truncate_str(
+ params.body,
+ LABEL_TRUNCATE_SIZE,
+ ));
let c_params = bitbox02_sys::confirm_params_t {
title: title.as_ptr().cast(),
title_autowrap: params.title_autowrap,
@@ -305,7 +318,7 @@ pub fn screen_process() {
pub fn status_create(text: &str, status_success: bool) -> Component {
let component = unsafe {
bitbox02_sys::status_create(
- util::strings::str_to_cstr_vec(text).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(text).as_ptr(), // copied in C
status_success,
)
};
@@ -430,7 +443,7 @@ pub async fn menu(params: MenuParams<'_>) -> MenuResponse {
let words: Vec<Vec<core::ffi::c_char>> = params
.words
.iter()
- .map(|word| util::strings::str_to_cstr_vec(word).unwrap())
+ .map(|word| display_str_to_cstr_vec(word))
.collect();
// Step two: collect pointers. This var also has to be valid until menu() finishes, or
// the pointer will be invalid.
@@ -452,9 +465,7 @@ pub async fn menu(params: MenuParams<'_>) -> MenuResponse {
shared_state_ptr, // passed to continue_on_last_cb as `user_data`.
),
};
- let title = params
- .title
- .map(|title| util::strings::str_to_cstr_vec(title).unwrap());
+ let title = params.title.map(display_str_to_cstr_vec);
let component = unsafe {
bitbox02_sys::menu_create(
c_words.as_ptr(),
@@ -548,13 +559,13 @@ pub async fn trinary_choice(
}
}
- let label_left = label_left.map(|label| util::strings::str_to_cstr_vec(label).unwrap());
- let label_middle = label_middle.map(|label| util::strings::str_to_cstr_vec(label).unwrap());
- let label_right = label_right.map(|label| util::strings::str_to_cstr_vec(label).unwrap());
+ let label_left = label_left.map(display_str_to_cstr_vec);
+ let label_middle = label_middle.map(display_str_to_cstr_vec);
+ let label_right = label_right.map(display_str_to_cstr_vec);
let component = unsafe {
bitbox02_sys::trinary_choice_create(
- util::strings::str_to_cstr_vec(message).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(message).as_ptr(), // copied in C
// copied in C
label_left
.as_ref()
@@ -627,8 +638,8 @@ pub async fn confirm_transaction_address(amount: &str, address: &str) -> Confirm
let component = unsafe {
bitbox02_sys::confirm_transaction_address_create(
- util::strings::str_to_cstr_vec(amount).unwrap().as_ptr(), // copied in C
- util::strings::str_to_cstr_vec(address).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(amount).as_ptr(), // copied in C
+ display_str_to_cstr_vec(address).as_ptr(), // copied in C
Some(callback),
shared_state_ptr, // passed to callback as `user_data`.
)
@@ -688,9 +699,9 @@ pub async fn confirm_swap(title: &str, from: &str, to: &str) -> ConfirmResponse
let component = unsafe {
bitbox02_sys::confirm_swap_create(
- util::strings::str_to_cstr_vec(title).unwrap().as_ptr(), // copied in C
- util::strings::str_to_cstr_vec(from).unwrap().as_ptr(), // copied in C
- util::strings::str_to_cstr_vec(to).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(title).as_ptr(), // copied in C
+ display_str_to_cstr_vec(from).as_ptr(), // copied in C
+ display_str_to_cstr_vec(to).as_ptr(), // copied in C
Some(callback),
shared_state_ptr, // passed to callback as `user_data`.
)
@@ -750,8 +761,8 @@ pub async fn confirm_transaction_fee(amount: &str, fee: &str, longtouch: bool) -
let component = unsafe {
bitbox02_sys::confirm_transaction_fee_create(
- util::strings::str_to_cstr_vec(amount).unwrap().as_ptr(), // copied in C
- util::strings::str_to_cstr_vec(fee).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(amount).as_ptr(), // copied in C
+ display_str_to_cstr_vec(fee).as_ptr(), // copied in C
longtouch,
Some(callback),
shared_state_ptr, // passed to callback as `user_data`.
@@ -790,7 +801,7 @@ pub fn screen_stack_pop_all() {
pub fn progress_create(title: &str) -> Component {
let component = unsafe {
bitbox02_sys::progress_create(
- util::strings::str_to_cstr_vec(title).unwrap().as_ptr(), // copied in C
+ display_str_to_cstr_vec(title).as_ptr(), // copied in C
)
};
### src/rust/erc20_params/build.rs
@@ -28,6 +28,9 @@ fn main() {
panic!("token line has more than three fields");
}
let (unit, contract_address) = (parts[0], parts[1]);
+ if !unit.bytes().all(|byte| (32..=126).contains(&byte)) {
+ panic!("token unit must be printable ASCII");
+ }
let decimals: u8 = parts[2].parse().unwrap();
tokens.push(Token {
### src/rust/erc20_params/src/lib.rs
@@ -20,13 +20,10 @@ pub struct Params {
impl Params {
fn from_p(p: &P, decimals: u8, unit_len: u8) -> Self {
+ let unit = unsafe { core::slice::from_raw_parts(p.unit, unit_len as usize) };
Params {
- unit: unsafe {
- core::str::from_utf8_unchecked(core::slice::from_raw_parts(
- p.unit,
- unit_len as usize,
- ))
- },
+ // SAFETY: `p.unit` points to a Rust byte-string generated from a validated ASCII unit.
+ unit: unsafe { core::str::from_utf8_unchecked(unit) },
contract_address: p.contract_address,
decimals,
}
### src/rust/util/src/log.rs
@@ -93,7 +93,7 @@ pub unsafe extern "C" fn rust_log(ptr: *const core::ffi::c_char) {
panic!("`ptr` must be a valid pointer");
}
let s = unsafe { core::ffi::CStr::from_ptr(ptr as _) };
- let s = unsafe { core::str::from_utf8_unchecked(s.to_bytes()) };
+ let s = alloc::string::String::from_utf8_lossy(s.to_bytes());
rtt_target::rprintln!("{}", s);
}
}
### src/rust/util/src/strings.rs
@@ -5,6 +5,8 @@ use alloc::string::String;
use alloc::vec::Vec;
use core::ffi::{CStr, c_char};
+use crate::bytes::Bytes;
+
/// Parses a utf-8 string out of a null terminated buffer. Returns `Err(())` if there
/// is no null terminator or if the bytes before the null terminator is invalid UTF8.
pub fn str_from_null_terminated(input: &[u8]) -> Result<&str, ()> {
@@ -25,10 +27,23 @@ pub unsafe fn str_from_null_terminated_ptr<'a>(
unsafe { core::ffi::CStr::from_ptr(ptr.cast()).to_str().or(Err(())) }
}
-/// truncate_str truncates string `s` to `len` chars. If `s` is
-/// shorter than `len`, the string is returned unchanged (no panics).
-pub fn truncate_str(s: &str, len: usize) -> &str {
- if s.len() > len { &s[..len] } else { s }
+/// Truncates `s` to at most `max_bytes`, without splitting a UTF-8 code point.
+pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
+ let mut len = core::cmp::min(s.len(), max_bytes);
+ while !s.is_char_boundary(len) {
+ len -= 1;
+ }
+ &s[..len]
+}
+
+/// Validates `input` as UTF-8 and returns the largest prefix that fits in `max_bytes` without
+/// splitting a code point, or -1 if `input` is not valid UTF-8.
+#[unsafe(no_mangle)]
+pub extern "C" fn rust_util_utf8_truncate(input: Bytes, max_bytes: usize) -> isize {
+ match core::str::from_utf8(input.as_ref()) {
+ Ok(input) => truncate_str(input, max_bytes).len() as isize,
+ Err(_) => -1,
+ }
}
/// Converts a Rust string to a null terminated C string by appending a null
@@ -130,6 +145,29 @@ mod tests {
assert_eq!(truncate_str("test", 4), "test");
assert_eq!(truncate_str("test", 5), "test");
assert_eq!(truncate_str("test", 6), "test");
+ assert_eq!(truncate_str("täst", 1), "t");
+ assert_eq!(truncate_str("täst", 2), "t");
+ assert_eq!(truncate_str("täst", 3), "tä");
+ assert_eq!(truncate_str("täst", 4), "täs");
+ assert_eq!(truncate_str("👌", 3), "");
+ assert_eq!(truncate_str("👌", 4), "👌");
+ }
+
+ #[test]
+ fn test_rust_util_utf8_truncate() {
+ let input = "täst".as_bytes();
+ let result = rust_util_utf8_truncate(
+ unsafe { crate::bytes::rust_util_bytes(input.as_ptr(), input.len()) },
+ 2,
+ );
+ assert_eq!(result, 1);
+
+ let input = b"t\xffst";
+ let result = rust_util_utf8_truncate(
+ unsafe { crate::bytes::rust_util_bytes(input.as_ptr(), input.len()) },
+ input.len(),
+ );
+ assert_eq!(result, -1);
}
#[test]
### src/screen.c
@@ -28,11 +28,32 @@ UG_COLOR screen_back_color = C_BLACK;
slider_location_t top_slider = 1;
slider_location_t bottom_slider = 0;
+static void _escape_debug_string(char* out, const size_t out_len, const char* message)
+{
+ static const char hex[] = "0123456789ABCDEF";
+ size_t out_pos = 0;
+ while (*message != '\0' && out_pos + 1 < out_len) {
+ const unsigned char chr = (unsigned char)*message++;
+ if ((chr >= 32 && chr <= 126) || chr == '\n') {
+ out[out_pos++] = (char)chr;
+ } else {
+ if (out_pos + 4 >= out_len) {
+ break;
+ }
+ out[out_pos++] = '\\';
+ out[out_pos++] = 'x';
+ out[out_pos++] = hex[chr >> 4];
+ out[out_pos++] = hex[chr & 0x0F];
+ }
+ }
+ out[out_pos] = '\0';
+}
+
// message truncated to 99 chars. somewhere between 99 and 120 we start to get hardfaults...
void screen_print_debug(const char* message, int duration)
{
- char print[100];
- util_strlcpy(print, message, sizeof(print));
+ char print[100] = {0};
+ _escape_debug_string(print, sizeof(print), message);
screen_clear();
UG_FontSelect(&font_font_a_9X9);
UG_PutString(0, 0, print);
### src/ui/components/button.c
@@ -11,6 +11,7 @@
#include <stdbool.h>
#include <string.h>
+#include <util.h>
static const uint8_t MIN_BUTTON_WIDTH = 32; // 0:SCREEN_WIDTH
@@ -162,10 +163,15 @@ void button_update(component_t* button, const char* text, void (*callback)(compo
{
button_data_t* data = (button_data_t*)button->data;
data->callback = callback;
- snprintf(data->text, sizeof(data->text), "%s", text);
+ if (!util_is_printable_ascii(text, true)) {
+ Abort("Unsupported button character");
+ }
+ if (util_utf8_strlcpy(data->text, text, sizeof(data->text)) < 0) {
+ Abort("Invalid UTF-8 button");
+ }
UG_FontSelect(&font_font_a_11X10);
UG_FontSetHSpace(0);
- UG_MeasureString(&(button->dimension.width), &(button->dimension.height), text);
+ UG_MeasureString(&(button->dimension.width), &(button->dimension.height), data->text);
if (button->dimension.width < MIN_BUTTON_WIDTH) {
button->dimension.width = MIN_BUTTON_WIDTH;
}
### src/ui/components/label.c
@@ -42,10 +42,17 @@ bool label_fits_width(const char* text, const UG_FONT* font, uint16_t max_width)
void label_update(component_t* component, const char* text)
{
data_t* data = (data_t*)component->data;
- int snprintf_result = snprintf(data->text, MAX_LABEL_SIZE + 1, "%s", text);
- if (snprintf_result >= MAX_LABEL_SIZE + 1) {
+ if (!util_is_printable_ascii(text, true)) {
+ Abort("Unsupported label character");
+ }
+ const intptr_t result = util_utf8_strlcpy(data->text, text, MAX_LABEL_SIZE + 1);
+ if (result < 0) {
+ Abort("Invalid UTF-8 label");
+ }
+ const size_t copied_len = (size_t)result;
+ if (copied_len < strlen(text)) {
// text has been truncated, add '...'
- snprintf(&data->text[MAX_LABEL_SIZE], 4, "...");
+ memcpy(&data->text[copied_len], "...", 4);
}
_measure_label_dimensions(component);
if (component->parent == NULL) {
### src/ui/components/lockscreen.c
@@ -11,6 +11,7 @@
#include <string.h>
#include <touch/gestures.h>
#include <ui/fonts/arial_fonts.h>
+#include <util.h>
/********************************** Component Functions **********************************/
@@ -37,29 +38,46 @@ static void _truncate_to_fit(
if (out == NULL || out_len == 0) {
return;
}
+ if (out_len < 4) {
+ out[0] = 0;
+ return;
+ }
if (in[0] == 0) {
out[0] = 0;
return;
}
+ if (!util_is_printable_ascii(in, false)) {
+ out[0] = 0;
+ return;
+ }
UG_S16 width = 0;
UG_S16 height = 0;
UG_FontSelect(font);
UG_MeasureStringCentered(&width, &height, in);
// Name fits without truncation.
if (width <= max_width) {
- snprintf(out, MEMORY_DEVICE_MAX_LEN_WITH_NULL, "%s", in);
+ util_utf8_strlcpy(out, in, out_len);
return;
}
// Truncate if too long to a size where "<name>..." fits.
- size_t truncate_len = strlen(in) - 1;
+ const size_t text_capacity = out_len - 4;
+ size_t truncate_len = MIN(strlen(in), text_capacity);
do {
- // truncate at `truncate_len`.
- snprintf(out, out_len, "%.*s...", (int)truncate_len, in);
- truncate_len--;
+ const intptr_t result = util_utf8_copy(out, out_len, in, truncate_len);
+ if (result < 0) {
+ out[0] = 0;
+ return;
+ }
+ const size_t copied_len = (size_t)result;
+ memcpy(&out[copied_len], "...", 4);
UG_MeasureStringCentered(&width, &height, out);
- } while (truncate_len > 0 && width >= max_width);
+ if (truncate_len == 0) {
+ break;
+ }
+ truncate_len--;
+ } while (width >= max_width);
}
component_t* lockscreen_create(void)
### src/ui/ugui/ugui.c
@@ -71,12 +71,9 @@ static ug_rotation_t rotation = {0};
static void _copy_slice(char* out, size_t out_len, const char* start, size_t len)
{
- if (out_len == 0) {
- return;
+ if (util_utf8_copy(out, out_len, start, len) < 0 && out_len > 0) {
+ out[0] = '\0';
}
- const size_t copy_len = MIN(len, out_len - 1);
- memcpy(out, start, copy_len);
- out[copy_len] = '\0';
}
static void _UG_PSet(UG_S16 x, UG_S16 y, UG_COLOR c)
### src/util.c
@@ -39,6 +39,50 @@ void util_strlcpy(char* dst, const char* src, size_t dst_len)
dst[copy_len] = '\0';
}
+intptr_t util_utf8_copy(char* dst, const size_t dst_len, const char* src, const size_t src_len)
+{
+ if (dst == NULL || src == NULL || dst_len == 0) {
+ return -1;
+ }
+
+ const intptr_t result =
+ rust_util_utf8_truncate(rust_util_bytes((const uint8_t*)src, src_len), dst_len - 1);
+ if (result < 0) {
+ dst[0] = '\0';
+ return -1;
+ }
+
+ const size_t result_len = (size_t)result;
+ memcpy(dst, src, result_len);
+ dst[result_len] = '\0';
+ return result;
+}
+
+intptr_t util_utf8_strlcpy(char* dst, const char* src, const size_t dst_len)
+{
+ if (src == NULL) {
+ if (dst != NULL && dst_len > 0) {
+ dst[0] = '\0';
+ }
+ return -1;
+ }
+ return util_utf8_copy(dst, dst_len, src, strlen(src));
+}
+
+bool util_is_printable_ascii(const char* str, const bool allow_newline)
+{
+ if (str == NULL) {
+ return false;
+ }
+ for (; *str != '\0'; str++) {
+ const unsigned char chr = (unsigned char)*str;
+ if ((chr < 32 || chr > 126) && !(allow_newline && chr == '\n')) {
+ return false;
+ }
+ }
+ return true;
+}
+
void util_uint8_to_hex(const uint8_t* in_bin, const size_t in_len, char* out)
{
memset(out, 0, in_len * 2 + 1);
### src/util.h
@@ -50,6 +50,22 @@ typedef uint8_t secbool_u8;
void util_zero(volatile void* dst, size_t len);
void util_strlcpy(char* dst, const char* src, size_t dst_len);
+/**
+ * Validates the complete `src` byte span as UTF-8 and copies as much as fits into `dst` without
+ * splitting a code point. `dst` is always null-terminated on success. Returns the number of bytes
+ * copied, or -1 on failure.
+ */
+intptr_t util_utf8_copy(char* dst, size_t dst_len, const char* src, size_t src_len);
+
+/**
+ * Validates `src` as UTF-8 and copies as much as fits into `dst` without splitting a code point.
+ * `dst` is always null-terminated on success. Returns the number of bytes copied, or -1 on failure.
+ */
+intptr_t util_utf8_strlcpy(char* dst, const char* src, size_t dst_len);
+
+/** Returns true if `str` contains only printable ASCII, optionally including newlines. */
+bool util_is_printable_ascii(const char* str, bool allow_newline);
+
// `out` must be of size in_len*2+1. Use BB_HEX_SIZE() to compute the size.
void util_uint8_to_hex(const uint8_t* in_bin, size_t in_len, char* out);
### test/unit-test/test_memory.c
@@ -563,13 +563,28 @@ static void _set_device_name(const char* device_name)
expect_value(__wrap_memory_write_chunk_fake, chunk_num, 1);
expect_memory(__wrap_memory_write_chunk_fake, chunk, expected_chunk, CHUNK_SIZE);
will_return(__wrap_memory_write_chunk_fake, true);
- assert_true(memory_set_device_name(device_name));
+ assert_true(memory_set_device_name(device_name, strlen(device_name)));
}
static void _test_memory_device_name(void** state)
{
+ assert_false(memory_set_device_name(NULL, 0));
+
+ const char mismatched_len[] = "name";
+ assert_false(memory_set_device_name(mismatched_len, sizeof(mismatched_len) - 2));
+
+ const char embedded_null[] = {'n', '\0', 'a', 'm', 'e', '\0'};
+ assert_false(memory_set_device_name(embedded_null, sizeof(embedded_null) - 1));
+
const char invalid_name[] = "\xff";
- assert_false(memory_set_device_name(invalid_name));
+ assert_false(memory_set_device_name(invalid_name, sizeof(invalid_name) - 1));
+
+ char truncated_suffix[MEMORY_DEVICE_MAX_LEN_WITH_NULL + 2];
+ memset(truncated_suffix, 'x', MEMORY_DEVICE_MAX_LEN_WITH_NULL - 1);
+ truncated_suffix[MEMORY_DEVICE_MAX_LEN_WITH_NULL - 1] = (char)0xC3;
+ truncated_suffix[MEMORY_DEVICE_MAX_LEN_WITH_NULL] = (char)0xA4;
+ truncated_suffix[MEMORY_DEVICE_MAX_LEN_WITH_NULL + 1] = '\0';
+ _set_device_name(truncated_suffix);
const char* device_name = "test name";
_set_device_name(device_name);
### test/unit-test/test_util.c
@@ -53,11 +53,45 @@ static void test_util_strlcpy(void** state)
assert_int_equal(zero_len, 'x');
}
+static void test_util_utf8_copy(void** state)
+{
+ (void)state;
+
+ const char utf8[] = {'t', (char)0xC3, (char)0xA4, 's', 't', '\0'};
+ char out[6] = {0};
+ assert_int_equal(util_utf8_strlcpy(out, utf8, sizeof(out)), 5);
+ assert_memory_equal(out, utf8, sizeof(out));
+
+ char truncated[3] = {0};
+ assert_int_equal(util_utf8_strlcpy(truncated, utf8, sizeof(truncated)), 1);
+ assert_string_equal(truncated, "t");
+
+ char empty[1] = {'x'};
+ assert_int_equal(util_utf8_strlcpy(empty, "", sizeof(empty)), 0);
+ assert_string_equal(empty, "");
+
+ const char invalid[] = {'t', (char)0xFF, '\0'};
+ assert_int_equal(util_utf8_strlcpy(out, invalid, sizeof(out)), -1);
+ assert_string_equal(out, "");
+}
+
+static void test_util_is_printable_ascii(void** state)
+{
+ (void)state;
+
+ assert_true(util_is_printable_ascii("printable ASCII", false));
+ assert_false(util_is_printable_ascii("line one\nline two", false));
+ assert_true(util_is_printable_ascii("line one\nline two", true));
+ assert_false(util_is_printable_ascii("t\xC3\xA4st", true));
+}
+
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_minmax),
cmocka_unit_test(test_util_strlcpy),
+ cmocka_unit_test(test_util_utf8_copy),
+ cmocka_unit_test(test_util_is_printable_ascii),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}Why this scored 53/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.