use super::types::{SsdpKind, SsdpMessage};
const MAX_LINE_LEN: usize = 4096;
const MAX_HEADERS: usize = 32;
pub fn parse(payload: &[u8]) -> Option<SsdpMessage> {
let crlf = find_crlf(payload, 0)?;
if crlf > MAX_LINE_LEN {
return None;
}
let request_line = std::str::from_utf8(&payload[..crlf]).ok()?;
let kind = classify_request_line(request_line)?;
let mut msg = SsdpMessage {
kind,
host: None,
nt: None,
nts: None,
st: None,
usn: None,
location: None,
server: None,
cache_control_max_age: None,
user_agent: None,
};
let mut cursor = crlf + 2;
for _ in 0..MAX_HEADERS {
if cursor >= payload.len() {
break;
}
let Some(eol) = find_crlf(payload, cursor) else {
break;
};
if eol == cursor {
break;
}
if eol - cursor > MAX_LINE_LEN {
break;
}
let line = &payload[cursor..eol];
cursor = eol + 2;
if let Some((name, value)) = split_header(line) {
apply_header(&mut msg, &name, value);
}
}
Some(msg)
}
fn find_crlf(buf: &[u8], from: usize) -> Option<usize> {
if from >= buf.len() {
return None;
}
buf[from..]
.windows(2)
.position(|w| w == b"\r\n")
.map(|p| p + from)
}
fn classify_request_line(line: &str) -> Option<SsdpKind> {
let upper = line.to_ascii_uppercase();
if upper.starts_with("NOTIFY ") {
Some(SsdpKind::Notify)
} else if upper.starts_with("M-SEARCH ") {
Some(SsdpKind::MSearch)
} else if upper.starts_with("HTTP/1.1 200") || upper.starts_with("HTTP/1.0 200") {
Some(SsdpKind::Response)
} else {
None
}
}
fn split_header(line: &[u8]) -> Option<(String, &str)> {
let colon = line.iter().position(|&b| b == b':')?;
let name = std::str::from_utf8(&line[..colon]).ok()?;
let value_raw = &line[colon + 1..];
let value = std::str::from_utf8(value_raw).ok()?.trim();
Some((name.trim().to_ascii_uppercase(), value))
}
fn apply_header(msg: &mut SsdpMessage, name: &str, value: &str) {
let slot = match name {
"HOST" => &mut msg.host,
"NT" => &mut msg.nt,
"NTS" => &mut msg.nts,
"ST" => &mut msg.st,
"USN" => &mut msg.usn,
"LOCATION" => &mut msg.location,
"SERVER" => &mut msg.server,
"USER-AGENT" => &mut msg.user_agent,
"CACHE-CONTROL" => {
for token in value.split(',') {
let token = token.trim();
let rest = token
.strip_prefix("max-age=")
.or_else(|| token.strip_prefix("MAX-AGE="));
if let Some(rest) = rest
&& let Ok(n) = rest.trim().parse::<u32>()
{
msg.cache_control_max_age = Some(n);
}
}
return;
}
_ => return,
};
if slot.is_none() {
*slot = Some(value.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_notify_alive() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"CACHE-CONTROL: max-age=1800\r\n",
"LOCATION: http://192.168.1.10:80/desc.xml\r\n",
"NT: upnp:rootdevice\r\n",
"NTS: ssdp:alive\r\n",
"SERVER: Linux/4.19 UPnP/1.0 IGD/1.1\r\n",
"USN: uuid:550e8400-e29b-41d4-a716-446655440000::upnp:rootdevice\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.kind, SsdpKind::Notify);
assert_eq!(m.host.as_deref(), Some("239.255.255.250:1900"));
assert_eq!(m.cache_control_max_age, Some(1800));
assert_eq!(
m.location.as_deref(),
Some("http://192.168.1.10:80/desc.xml")
);
assert_eq!(m.nt.as_deref(), Some("upnp:rootdevice"));
assert_eq!(m.nts.as_deref(), Some("ssdp:alive"));
assert_eq!(m.server.as_deref(), Some("Linux/4.19 UPnP/1.0 IGD/1.1"));
assert_eq!(
m.usn.as_deref(),
Some("uuid:550e8400-e29b-41d4-a716-446655440000::upnp:rootdevice")
);
assert!(m.is_alive());
}
#[test]
fn parses_notify_byebye() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"NT: upnp:rootdevice\r\n",
"NTS: ssdp:byebye\r\n",
"USN: uuid:device-uuid::upnp:rootdevice\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert!(m.is_byebye());
assert!(!m.is_alive());
}
#[test]
fn parses_m_search() {
let payload = concat!(
"M-SEARCH * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"MAN: \"ssdp:discover\"\r\n",
"MX: 3\r\n",
"ST: ssdp:all\r\n",
"USER-AGENT: Roku/9.4.0 UPnP/1.0\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.kind, SsdpKind::MSearch);
assert_eq!(m.st.as_deref(), Some("ssdp:all"));
assert_eq!(m.user_agent.as_deref(), Some("Roku/9.4.0 UPnP/1.0"));
}
#[test]
fn parses_search_response() {
let payload = concat!(
"HTTP/1.1 200 OK\r\n",
"CACHE-CONTROL: max-age=120\r\n",
"LOCATION: http://10.0.0.50:1900/igd.xml\r\n",
"SERVER: AsusWRT/3.0.0 UPnP/1.0 MiniUPnPd/1.9\r\n",
"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n",
"USN: uuid:abc-123::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.kind, SsdpKind::Response);
assert_eq!(m.cache_control_max_age, Some(120));
assert_eq!(
m.server.as_deref(),
Some("AsusWRT/3.0.0 UPnP/1.0 MiniUPnPd/1.9")
);
assert_eq!(
m.st.as_deref(),
Some("urn:schemas-upnp-org:device:InternetGatewayDevice:1")
);
}
#[test]
fn case_insensitive_headers() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"host: 239.255.255.250:1900\r\n",
"Cache-Control: max-age=900\r\n",
"NtS: ssdp:alive\r\n",
"Nt: upnp:rootdevice\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.host.as_deref(), Some("239.255.255.250:1900"));
assert_eq!(m.cache_control_max_age, Some(900));
assert!(m.is_alive());
}
#[test]
fn rejects_non_ssdp_payload() {
assert!(parse(b"GET / HTTP/1.1\r\n\r\n").is_none());
assert!(parse(b"random garbage no CRLF").is_none());
assert!(parse(b"").is_none());
}
#[test]
fn truncated_header_block_returns_partial_parse() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"NTS:",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.host.as_deref(), Some("239.255.255.250:1900"));
assert!(m.nts.is_none());
}
#[test]
fn malformed_header_skipped() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"no_colon_here_so_invalid\r\n",
"NTS: ssdp:alive\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.host.as_deref(), Some("239.255.255.250:1900"));
assert!(m.is_alive());
}
#[test]
fn header_walker_caps_iteration() {
let mut payload = String::from("NOTIFY * HTTP/1.1\r\n");
for i in 0..200 {
payload.push_str(&format!("X-Filler-{i}: value\r\n"));
}
payload.push_str("NTS: ssdp:alive\r\n\r\n");
let m = parse(payload.as_bytes());
assert!(m.is_some());
}
#[test]
fn cache_control_other_directives_dont_break() {
let payload = concat!(
"NOTIFY * HTTP/1.1\r\n",
"HOST: 239.255.255.250:1900\r\n",
"CACHE-CONTROL: no-cache, max-age=42, private\r\n",
"NTS: ssdp:alive\r\n",
"\r\n",
);
let m = parse(payload.as_bytes()).unwrap();
assert_eq!(m.cache_control_max_age, Some(42));
}
}