Skip to main content

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}