Skip to main content

turbo_debug_console/
proto.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! The `HELLO` handshake.
5//!
6//! ```text
7//! client -> control 7878 :  HELLO <version> <name>\n
8//! server ->              :  PORT <n>\n              (or  ERR <reason>\n)
9//! ```
10//!
11//! A first line that is not a `HELLO` is not an error: the connection is
12//! treated as a raw anonymous stream, so `nc host 7878 < capture.txt` works
13//! with no ceremony.
14
15/// Maximum session-name length, in bytes.
16pub const NAME_MAX: usize = 64;
17
18/// The protocol version this console speaks. The single source of truth for
19/// what a `HELLO` must claim to be accepted.
20pub const PROTOCOL_VERSION: u32 = 1;
21
22/// Why a line was not a usable `HELLO`.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum HelloError {
25    /// Not a handshake at all — treat the connection as a raw stream.
26    NotHello,
27    /// A handshake with an unusable name.
28    BadName,
29    /// A `HELLO` with no version field at all. Nothing is deployed yet, so
30    /// this ambiguity is cheapest to close now rather than silently
31    /// assuming version 1.
32    MissingVersion,
33    /// A version field that isn't a bare non-negative integer.
34    BadVersion,
35    /// A well-formed version this console does not speak.
36    UnsupportedVersion(u32),
37}
38
39impl HelloError {
40    /// The line to send back, without its newline.
41    #[must_use]
42    pub fn wire(&self) -> String {
43        match self {
44            Self::NotHello => "ERR not a handshake".to_string(),
45            Self::BadName => "ERR bad name".to_string(),
46            Self::MissingVersion => "ERR missing protocol version".to_string(),
47            Self::BadVersion => "ERR bad protocol version".to_string(),
48            Self::UnsupportedVersion(v) => format!("ERR unsupported protocol version {v}"),
49        }
50    }
51}
52
53/// Parses one handshake line, returning the session name.
54///
55/// # Errors
56/// [`HelloError::NotHello`] when the line has no `HELLO ` prefix;
57/// [`HelloError::MissingVersion`] when there is no version field at all;
58/// [`HelloError::BadVersion`] when the version field is not a bare
59/// non-negative integer; [`HelloError::UnsupportedVersion`] when the version
60/// is well-formed but not [`PROTOCOL_VERSION`]; [`HelloError::BadName`] when
61/// the name is empty, longer than [`NAME_MAX`], or contains anything but
62/// printable non-space ASCII.
63pub fn parse_hello(line: &str) -> Result<String, HelloError> {
64    let line = line.trim_end_matches(['\r', '\n']);
65    let rest = line.strip_prefix("HELLO ").ok_or(HelloError::NotHello)?;
66
67    let (version, name) = rest.split_once(' ').ok_or(HelloError::MissingVersion)?;
68    if version.is_empty() {
69        return Err(HelloError::MissingVersion);
70    }
71    let version: u32 = version.parse().map_err(|_| HelloError::BadVersion)?;
72    if version != PROTOCOL_VERSION {
73        return Err(HelloError::UnsupportedVersion(version));
74    }
75
76    if name.is_empty() || name.len() > NAME_MAX {
77        return Err(HelloError::BadName);
78    }
79    if !name.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
80        return Err(HelloError::BadName);
81    }
82    Ok(name.to_string())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn accepts_a_well_formed_hello() {
91        assert_eq!(parse_hello("HELLO 1 build-agent").unwrap(), "build-agent");
92    }
93
94    #[test]
95    fn trailing_cr_is_tolerated() {
96        assert_eq!(parse_hello("HELLO 1 x\r").unwrap(), "x");
97    }
98
99    #[test]
100    fn a_non_hello_line_is_not_an_error_but_a_raw_stream() {
101        assert!(matches!(
102            parse_hello("hello there"),
103            Err(HelloError::NotHello)
104        ));
105        assert!(matches!(
106            parse_hello("{\"tok\":1}"),
107            Err(HelloError::NotHello)
108        ));
109    }
110
111    #[test]
112    fn hello_with_no_version_is_missing_version_not_assumed_v1() {
113        assert!(matches!(
114            parse_hello("HELLO build-agent"),
115            Err(HelloError::MissingVersion)
116        ));
117    }
118
119    #[test]
120    fn non_numeric_version_is_bad_version_not_a_fallback() {
121        assert!(matches!(
122            parse_hello("HELLO v1 build-agent"),
123            Err(HelloError::BadVersion)
124        ));
125        assert!(matches!(
126            parse_hello("HELLO -1 build-agent"),
127            Err(HelloError::BadVersion)
128        ));
129    }
130
131    #[test]
132    fn unsupported_version_is_rejected_by_number() {
133        assert_eq!(
134            parse_hello("HELLO 2 build-agent"),
135            Err(HelloError::UnsupportedVersion(2))
136        );
137        assert_eq!(
138            HelloError::UnsupportedVersion(2).wire(),
139            "ERR unsupported protocol version 2"
140        );
141    }
142
143    #[test]
144    fn empty_oversized_and_whitespace_names_are_rejected() {
145        assert!(matches!(parse_hello("HELLO 1 "), Err(HelloError::BadName)));
146        assert!(matches!(
147            parse_hello("HELLO 1 a b"),
148            Err(HelloError::BadName)
149        ));
150        let long = "x".repeat(65);
151        assert!(matches!(
152            parse_hello(&format!("HELLO 1 {long}")),
153            Err(HelloError::BadName)
154        ));
155        assert!(parse_hello(&format!("HELLO 1 {}", "x".repeat(64))).is_ok());
156    }
157
158    #[test]
159    fn non_printable_names_are_rejected() {
160        assert!(matches!(
161            parse_hello("HELLO 1 na\u{7}me"),
162            Err(HelloError::BadName)
163        ));
164        assert!(matches!(
165            parse_hello("HELLO 1 café"),
166            Err(HelloError::BadName)
167        ));
168    }
169}