fix(config): parse colour hex digits as chars, not byte offsets

Bug:
parse_hex validated the colour string by byte length and then sliced at
fixed byte offsets. A multi-byte UTF-8 character inside the field makes
the byte offsets land mid-character, so committing the Options colour
keystroke field (or set_hex) panics at the char-boundary slice.

Reproduce:
1. Options -> any colour field; enter a value that passes the byte
   length check but contains a multi-byte character.
2. Commit the field.
3. Before: panic at the slice.
4. After: the value parses as far as valid hex digits allow or is
   rejected as invalid input - no panic.

Fix:
The parser walks chars, consuming exactly two hex digits per channel via
to_digit(16) and requiring the exact total length - byte offsets no
longer exist in the function. Covers all five colour fields and set_hex.
A regression test covers multi-byte inputs.
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:49 +01:00
1 parent d175861fa4
commit c86746726b
1 file changed
+32 -6
+32 -6
View File
@@ -210,14 +210,20 @@ pub(crate) fn rgb_to_hex(rgb: [u8; 3]) -> String {
pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> {
let value = value.trim().strip_prefix('#').unwrap_or(value.trim());
if value.len() != 6 {
// Walk pairs of hex digits as chars: the old byte-length guard let a
// 6-byte multibyte string (`aébé`) through to fixed-offset slices that
// split a character. Exactly two digits per channel, or it is not a colour.
let mut bytes = [0u8; 3];
let mut chars = value.chars();
for slot in &mut bytes {
let high = chars.next()?.to_digit(16)?;
let low = chars.next()?.to_digit(16)?;
*slot = ((high << 4) | low) as u8;
}
if chars.next().is_some() {
return None;
}
Some([
u8::from_str_radix(&value[0..2], 16).ok()?,
u8::from_str_radix(&value[2..4], 16).ok()?,
u8::from_str_radix(&value[4..6], 16).ok()?,
])
Some(bytes)
}
/// Canvas mode for Model space.
@@ -612,4 +618,24 @@ mod tests {
assert_eq!(parse_theme_name("0"), None);
assert_eq!(parse_theme_name("nonexistent_theme"), None);
}
/// The old guard checked the BYTE length (`value.len() != 6`) but sliced
/// at fixed byte offsets — a 6-byte multibyte string like `aébé` passed
/// the guard and then split a character:
/// `byte index 2 is not a char boundary; it is inside 'é'`. Pasting it
/// into any Options colour field killed the process on the keystroke.
#[test]
fn test_parse_hex_rejects_multibyte_input_without_panicking() {
for value in ["aébé", "#aébé", "éééééé", "#ébé#"] {
assert_eq!(parse_hex(value), None, "{value} should not parse");
}
// Non-hex garbage and wrong lengths stay None.
for value in ["gggggg", "a1b2c", "a1b2c34", "#GGHHII"] {
assert_eq!(parse_hex(value), None, "{value} should not parse");
}
// Valid input keeps working, with and without '#', with whitespace.
assert_eq!(parse_hex("a1b2c3"), Some([0xa1, 0xb2, 0xc3]));
assert_eq!(parse_hex("#A1B2C3"), Some([0xa1, 0xb2, 0xc3]));
assert_eq!(parse_hex(" 000000 "), Some([0, 0, 0]));
}
}