fix(mesh): never show mojibake device names — strict decode with readable fallback

Name fields sit at firmware-version-dependent offsets; when an offset lands
in binary data, from_utf8_lossy handed the UI replacement-character soup in
the mesh modals and settings panel. decode_mesh_name(): strict UTF-8 to the
first NUL, no control chars, else a readable fallback (short pubkey /
node-<id>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 15:30:09 -04:00
parent be6f06a573
commit e59ea1da69

View File

@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
// ─── Response parsers ───────────────────────────────────────────────────
/// Decode a device/contact name from raw frame bytes, defensively.
///
/// Name fields sit at firmware-version-dependent offsets; when an offset
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
/// hand the UI replacement-character soup ("<22>\u{618}…"). Strict rules: valid
/// UTF-8 up to the first NUL, no control characters, at least one
/// non-whitespace char — anything else gets the caller's readable fallback.
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
match std::str::from_utf8(&bytes[..end]) {
Ok(s) => {
let s = s.trim();
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
s.to_string()
} else {
fallback.to_string()
}
}
Err(_) => fallback.to_string(),
}
}
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
// Name follows after fixed fields. A firmware whose fixed-field layout
// differs would put binary here — decode defensively so the settings
// panel never shows byte soup.
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
} else {
String::new()
};
@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let short_id = format!("{}...", &public_key_hex[..8]);
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
decode_mesh_name(&data[name_start..name_end], &short_id)
} else {
format!("{}...", &public_key_hex[..8])
short_id
};
// last_advert at data[131..135]