fraiseql_core/utils/text.rs
1//! UTF-8-safe string truncation helpers.
2//!
3//! Error, log, and audit paths routinely truncate user-controlled query text
4//! for display. Slicing a `&str` at a fixed byte offset (`&s[..100]`) panics
5//! when that offset lands inside a multi-byte UTF-8 character — a
6//! remotely-triggerable abort if the truncated string is attacker-influenced
7//! (audit H20). These helpers truncate on character boundaries instead.
8
9/// Borrow the longest prefix of `s` that is at most `max_bytes` long and ends
10/// on a UTF-8 character boundary.
11///
12/// Returns `s` unchanged when it already fits. Never panics and never splits a
13/// character: if `max_bytes` falls inside a multi-byte character, the prefix is
14/// shortened to the preceding boundary (so the result may be a few bytes
15/// shorter than `max_bytes`).
16#[must_use]
17pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
18 if s.len() <= max_bytes {
19 return s;
20 }
21 let mut end = max_bytes;
22 while end > 0 && !s.is_char_boundary(end) {
23 end -= 1;
24 }
25 // `end` is now a valid char boundary in `0..=max_bytes`; slicing is safe.
26 &s[..end]
27}
28
29/// Render `s` for inclusion in an error/log message, truncated to `max_bytes`.
30///
31/// Returns `s` unchanged when it fits; otherwise a character-boundary-safe
32/// prefix followed by a trailing `...`. This is the char-safe replacement for
33/// the `if s.len() > N { format!("{}...", &s[..N]) } else { s.to_string() }`
34/// snippet that was copy-pasted across the query-timeout, audit-export, and
35/// error-formatting paths (audit H20).
36#[must_use]
37pub fn truncate_for_display(s: &str, max_bytes: usize) -> String {
38 if s.len() <= max_bytes {
39 s.to_string()
40 } else {
41 format!("{}...", truncate_at_char_boundary(s, max_bytes))
42 }
43}
44
45#[cfg(test)]
46mod text_tests {
47 use super::*;
48
49 #[test]
50 fn short_string_unchanged() {
51 assert_eq!(truncate_at_char_boundary("hello", 100), "hello");
52 assert_eq!(truncate_for_display("hello", 100), "hello");
53 }
54
55 #[test]
56 fn exact_length_unchanged() {
57 assert_eq!(truncate_at_char_boundary("abcde", 5), "abcde");
58 assert_eq!(truncate_for_display("abcde", 5), "abcde");
59 }
60
61 #[test]
62 fn ascii_truncates_at_limit() {
63 let s = "a".repeat(200);
64 assert_eq!(truncate_at_char_boundary(&s, 50).len(), 50);
65 let display = truncate_for_display(&s, 50);
66 assert!(display.ends_with("..."));
67 assert_eq!(display.len(), 53);
68 }
69
70 #[test]
71 fn does_not_split_multibyte_char() {
72 // "é" is two bytes (0xC3 0xA9). A cut at an odd byte must step back to
73 // the preceding boundary rather than panic.
74 let s = "é".repeat(10); // 20 bytes
75 let truncated = truncate_at_char_boundary(&s, 5);
76 // 5 lands mid-char → step back to 4 (two whole 'é').
77 assert_eq!(truncated, "éé");
78 assert!(s.is_char_boundary(truncated.len()));
79 }
80
81 #[test]
82 fn display_preserves_validity_on_multibyte() {
83 let s = "héllo wörld ".repeat(20);
84 for max in 0..s.len() {
85 let out = truncate_for_display(&s, max);
86 // Must always be valid UTF-8 (String guarantees it) and never panic.
87 assert!(out.is_char_boundary(0));
88 }
89 }
90
91 #[test]
92 fn zero_max_bytes() {
93 assert_eq!(truncate_at_char_boundary("abc", 0), "");
94 assert_eq!(truncate_for_display("abc", 0), "...");
95 }
96}