fix: prevent UTF-8 panics in byte-index string truncation

Replace unsafe `&s[..n]` patterns with `floor_char_boundary(s, n)` at 3
production code sites where the truncation index could land mid-multibyte
character, panicking on non-ASCII input:

- src/llm/nearai_chat.rs: API response truncation in error message
- src/cli/memory.rs: memory content display truncation
- src/cli/config.rs: config value display truncation

All 3 sites operate on external or user-supplied strings that may contain
non-ASCII characters. The existing `crate::util::floor_char_boundary`
utility (used at 18 other call sites) walks back to the nearest char
boundary, preventing the panic.

Adds regression test with multi-byte characters (combining accents and
4-byte emoji) for truncate_content.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Zaki
2026-03-26 16:34:14 -07:00
co-authored by Claude Opus 4.6
parent dd0a0e10ab
commit 6198c98673
3 changed files with 20 additions and 3 deletions
+2 -1
View File
@@ -127,7 +127,8 @@ async fn list_settings(
}
let display_value = if value.len() > 60 {
format!("{}...", &value[..57])
let end = crate::util::floor_char_boundary(&value, 57);
format!("{}...", &value[..end])
} else {
value
};
+17 -1
View File
@@ -256,7 +256,8 @@ fn truncate_content(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len])
let end = crate::util::floor_char_boundary(s, max_len);
format!("{}...", &s[..end])
}
}
@@ -292,4 +293,19 @@ mod tests {
assert_eq!(truncate_content("hello", 10), "hello");
assert_eq!(truncate_content("hello world", 5), "hello...");
}
#[test]
fn test_truncate_content_multibyte_does_not_panic() {
// "cafe\u{0301}" = "café" where é is e + combining accent (2 bytes for accent)
// Slicing at byte 5 would land inside the combining character
let s = "caf\u{00e9} au lait"; // café = 5 bytes (é is 2 bytes)
let result = truncate_content(s, 4); // byte 4 is inside é
assert!(result.ends_with("..."));
assert!(!result.is_empty());
// 4-byte emoji: slicing mid-emoji must not panic
let emoji = "Hi \u{1F600} there"; // 😀 is 4 bytes
let result = truncate_content(emoji, 4); // byte 4 is inside 😀
assert!(result.ends_with("..."));
}
}
+1 -1
View File
@@ -451,7 +451,7 @@ impl NearAiChatProvider {
provider: "nearai_chat".to_string(),
reason: format!(
"No model names found in response: {}",
&response_text[..response_text.len().min(300)]
&response_text[..crate::util::floor_char_boundary(&response_text, 300)]
),
})
}