#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum WireProtocol {
Tls,
Http1,
Http2Preface,
Ssh,
Raw,
}
impl WireProtocol {
pub const fn as_str(self) -> &'static str {
match self {
Self::Tls => "tls",
Self::Http1 => "http1",
Self::Http2Preface => "http2-preface",
Self::Ssh => "ssh",
Self::Raw => "raw",
}
}
}
impl std::fmt::Display for WireProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Classify {
Decided(WireProtocol),
NeedMore,
}
pub const HTTP2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
const SSH_PREFIXES: &[&[u8]] = &[b"SSH-2.0-", b"SSH-1.99-"];
const HTTP_METHODS: &[&[u8]] = &[
b"GET ",
b"POST ",
b"HEAD ",
b"PUT ",
b"DELETE ",
b"OPTIONS ",
b"PATCH ",
b"TRACE ",
b"CONNECT ",
];
const MAX_PREFIX: usize = HTTP2_PREFACE.len();
pub fn classify_first_bytes(peek: &[u8]) -> Classify {
if peek.is_empty() {
return Classify::NeedMore;
}
match prefix_of(peek, HTTP2_PREFACE) {
Prefix::Full => return Classify::Decided(WireProtocol::Http2Preface),
Prefix::Partial => return Classify::NeedMore,
Prefix::No => {}
}
if peek[0] == 0x16 {
if peek.len() < 6 {
return Classify::NeedMore;
}
if peek[1] == 0x03 && peek[2] <= 0x04 && peek[5] == 0x01 {
return Classify::Decided(WireProtocol::Tls);
}
return Classify::Decided(WireProtocol::Raw);
}
for prefix in SSH_PREFIXES {
match prefix_of(peek, prefix) {
Prefix::Full => return Classify::Decided(WireProtocol::Ssh),
Prefix::Partial => return Classify::NeedMore,
Prefix::No => {}
}
}
for method in HTTP_METHODS {
match prefix_of(peek, method) {
Prefix::Full => return Classify::Decided(WireProtocol::Http1),
Prefix::Partial => return Classify::NeedMore,
Prefix::No => {}
}
}
if peek.len() < MAX_PREFIX && could_still_grow(peek) {
return Classify::NeedMore;
}
Classify::Decided(WireProtocol::Raw)
}
enum Prefix {
Full,
Partial,
No,
}
fn prefix_of(peek: &[u8], needle: &[u8]) -> Prefix {
if peek.len() >= needle.len() {
if peek.starts_with(needle) {
Prefix::Full
} else {
Prefix::No
}
} else if needle.starts_with(peek) {
Prefix::Partial
} else {
Prefix::No
}
}
fn could_still_grow(peek: &[u8]) -> bool {
if HTTP2_PREFACE.starts_with(peek) {
return true;
}
if peek[0] == 0x16 {
return true;
}
SSH_PREFIXES.iter().any(|p| p.starts_with(peek))
|| HTTP_METHODS.iter().any(|m| m.starts_with(peek))
}
#[cfg(test)]
mod tests {
use super::*;
fn decided(bytes: &[u8]) -> WireProtocol {
match classify_first_bytes(bytes) {
Classify::Decided(p) => p,
Classify::NeedMore => panic!("expected a decision for {bytes:?}"),
}
}
#[test]
fn recognises_each_protocol() {
assert_eq!(decided(b"GET / HTTP/1.1\r\n"), WireProtocol::Http1);
assert_eq!(decided(b"CONNECT h:443 HTTP/1.1\r\n"), WireProtocol::Http1);
assert_eq!(decided(HTTP2_PREFACE), WireProtocol::Http2Preface);
assert_eq!(decided(b"SSH-2.0-OpenSSH_9.6\r\n"), WireProtocol::Ssh);
assert_eq!(decided(b"SSH-1.99-Cisco\r\n"), WireProtocol::Ssh);
assert_eq!(
decided(&[0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00]),
WireProtocol::Tls
);
}
#[test]
fn h2_preface_wins_over_the_http1_reading() {
assert_eq!(decided(HTTP2_PREFACE), WireProtocol::Http2Preface);
assert_eq!(
classify_first_bytes(b"PRI * HTTP/2.0\r\n"),
Classify::NeedMore
);
}
#[test]
fn short_peeks_never_decide_wrongly() {
let inputs: &[&[u8]] = &[
b"GET /index.html HTTP/1.1\r\nHost: x\r\n\r\n",
b"CONNECT example.com:443 HTTP/1.1\r\n\r\n",
HTTP2_PREFACE,
b"SSH-2.0-OpenSSH_9.6\r\n",
&[0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc],
];
for full in inputs {
let answer = decided(full);
for n in 1..full.len() {
match classify_first_bytes(&full[..n]) {
Classify::NeedMore => {}
Classify::Decided(p) => assert_eq!(
p, answer,
"prefix of length {n} of {full:?} decided differently"
),
}
}
}
}
#[test]
fn empty_peek_waits() {
assert_eq!(classify_first_bytes(b""), Classify::NeedMore);
}
#[test]
fn unrecognised_bytes_settle_on_raw() {
assert_eq!(
decided(b"\x00\x01\x02\x03\x04\x05\x06\x07\x08"),
WireProtocol::Raw
);
assert_eq!(decided(b"HELO mail.example.com\r\n"), WireProtocol::Raw);
assert_eq!(decided(b"\xff"), WireProtocol::Raw);
}
#[test]
fn lowercase_methods_are_not_http() {
assert_eq!(decided(b"get / HTTP/1.1\r\n"), WireProtocol::Raw);
}
#[test]
fn non_handshake_tls_record_is_raw() {
assert_eq!(
decided(&[0x16, 0x03, 0x01, 0x00, 0x10, 0x02]),
WireProtocol::Raw
);
assert_eq!(
decided(&[0x17, 0x03, 0x03, 0x00, 0x10, 0x01]),
WireProtocol::Raw
);
}
#[test]
fn slugs_are_stable() {
assert_eq!(WireProtocol::Tls.as_str(), "tls");
assert_eq!(WireProtocol::Http1.as_str(), "http1");
assert_eq!(WireProtocol::Http2Preface.as_str(), "http2-preface");
assert_eq!(WireProtocol::Ssh.as_str(), "ssh");
assert_eq!(WireProtocol::Raw.as_str(), "raw");
}
#[test]
fn never_panics_on_arbitrary_input() {
for len in 0..40usize {
for seed in 0..8u8 {
let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(seed)).collect();
let _ = classify_first_bytes(&bytes);
}
}
}
}