pub const NAME_MAX: usize = 64;
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelloError {
NotHello,
BadName,
MissingVersion,
BadVersion,
UnsupportedVersion(u32),
}
impl HelloError {
#[must_use]
pub fn wire(&self) -> String {
match self {
Self::NotHello => "ERR not a handshake".to_string(),
Self::BadName => "ERR bad name".to_string(),
Self::MissingVersion => "ERR missing protocol version".to_string(),
Self::BadVersion => "ERR bad protocol version".to_string(),
Self::UnsupportedVersion(v) => format!("ERR unsupported protocol version {v}"),
}
}
}
pub fn parse_hello(line: &str) -> Result<String, HelloError> {
let line = line.trim_end_matches(['\r', '\n']);
let rest = line.strip_prefix("HELLO ").ok_or(HelloError::NotHello)?;
let (version, name) = rest.split_once(' ').ok_or(HelloError::MissingVersion)?;
if version.is_empty() {
return Err(HelloError::MissingVersion);
}
let version: u32 = version.parse().map_err(|_| HelloError::BadVersion)?;
if version != PROTOCOL_VERSION {
return Err(HelloError::UnsupportedVersion(version));
}
if name.is_empty() || name.len() > NAME_MAX {
return Err(HelloError::BadName);
}
if !name.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
return Err(HelloError::BadName);
}
Ok(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_well_formed_hello() {
assert_eq!(parse_hello("HELLO 1 build-agent").unwrap(), "build-agent");
}
#[test]
fn trailing_cr_is_tolerated() {
assert_eq!(parse_hello("HELLO 1 x\r").unwrap(), "x");
}
#[test]
fn a_non_hello_line_is_not_an_error_but_a_raw_stream() {
assert!(matches!(
parse_hello("hello there"),
Err(HelloError::NotHello)
));
assert!(matches!(
parse_hello("{\"tok\":1}"),
Err(HelloError::NotHello)
));
}
#[test]
fn hello_with_no_version_is_missing_version_not_assumed_v1() {
assert!(matches!(
parse_hello("HELLO build-agent"),
Err(HelloError::MissingVersion)
));
}
#[test]
fn non_numeric_version_is_bad_version_not_a_fallback() {
assert!(matches!(
parse_hello("HELLO v1 build-agent"),
Err(HelloError::BadVersion)
));
assert!(matches!(
parse_hello("HELLO -1 build-agent"),
Err(HelloError::BadVersion)
));
}
#[test]
fn unsupported_version_is_rejected_by_number() {
assert_eq!(
parse_hello("HELLO 2 build-agent"),
Err(HelloError::UnsupportedVersion(2))
);
assert_eq!(
HelloError::UnsupportedVersion(2).wire(),
"ERR unsupported protocol version 2"
);
}
#[test]
fn empty_oversized_and_whitespace_names_are_rejected() {
assert!(matches!(parse_hello("HELLO 1 "), Err(HelloError::BadName)));
assert!(matches!(
parse_hello("HELLO 1 a b"),
Err(HelloError::BadName)
));
let long = "x".repeat(65);
assert!(matches!(
parse_hello(&format!("HELLO 1 {long}")),
Err(HelloError::BadName)
));
assert!(parse_hello(&format!("HELLO 1 {}", "x".repeat(64))).is_ok());
}
#[test]
fn non_printable_names_are_rejected() {
assert!(matches!(
parse_hello("HELLO 1 na\u{7}me"),
Err(HelloError::BadName)
));
assert!(matches!(
parse_hello("HELLO 1 café"),
Err(HelloError::BadName)
));
}
}