Skip to main content

ssh_cli/ssh/
session_io.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-TYPE-14 / G-CLOSE: UTF-8 truncation helpers shared by the SSH session path.
3#![forbid(unsafe_code)]
4//! Session I/O helpers — UTF-8-safe truncation for captured stdout/stderr.
5//!
6//! Pure CPU, no network. Extracted from [`super::client`] so the exec path and
7//! unit tests share one implementation (G-TYPE-14).
8
9/// Truncates a UTF-8 string to at most `max_chars` codepoints.
10///
11/// Returns `(truncated_string, was_truncated)`. If `max_chars == 0` returns empty string.
12/// Unicode-safe: never splits mid-codepoint (slice at `char_indices` boundary).
13///
14/// Resource: single pass via `char_indices().nth`; fast-path when `content.len() <= max_chars`
15/// (each scalar is ≥ 1 byte, so byte length bounds char count from above).
16///
17/// Prefer [`take_utf8_capped`] on the exec path when you already own a `Vec<u8>` —
18/// that reuses the buffer instead of allocating a second copy.
19#[must_use]
20pub fn truncate_utf8(content: &str, max_chars: usize) -> (String, bool) {
21    if max_chars == 0 {
22        return (String::new(), !content.is_empty());
23    }
24    // Fast path: byte len ≤ max_chars ⇒ codepoint count ≤ max_chars.
25    if content.len() <= max_chars {
26        return (content.to_string(), false);
27    }
28    match content.char_indices().nth(max_chars) {
29        None => (content.to_string(), false),
30        Some((idx, _)) => (content[..idx].to_string(), true),
31    }
32}
33
34/// Decode captured bytes as UTF-8 and apply the codepoint cap, **reusing** the
35/// `Vec` allocation on the valid-UTF-8 happy path (one heap buffer total).
36///
37/// Latency rule: avoid `from_utf8_lossy` + `to_string` double-copy after every exec.
38/// Invalid UTF-8 falls back to lossy replacement (same contract as before).
39#[must_use]
40pub(crate) fn take_utf8_capped(bytes: Vec<u8>, max_chars: usize) -> (String, bool) {
41    let s = match String::from_utf8(bytes) {
42        Ok(s) => s,
43        Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
44    };
45    if max_chars == 0 {
46        return (String::new(), !s.is_empty());
47    }
48    // Fast path: byte len ≤ max_chars ⇒ codepoint count ≤ max_chars.
49    if s.len() <= max_chars {
50        return (s, false);
51    }
52    match s.char_indices().nth(max_chars) {
53        None => (s, false),
54        Some((idx, _)) => {
55            let mut owned = s;
56            owned.truncate(idx);
57            (owned, true)
58        }
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn truncate_utf8_no_truncate_if_fits() {
68        let (s, t) = truncate_utf8("ola mundo", 100);
69        assert_eq!(s, "ola mundo");
70        assert!(!t);
71    }
72
73    #[test]
74    fn truncate_utf8_truncates_large_ascii() {
75        let entrada: String = "a".repeat(200);
76        let (s, t) = truncate_utf8(&entrada, 50);
77        assert_eq!(s.chars().count(), 50);
78        assert!(t);
79    }
80
81    #[test]
82    fn truncate_utf8_preserves_accented_graphemes() {
83        // 10 codepoints: "á" (1 char) * 10
84        let entrada: String = "á".repeat(30);
85        let (s, t) = truncate_utf8(&entrada, 10);
86        assert_eq!(s.chars().count(), 10);
87        // Each 'á' is 2 UTF-8 bytes → 10 chars = 20 bytes
88        assert_eq!(s.len(), 20);
89        assert!(t);
90        // Does not split mid-byte
91        assert!(s.chars().all(|c| c == 'á'));
92    }
93
94    #[test]
95    fn truncate_utf8_emojis_does_not_break() {
96        let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
97        let (s, t) = truncate_utf8(entrada, 5);
98        assert_eq!(s.chars().count(), 5);
99        assert!(t);
100    }
101
102    #[test]
103    fn truncate_utf8_zero_returns_empty() {
104        let (s, t) = truncate_utf8("abc", 0);
105        assert_eq!(s, "");
106        assert!(t);
107    }
108
109    #[test]
110    fn take_utf8_capped_reuses_valid_utf8_without_truncate() {
111        let bytes = b"hello agent".to_vec();
112        let (s, t) = take_utf8_capped(bytes, 100);
113        assert_eq!(s, "hello agent");
114        assert!(!t);
115    }
116
117    #[test]
118    fn take_utf8_capped_truncates_and_replaces_invalid() {
119        let mut bytes = b"abcdef".to_vec();
120        let (s, t) = take_utf8_capped(bytes.clone(), 3);
121        assert_eq!(s, "abc");
122        assert!(t);
123        // Invalid UTF-8: 0xFF is replaced lossily, still returns owned String.
124        bytes = vec![0xFF, b'a', b'b'];
125        let (s, t) = take_utf8_capped(bytes, 10);
126        assert!(!s.is_empty());
127        assert!(!t);
128        assert!(s.contains('a'));
129    }
130}