//! SIP message parsing, serialization, inspection, mutation, and factory methods. //! //! Ported from ts/sip/message.ts. use crate::helpers::{generate_branch, generate_call_id, generate_tag}; /// A parsed SIP message (request or response). #[derive(Debug, Clone)] pub struct SipMessage { pub start_line: String, pub headers: Vec<(String, String)>, pub body: String, } impl SipMessage { pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self { Self { start_line, headers, body } } // ---- Parsing ----------------------------------------------------------- /// Parse a raw buffer into a SipMessage. Returns None for invalid data. pub fn parse(buf: &[u8]) -> Option { if buf.is_empty() { return None; } // First byte must be ASCII A-z. if buf[0] < 0x41 || buf[0] > 0x7a { return None; } let text = std::str::from_utf8(buf).ok()?; let (head, body) = if let Some(sep) = text.find("\r\n\r\n") { (&text[..sep], &text[sep + 4..]) } else if let Some(sep) = text.find("\n\n") { (&text[..sep], &text[sep + 2..]) } else { (text, "") }; let normalized = head.replace("\r\n", "\n"); let lines: Vec<&str> = normalized.split('\n').collect(); if lines.is_empty() || lines[0].is_empty() { return None; } let start_line = lines[0]; // Validate: must be a SIP request or response start line. if !is_sip_first_line(start_line) { return None; } let mut headers = Vec::new(); for &line in &lines[1..] { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Some(colon) = line.find(':') { let name = line[..colon].trim().to_string(); let value = line[colon + 1..].trim().to_string(); headers.push((name, value)); } } Some(SipMessage { start_line: start_line.to_string(), headers, body: body.to_string(), }) } // ---- Serialization ----------------------------------------------------- /// Serialize the message to a byte buffer suitable for UDP transmission. pub fn serialize(&self) -> Vec { let mut head = self.start_line.clone(); for (name, value) in &self.headers { head.push_str("\r\n"); head.push_str(name); head.push_str(": "); head.push_str(value); } head.push_str("\r\n\r\n"); let mut buf = head.into_bytes(); if !self.body.is_empty() { buf.extend_from_slice(self.body.as_bytes()); } buf } // ---- Inspectors -------------------------------------------------------- pub fn is_request(&self) -> bool { !self.start_line.starts_with("SIP/") } pub fn is_response(&self) -> bool { self.start_line.starts_with("SIP/") } /// Request method (INVITE, REGISTER, ...) or None for responses. pub fn method(&self) -> Option<&str> { if !self.is_request() { return None; } self.start_line.split_whitespace().next() } /// Response status code or None for requests. pub fn status_code(&self) -> Option { if !self.is_response() { return None; } self.start_line .split_whitespace() .nth(1) .and_then(|s| s.parse().ok()) } pub fn call_id(&self) -> &str { self.get_header("Call-ID").unwrap_or("noid") } /// Method from the CSeq header (e.g. "INVITE"). pub fn cseq_method(&self) -> Option<&str> { let cseq = self.get_header("CSeq")?; cseq.split_whitespace().nth(1) } /// True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE. pub fn is_dialog_establishing(&self) -> bool { matches!( self.method(), Some("INVITE" | "SUBSCRIBE" | "REFER" | "NOTIFY" | "UPDATE") ) } /// True when the body carries an SDP payload. pub fn has_sdp_body(&self) -> bool { if self.body.is_empty() { return false; } let ct = self.get_header("Content-Type").unwrap_or(""); ct.to_ascii_lowercase().starts_with("application/sdp") } // ---- Header accessors -------------------------------------------------- /// Get the first header value matching `name` (case-insensitive). pub fn get_header(&self, name: &str) -> Option<&str> { let nl = name.to_ascii_lowercase(); for (n, v) in &self.headers { if n.to_ascii_lowercase() == nl { return Some(v.as_str()); } } None } /// Overwrites the first header with the given name, or appends it. pub fn set_header(&mut self, name: &str, value: &str) -> &mut Self { let nl = name.to_ascii_lowercase(); for h in &mut self.headers { if h.0.to_ascii_lowercase() == nl { h.1 = value.to_string(); return self; } } self.headers.push((name.to_string(), value.to_string())); self } /// Inserts a header at the top of the header list. pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self { self.headers.insert(0, (name.to_string(), value.to_string())); self } /// Removes all headers with the given name. pub fn remove_header(&mut self, name: &str) -> &mut Self { let nl = name.to_ascii_lowercase(); self.headers.retain(|(n, _)| n.to_ascii_lowercase() != nl); self } /// Recalculates Content-Length to match the current body. pub fn update_content_length(&mut self) -> &mut Self { let len = self.body.len(); self.set_header("Content-Length", &len.to_string()) } // ---- Start-line mutation ----------------------------------------------- /// Replace the Request-URI (second token) of a request start line. pub fn set_request_uri(&mut self, uri: &str) -> &mut Self { if !self.is_request() { return self; } let parts: Vec<&str> = self.start_line.splitn(3, ' ').collect(); if parts.len() >= 3 { self.start_line = format!("{} {} {}", parts[0], uri, parts[2]); } self } /// Returns the Request-URI (second token) of a request start line. pub fn request_uri(&self) -> Option<&str> { if !self.is_request() { return None; } self.start_line.split_whitespace().nth(1) } // ---- Factory methods --------------------------------------------------- /// Build a new SIP request. pub fn create_request(method: &str, request_uri: &str, opts: RequestOptions) -> Self { let branch = opts.via_branch.unwrap_or_else(|| generate_branch()); let transport = opts.via_transport.unwrap_or_else(|| "UDP".to_string()); let from_tag = opts.from_tag.unwrap_or_else(|| generate_tag()); let call_id = opts.call_id.unwrap_or_else(|| generate_call_id(None)); let cseq = opts.cseq.unwrap_or(1); let max_forwards = opts.max_forwards.unwrap_or(70); let from_display = opts .from_display_name .map(|d| format!("\"{d}\" ")) .unwrap_or_default(); let to_display = opts .to_display_name .map(|d| format!("\"{d}\" ")) .unwrap_or_default(); let to_tag_str = opts .to_tag .map(|t| format!(";tag={t}")) .unwrap_or_default(); let mut headers = vec![ ( "Via".to_string(), format!( "SIP/2.0/{transport} {}:{};branch={branch};rport", opts.via_host, opts.via_port ), ), ( "From".to_string(), format!("{from_display}<{}>;tag={from_tag}", opts.from_uri), ), ( "To".to_string(), format!("{to_display}<{}>{to_tag_str}", opts.to_uri), ), ("Call-ID".to_string(), call_id), ("CSeq".to_string(), format!("{cseq} {method}")), ("Max-Forwards".to_string(), max_forwards.to_string()), ]; if let Some(contact) = &opts.contact { headers.push(("Contact".to_string(), contact.clone())); } if let Some(extra) = opts.extra_headers { headers.extend(extra); } let body = opts.body.unwrap_or_default(); if !body.is_empty() { if let Some(ct) = &opts.content_type { headers.push(("Content-Type".to_string(), ct.clone())); } } headers.push(("Content-Length".to_string(), body.len().to_string())); SipMessage { start_line: format!("{method} {request_uri} SIP/2.0"), headers, body, } } /// Build a SIP response to an incoming request. /// Copies Via, From, To, Call-ID, and CSeq from the original request. pub fn create_response( status_code: u16, reason_phrase: &str, request: &SipMessage, opts: Option, ) -> Self { let opts = opts.unwrap_or_default(); let mut headers: Vec<(String, String)> = Vec::new(); // Copy all Via headers (order matters). for (n, v) in &request.headers { if n.to_ascii_lowercase() == "via" { headers.push(("Via".to_string(), v.clone())); } } // From — copied verbatim. if let Some(from) = request.get_header("From") { headers.push(("From".to_string(), from.to_string())); } // To — add tag if provided and not already present. let mut to = request.get_header("To").unwrap_or("").to_string(); if let Some(tag) = &opts.to_tag { if !to.contains("tag=") { to.push_str(&format!(";tag={tag}")); } } headers.push(("To".to_string(), to)); headers.push(("Call-ID".to_string(), request.call_id().to_string())); if let Some(cseq) = request.get_header("CSeq") { headers.push(("CSeq".to_string(), cseq.to_string())); } if let Some(contact) = &opts.contact { headers.push(("Contact".to_string(), contact.clone())); } if let Some(extra) = opts.extra_headers { headers.extend(extra); } let body = opts.body.unwrap_or_default(); if !body.is_empty() { if let Some(ct) = &opts.content_type { headers.push(("Content-Type".to_string(), ct.clone())); } } headers.push(("Content-Length".to_string(), body.len().to_string())); SipMessage { start_line: format!("SIP/2.0 {status_code} {reason_phrase}"), headers, body, } } /// Extract the tag from a From or To header value. pub fn extract_tag(header_value: &str) -> Option<&str> { let idx = header_value.find(";tag=")?; let rest = &header_value[idx + 5..]; let end = rest .find(|c: char| c.is_whitespace() || c == ';' || c == '>') .unwrap_or(rest.len()); Some(&rest[..end]) } /// Extract the URI from an addr-spec or name-addr (From/To/Contact). pub fn extract_uri(header_value: &str) -> Option<&str> { if let Some(start) = header_value.find('<') { let end = header_value[start..].find('>')?; Some(&header_value[start + 1..start + end]) } else { let trimmed = header_value.trim(); let end = trimmed .find(|c: char| c == ';' || c == '>') .unwrap_or(trimmed.len()); let result = &trimmed[..end]; if result.is_empty() { None } else { Some(result) } } } } /// Options for `SipMessage::create_request`. pub struct RequestOptions { pub via_host: String, pub via_port: u16, pub via_transport: Option, pub via_branch: Option, pub from_uri: String, pub from_display_name: Option, pub from_tag: Option, pub to_uri: String, pub to_display_name: Option, pub to_tag: Option, pub call_id: Option, pub cseq: Option, pub contact: Option, pub max_forwards: Option, pub body: Option, pub content_type: Option, pub extra_headers: Option>, } /// Options for `SipMessage::create_response`. #[derive(Default)] pub struct ResponseOptions { pub to_tag: Option, pub contact: Option, pub body: Option, pub content_type: Option, pub extra_headers: Option>, } /// Check if a string matches the SIP first-line pattern. fn is_sip_first_line(line: &str) -> bool { // Request: METHOD SP URI SP SIP/X.Y // Response: SIP/X.Y SP STATUS SP REASON if line.starts_with("SIP/") { // Response: SIP/2.0 200 OK let parts: Vec<&str> = line.splitn(3, ' ').collect(); if parts.len() >= 2 { return parts[1].chars().all(|c| c.is_ascii_digit()); } } else { // Request: INVITE sip:user@host SIP/2.0 let parts: Vec<&str> = line.splitn(3, ' ').collect(); if parts.len() >= 3 { return parts[0].chars().all(|c| c.is_ascii_uppercase()) && parts[2].starts_with("SIP/"); } } false } #[cfg(test)] mod tests { use super::*; const INVITE_RAW: &str = "INVITE sip:user@host SIP/2.0\r\n\ Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\ From: ;tag=abc\r\n\ To: \r\n\ Call-ID: test-call-id\r\n\ CSeq: 1 INVITE\r\n\ Content-Length: 0\r\n\r\n"; #[test] fn parse_invite() { let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap(); assert!(msg.is_request()); assert!(!msg.is_response()); assert_eq!(msg.method(), Some("INVITE")); assert_eq!(msg.call_id(), "test-call-id"); assert_eq!(msg.cseq_method(), Some("INVITE")); assert!(msg.is_dialog_establishing()); assert_eq!(msg.request_uri(), Some("sip:user@host")); } #[test] fn parse_response() { let raw = "SIP/2.0 200 OK\r\n\ Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\ From: ;tag=abc\r\n\ To: ;tag=def\r\n\ Call-ID: test-call-id\r\n\ CSeq: 1 INVITE\r\n\ Content-Length: 0\r\n\r\n"; let msg = SipMessage::parse(raw.as_bytes()).unwrap(); assert!(msg.is_response()); assert_eq!(msg.status_code(), Some(200)); assert_eq!(msg.cseq_method(), Some("INVITE")); } #[test] fn serialize_roundtrip() { let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap(); let serialized = msg.serialize(); let reparsed = SipMessage::parse(&serialized).unwrap(); assert_eq!(reparsed.call_id(), "test-call-id"); assert_eq!(reparsed.method(), Some("INVITE")); assert_eq!(reparsed.headers.len(), msg.headers.len()); } #[test] fn header_mutation() { let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap(); msg.set_header("X-Custom", "value1"); assert_eq!(msg.get_header("X-Custom"), Some("value1")); msg.set_header("X-Custom", "value2"); assert_eq!(msg.get_header("X-Custom"), Some("value2")); msg.prepend_header("X-First", "first"); assert_eq!(msg.headers[0].0, "X-First"); msg.remove_header("X-Custom"); assert_eq!(msg.get_header("X-Custom"), None); } #[test] fn set_request_uri() { let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap(); msg.set_request_uri("sip:new@host"); assert_eq!(msg.request_uri(), Some("sip:new@host")); assert!(msg.start_line.starts_with("INVITE sip:new@host SIP/2.0")); } #[test] fn extract_tag_and_uri() { assert_eq!( SipMessage::extract_tag(";tag=abc123"), Some("abc123") ); assert_eq!(SipMessage::extract_tag(""), None); assert_eq!( SipMessage::extract_uri(""), Some("sip:user@host") ); assert_eq!( SipMessage::extract_uri("\"Name\" ;tag=abc"), Some("sip:user@host") ); } #[test] fn create_request_and_response() { let invite = SipMessage::create_request( "INVITE", "sip:user@host", RequestOptions { via_host: "192.168.1.1".to_string(), via_port: 5070, via_transport: None, via_branch: None, from_uri: "sip:caller@proxy".to_string(), from_display_name: None, from_tag: Some("mytag".to_string()), to_uri: "sip:user@host".to_string(), to_display_name: None, to_tag: None, call_id: Some("test-123".to_string()), cseq: Some(1), contact: Some("".to_string()), max_forwards: None, body: None, content_type: None, extra_headers: None, }, ); assert_eq!(invite.method(), Some("INVITE")); assert_eq!(invite.call_id(), "test-123"); assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070")); let response = SipMessage::create_response( 200, "OK", &invite, Some(ResponseOptions { to_tag: Some("remotetag".to_string()), ..Default::default() }), ); assert!(response.is_response()); assert_eq!(response.status_code(), Some(200)); let to = response.get_header("To").unwrap(); assert!(to.contains("tag=remotetag")); } #[test] fn has_sdp_body() { let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap(); assert!(!msg.has_sdp_body()); msg.body = "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\n".to_string(); msg.set_header("Content-Type", "application/sdp"); assert!(msg.has_sdp_body()); } }