564 lines
18 KiB
Rust
564 lines
18 KiB
Rust
//! 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<Self> {
|
|
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<u8> {
|
|
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<u16> {
|
|
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<ResponseOptions>,
|
|
) -> 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<String>,
|
|
pub via_branch: Option<String>,
|
|
pub from_uri: String,
|
|
pub from_display_name: Option<String>,
|
|
pub from_tag: Option<String>,
|
|
pub to_uri: String,
|
|
pub to_display_name: Option<String>,
|
|
pub to_tag: Option<String>,
|
|
pub call_id: Option<String>,
|
|
pub cseq: Option<u32>,
|
|
pub contact: Option<String>,
|
|
pub max_forwards: Option<u16>,
|
|
pub body: Option<String>,
|
|
pub content_type: Option<String>,
|
|
pub extra_headers: Option<Vec<(String, String)>>,
|
|
}
|
|
|
|
/// Options for `SipMessage::create_response`.
|
|
#[derive(Default)]
|
|
pub struct ResponseOptions {
|
|
pub to_tag: Option<String>,
|
|
pub contact: Option<String>,
|
|
pub body: Option<String>,
|
|
pub content_type: Option<String>,
|
|
pub extra_headers: Option<Vec<(String, String)>>,
|
|
}
|
|
|
|
/// 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: <sip:caller@host>;tag=abc\r\n\
|
|
To: <sip:user@host>\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: <sip:caller@host>;tag=abc\r\n\
|
|
To: <sip:user@host>;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("<sip:user@host>;tag=abc123"),
|
|
Some("abc123")
|
|
);
|
|
assert_eq!(SipMessage::extract_tag("<sip:user@host>"), None);
|
|
assert_eq!(
|
|
SipMessage::extract_uri("<sip:user@host>"),
|
|
Some("sip:user@host")
|
|
);
|
|
assert_eq!(
|
|
SipMessage::extract_uri("\"Name\" <sip:user@host>;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("<sip:caller@192.168.1.1:5070>".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());
|
|
}
|
|
}
|