netscli_core/common/terminal.rs
1//! Making remote-controlled strings safe to print to a terminal.
2//!
3//! Almost everything netscli reports is supplied by something else on the
4//! network: reverse-DNS hostnames, TXT record contents, service banners,
5//! mDNS instance names, TLS metadata. All of it is attacker-influenced —
6//! a hostile authoritative DNS server picks its own TXT bytes, and any
7//! device on the local link can announce whatever mDNS name it likes.
8//!
9//! When that lands in a terminal via `println!`, an embedded `ESC` is not
10//! inert. ANSI/OSC sequences can repaint the screen, hide or fabricate
11//! output lines, set (and on some emulators read back) the window title,
12//! and write the clipboard via OSC 52. For a tool whose entire value is
13//! the operator trusting what it prints, forged output is the interesting
14//! attack, not a cosmetic glitch.
15//!
16//! So: strip control characters at the point where remote data becomes a
17//! displayable string.
18//!
19//! **Why this is not needed everywhere.** Two of netscli's output paths
20//! are already structurally safe and deliberately do not call this:
21//!
22//! - The TUI renders through ratatui, which filters control characters
23//! out of graphemes before they reach the cell buffer.
24//! - `--json` / `--yaml` go through serde, which escapes control
25//! characters as part of producing valid JSON/YAML.
26//!
27//! The plain-text CLI path is the one that writes bytes straight to the
28//! terminal, and that is what this module protects.
29
30/// Replace every control character with `'.'`.
31///
32/// Keeps the string's visual length stable (one replacement per removed
33/// character) so column-aligned table output does not shift, and returns
34/// `Cow::Borrowed` when there is nothing to strip, which is the
35/// overwhelmingly common case.
36///
37/// Unlike the scan-probe sanitizer, this strips `\n`, `\r`, and `\t` too.
38/// A single-line display value has no business containing them: `\n`
39/// fabricates extra output lines and `\r` lets a remote host overwrite
40/// the row it was printed on.
41pub fn sanitize_for_terminal(value: &str) -> std::borrow::Cow<'_, str> {
42 if !value.chars().any(char::is_control) {
43 return std::borrow::Cow::Borrowed(value);
44 }
45 std::borrow::Cow::Owned(
46 value
47 .chars()
48 .map(|c| if c.is_control() { '.' } else { c })
49 .collect(),
50 )
51}
52
53#[cfg(test)]
54mod tests {
55 use super::sanitize_for_terminal;
56
57 #[test]
58 fn clean_input_is_borrowed_unchanged() {
59 let out = sanitize_for_terminal("printer.local");
60 assert_eq!(out, "printer.local");
61 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
62 }
63
64 #[test]
65 fn ansi_escape_is_stripped() {
66 // The mDNS/DNS attack: a remote name carrying a colour sequence
67 // plus a cursor-up, used to repaint lines already printed.
68 let hostile = "evil\u{1b}[31m\u{1b}[1Aowned";
69 let out = sanitize_for_terminal(hostile);
70 assert!(!out.contains('\u{1b}'), "ESC survived: {out:?}");
71 assert_eq!(out, "evil.[31m.[1Aowned");
72 }
73
74 #[test]
75 fn newline_and_carriage_return_are_stripped() {
76 // \n fabricates output lines; \r overwrites the current one.
77 assert_eq!(sanitize_for_terminal("a\nb"), "a.b");
78 assert_eq!(sanitize_for_terminal("real\rfake"), "real.fake");
79 assert_eq!(sanitize_for_terminal("a\tb"), "a.b");
80 }
81
82 #[test]
83 fn osc_52_clipboard_write_is_defused() {
84 // OSC 52 is the nastiest of the family: it writes the user's
85 // clipboard. It needs both the leading ESC and the terminating
86 // BEL, and we remove both.
87 let hostile = "host\u{1b}]52;c;ZWNobyBwd25lZAo=\u{7}";
88 let out = sanitize_for_terminal(hostile);
89 assert!(!out.contains('\u{1b}'));
90 assert!(!out.contains('\u{7}'));
91 }
92
93 #[test]
94 fn visual_width_is_preserved() {
95 // One replacement character per control character, so table
96 // column alignment computed from the sanitized string holds.
97 let hostile = "ab\u{1b}\u{7}cd";
98 assert_eq!(sanitize_for_terminal(hostile).chars().count(), 6);
99 }
100
101 #[test]
102 fn unicode_is_left_alone() {
103 assert_eq!(sanitize_for_terminal("café-über-日本"), "café-über-日本");
104 }
105}