Skip to main content

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}