Skip to main content

nomoreide_remote_protocol/
terminal_bytes.rs

1//! Raw PTY bytes on a JSON wire.
2//!
3//! **Why this type exists at all.** Everything else the protocol carries is
4//! text the daemon composed — a service name, a log line it already decoded. A
5//! terminal is different: it is whatever the child wrote, and that is not
6//! UTF-8. A cursor move is bytes; a repaint is bytes; a `read()` that lands in
7//! the middle of a multi-byte character is bytes with half a character at each
8//! end. Passing that through `String::from_utf8_lossy` — the way log lines are
9//! handled — replaces the offending bytes with `U+FFFD`, and a replacement
10//! character in an escape sequence does not render as a slightly wrong screen.
11//! It renders as garbage, and the next sequence is misparsed too.
12//!
13//! So the payload is base64, and this type is the one place that is decided.
14//! Serialising is infallible; deserialising rejects anything that is not
15//! base64, which is what keeps the failure at the protocol boundary instead of
16//! several layers into a terminal emulator.
17
18use base64::engine::general_purpose::STANDARD;
19use base64::Engine as _;
20use serde::de::Error as _;
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22
23/// A chunk of PTY traffic, base64 on the wire and bytes in memory.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct TerminalBytes(pub Vec<u8>);
26
27impl TerminalBytes {
28    pub fn new(data: impl Into<Vec<u8>>) -> Self {
29        Self(data.into())
30    }
31
32    pub fn as_slice(&self) -> &[u8] {
33        &self.0
34    }
35
36    pub fn len(&self) -> usize {
37        self.0.len()
38    }
39
40    pub fn is_empty(&self) -> bool {
41        self.0.is_empty()
42    }
43}
44
45impl Serialize for TerminalBytes {
46    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
47        serializer.serialize_str(&STANDARD.encode(&self.0))
48    }
49}
50
51impl<'de> Deserialize<'de> for TerminalBytes {
52    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
53        let encoded = String::deserialize(deserializer)?;
54        STANDARD
55            .decode(encoded.as_bytes())
56            .map(Self)
57            .map_err(|error| D::Error::custom(format!("terminal data is not base64: {error}")))
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    /// The property the whole type is for: bytes that are not valid UTF-8
66    /// survive the round trip unchanged. A lossy string would not.
67    #[test]
68    fn invalid_utf8_survives_the_round_trip() {
69        let raw = vec![0x1b, b'[', b'2', b'J', 0xff, 0xfe, 0x00, b'x'];
70        let json = serde_json::to_string(&TerminalBytes::new(raw.clone())).unwrap();
71        let back: TerminalBytes = serde_json::from_str(&json).unwrap();
72
73        assert_eq!(back.as_slice(), raw.as_slice());
74        assert_ne!(
75            String::from_utf8_lossy(&raw).as_bytes(),
76            raw.as_slice(),
77            "the bytes chosen must actually be the case a lossy decode would ruin"
78        );
79    }
80
81    #[test]
82    fn it_is_a_json_string_not_an_array_of_numbers() {
83        let json = serde_json::to_string(&TerminalBytes::new(b"hi".to_vec())).unwrap();
84        assert_eq!(json, "\"aGk=\"");
85    }
86
87    #[test]
88    fn anything_that_is_not_base64_is_refused_at_the_boundary() {
89        assert!(serde_json::from_str::<TerminalBytes>("\"not base64!!\"").is_err());
90        assert!(serde_json::from_str::<TerminalBytes>("[104,105]").is_err());
91    }
92}