From 149afd450541bca618c4627b4b0e7eb6ea4d0a75 Mon Sep 17 00:00:00 2001 From: Tyson George Date: Thu, 8 Oct 2026 02:38:07 -0400 Subject: [PATCH] feat(sip): keep a Status-Line's custom reason phrase as Response::wire_reason A response such as "SIP/2.0 403 Caller Origination Number is Invalid" parsed to StatusCode::Forbidden and the phrase was dropped: it was not kept anywhere in the parsed Response. Parsed responses now keep a phrase that differs from the standard text of a known code in the new Response::wire_reason field. The standard phrase (compared case-insensitively), an empty phrase, an unknown code (StatusCode::Other already keeps its phrase) and every locally built response leave it None. Display and to_bytes still write the standard phrase, so nothing changes on the wire. --- src/dialog/dialog.rs | 1 + src/dialog/tests/test_authenticate.rs | 2 + src/dialog/tests/test_client_dialog.rs | 4 ++ src/dialog/tests/test_connection_affinity.rs | 1 + src/dialog/tests/test_dialog_states.rs | 1 + src/dialog/tests/test_session_id.rs | 5 ++ src/sip/message.rs | 8 +++ src/sip/parser.rs | 55 +++++++++++++++++++ src/transaction/key.rs | 1 + src/transaction/message.rs | 1 + src/transaction/tests/test_client.rs | 2 + .../tests/test_provisional_responses.rs | 2 + .../tests/test_server_invite_drop.rs | 4 ++ src/transport/tests/test_via_received.rs | 1 + 14 files changed, 88 insertions(+) diff --git a/src/dialog/dialog.rs b/src/dialog/dialog.rs index d59152ed..a4b6483e 100644 --- a/src/dialog/dialog.rs +++ b/src/dialog/dialog.rs @@ -1055,6 +1055,7 @@ impl DialogInner { Response { status_code: status, + wire_reason: None, headers: resp_headers, body: body.unwrap_or_default(), version: *request.version(), diff --git a/src/dialog/tests/test_authenticate.rs b/src/dialog/tests/test_authenticate.rs index ab0a9f85..5d50c496 100644 --- a/src/dialog/tests/test_authenticate.rs +++ b/src/dialog/tests/test_authenticate.rs @@ -49,6 +49,7 @@ fn create_request_with_branch(branch: &str) -> Request { fn create_401_response() -> Response { Response { status_code: StatusCode::Unauthorized, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ Via::new("SIP/2.0/UDP alice.example.com:5060;branch=z9hG4bKnashds").into(), @@ -161,6 +162,7 @@ async fn test_authenticate_via_header_branch_update() -> crate::Result<()> { fn create_407_response() -> Response { Response { status_code: StatusCode::ProxyAuthenticationRequired, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ Via::new("SIP/2.0/UDP alice.example.com:5060;branch=z9hG4bKnashds").into(), diff --git a/src/dialog/tests/test_client_dialog.rs b/src/dialog/tests/test_client_dialog.rs index b487a139..39b4c702 100644 --- a/src/dialog/tests/test_client_dialog.rs +++ b/src/dialog/tests/test_client_dialog.rs @@ -169,6 +169,7 @@ async fn test_client_dialog_state_transitions() -> crate::Result<()> { // Transition to Early (after receiving 1xx) let ringing_resp = Response { status_code: StatusCode::Ringing, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ Via::new("SIP/2.0/UDP alice.example.com:5060;branch=z9hG4bKnashds").into(), @@ -422,6 +423,7 @@ async fn test_route_set_updates_from_200_ok_response() -> crate::Result<()> { let success_resp = Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: headers.into(), body: vec![], @@ -517,6 +519,7 @@ async fn test_confirmed_dialog_bye_keeps_contact_uri_with_outbound_route() -> cr let success_resp = Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: headers.into(), body: vec![], @@ -1038,6 +1041,7 @@ async fn test_ack_sent_to_websocket_channel_via_locator() -> crate::Result<()> { let ok_response = Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ invite_req.via_header()?.clone().into(), diff --git a/src/dialog/tests/test_connection_affinity.rs b/src/dialog/tests/test_connection_affinity.rs index 494276c7..6abe5aa8 100644 --- a/src/dialog/tests/test_connection_affinity.rs +++ b/src/dialog/tests/test_connection_affinity.rs @@ -311,6 +311,7 @@ async fn test_server_dialog_bye_is_delivered_over_initial_connection() { // ── complete the handshake: the browser answers 200 OK for our BYE ───── let ok_response = Response { status_code: StatusCode::OK, + wire_reason: None, version: Version::V2, headers: vec![ bye_req.via_header().unwrap().clone().into(), diff --git a/src/dialog/tests/test_dialog_states.rs b/src/dialog/tests/test_dialog_states.rs index f41c639d..018b1dd5 100644 --- a/src/dialog/tests/test_dialog_states.rs +++ b/src/dialog/tests/test_dialog_states.rs @@ -45,6 +45,7 @@ fn create_response(status: StatusCode, from_tag: &str, to_tag: &str, call_id: &s Response { status_code: status, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ Via::new("SIP/2.0/UDP alice.example.com:5060;branch=z9hG4bKnashds").into(), diff --git a/src/dialog/tests/test_session_id.rs b/src/dialog/tests/test_session_id.rs index 7a5effaf..a28b785d 100644 --- a/src/dialog/tests/test_session_id.rs +++ b/src/dialog/tests/test_session_id.rs @@ -179,6 +179,7 @@ async fn client_learns_remote_uuid_from_response() -> crate::Result<()> { let resp_header = make_header("Session-ID", format!("{};remote={}", UUID_B, UUID_A)); let mut resp = crate::sip::Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: Default::default(), body: vec![], @@ -268,6 +269,7 @@ async fn make_ack_swaps_remote_uuid_from_response() -> crate::Result<()> { let resp = crate::sip::Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![ make_header("Session-ID", format!("{};remote={}", UUID_B, UUID_A)), @@ -298,6 +300,7 @@ async fn make_ack_without_session_id_untouched() -> crate::Result<()> { let invite = create_invite_request("alice-tag", "", "ack-test"); let resp = crate::sip::Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: vec![crate::sip::headers::Contact::new("").into()] .into(), @@ -317,6 +320,7 @@ async fn session_id_survives_snapshot_restore() -> crate::Result<()> { let peer = make_header("Session-ID", format!("{};remote={}", UUID_B, UUID_A)); let mut resp = crate::sip::Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: Default::default(), body: vec![], @@ -591,6 +595,7 @@ fn ok_response_with(headers: Vec
) -> crate::sip::Response { headers.push(crate::sip::headers::Contact::new("").into()); crate::sip::Response { status_code: StatusCode::OK, + wire_reason: None, version: crate::sip::Version::V2, headers: headers.into(), body: vec![], diff --git a/src/sip/message.rs b/src/sip/message.rs index 98f39be1..39d95531 100644 --- a/src/sip/message.rs +++ b/src/sip/message.rs @@ -514,6 +514,13 @@ impl core::convert::From for Vec { #[derive(Debug, PartialEq, Eq, Clone)] pub struct Response { pub status_code: StatusCode, + /// The reason phrase a parsed Status-Line carried (trimmed) when it + /// differs from the standard text for its code (e.g. `403 Caller + /// Origination Number is Invalid`); `None` for the standard phrase, an + /// unknown code (kept in [`StatusCode::Other`]) and every locally built + /// response. Never serialized: `Display` and `to_bytes` still write the + /// standard phrase. + pub wire_reason: Option, pub version: Version, pub headers: Headers, pub body: Vec, @@ -587,6 +594,7 @@ impl Default for Response { fn default() -> Self { Response { status_code: StatusCode::OK, + wire_reason: None, version: Version::V2, headers: Headers::default(), body: Vec::new(), diff --git a/src/sip/parser.rs b/src/sip/parser.rs index 5e36942a..618f6dbe 100644 --- a/src/sip/parser.rs +++ b/src/sip/parser.rs @@ -115,15 +115,29 @@ fn parse_response_line(line: &str, headers: Headers, body: Vec) -> Result Option { + if reason.is_empty() + || matches!(status_code, StatusCode::Other(..)) + || reason.eq_ignore_ascii_case(status_code.text()) + { + return None; + } + Some(reason.to_string()) +} + fn find_double_crlf(data: &[u8]) -> Option { memmem::find(data, b"\r\n\r\n") } @@ -195,4 +209,45 @@ mod tests { SipMessage::Response(_) => panic!("expected request"), } } + + fn response(status_line: &str) -> crate::sip::Response { + let raw = format!( + "{status_line}\r\nVia: SIP/2.0/UDP a.example:5060;branch=z9hG4bK-1\r\n\ + From: ;tag=1\r\nTo: ;tag=2\r\n\ + Call-ID: c1\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n" + ); + match SipMessage::try_from(raw.as_str()).unwrap() { + SipMessage::Response(r) => r, + SipMessage::Request(_) => panic!("expected response"), + } + } + + #[test] + fn a_custom_reason_phrase_is_kept_beside_the_standard_code() { + let r = response("SIP/2.0 403 Caller Origination Number is Invalid"); + assert_eq!(r.status_code, crate::sip::StatusCode::Forbidden); + assert_eq!( + r.wire_reason.as_deref(), + Some("Caller Origination Number is Invalid") + ); + // The wire form is unchanged: the standard phrase is still written. + assert!(r.to_string().starts_with("SIP/2.0 403 Forbidden\r\n")); + assert!(r.to_bytes().starts_with(b"SIP/2.0 403 Forbidden\r\n")); + // Surrounding whitespace is trimmed. + let r = response("SIP/2.0 503 \tOverloaded Try Later "); + assert_eq!(r.wire_reason.as_deref(), Some("Overloaded Try Later")); + } + + #[test] + fn the_standard_phrase_or_none_keeps_no_wire_reason() { + assert_eq!(response("SIP/2.0 403 Forbidden").wire_reason, None); + assert_eq!(response("SIP/2.0 486 busy here").wire_reason, None); + assert_eq!(response("SIP/2.0 404").wire_reason, None); + assert_eq!(response("SIP/2.0 404 ").wire_reason, None); + // An unknown code keeps its phrase in StatusCode::Other instead. + let r = response("SIP/2.0 499 Odd Thing"); + assert_eq!(r.wire_reason, None); + assert_eq!(r.status_code.text(), "Odd Thing"); + assert_eq!(crate::sip::Response::default().wire_reason, None); + } } diff --git a/src/transaction/key.rs b/src/transaction/key.rs index fd8e4cc3..21a18f88 100644 --- a/src/transaction/key.rs +++ b/src/transaction/key.rs @@ -125,6 +125,7 @@ fn test_transaction_key() -> Result<()> { ); let register_resp = Response { status_code: StatusCode::OK, + wire_reason: None, version: Version::V2, headers: vec![ Via::new("SIP/2.0/TLS client.sip.restsend.com:5061;branch=z9hG4bKnashd92").into(), diff --git a/src/transaction/message.rs b/src/transaction/message.rs index 45ae3f41..35ae3fbd 100644 --- a/src/transaction/message.rs +++ b/src/transaction/message.rs @@ -246,6 +246,7 @@ impl EndpointInner { headers.unique_push(Header::UserAgent(self.user_agent.clone().into())); Response { status_code, + wire_reason: None, version: *req.version(), headers, body: body.unwrap_or_default(), diff --git a/src/transaction/tests/test_client.rs b/src/transaction/tests/test_client.rs index 64ac6c9f..75e5e0f9 100644 --- a/src/transaction/tests/test_client.rs +++ b/src/transaction/tests/test_client.rs @@ -52,6 +52,7 @@ async fn test_client_transaction() -> Result<()> { let response = SipMessage::Response(crate::sip::message::Response { version: crate::sip::Version::V2, status_code:crate::sip::StatusCode::Trying, + wire_reason: None, headers: headers.clone(), body: Default::default(), }); @@ -61,6 +62,7 @@ async fn test_client_transaction() -> Result<()> { let response = SipMessage::Response(crate::sip::message::Response { version: crate::sip::Version::V2, status_code:crate::sip::StatusCode::OK, + wire_reason: None, headers, body: Default::default(), }); diff --git a/src/transaction/tests/test_provisional_responses.rs b/src/transaction/tests/test_provisional_responses.rs index 39dbfb1f..8f3d65c3 100644 --- a/src/transaction/tests/test_provisional_responses.rs +++ b/src/transaction/tests/test_provisional_responses.rs @@ -39,6 +39,7 @@ async fn test_multiple_provisional_responses() -> crate::Result<()> { let resp1 = Response { version: crate::sip::Version::V2, status_code: StatusCode::SessionProgress, // 183 + wire_reason: None, headers: vec![ Via::new("SIP/2.0/UDP test.example.com:5060;branch=z9hG4bKnashds").into(), CSeq::new("1 INVITE").into(), @@ -73,6 +74,7 @@ async fn test_multiple_provisional_responses() -> crate::Result<()> { let resp2 = Response { version: crate::sip::Version::V2, status_code: StatusCode::SessionProgress, // 183 + wire_reason: None, headers: vec![ Via::new("SIP/2.0/UDP test.example.com:5060;branch=z9hG4bKnashds").into(), CSeq::new("1 INVITE").into(), diff --git a/src/transaction/tests/test_server_invite_drop.rs b/src/transaction/tests/test_server_invite_drop.rs index fc6303bc..3e24d46e 100644 --- a/src/transaction/tests/test_server_invite_drop.rs +++ b/src/transaction/tests/test_server_invite_drop.rs @@ -105,6 +105,7 @@ async fn test_cleanup_server_invite_completed_keeps_waiting_ack() -> crate::Resu // Manually set the fields that would be set during respond() + transition(Completed) let resp = crate::sip::Response { status_code: StatusCode::ServiceUnavailable, + wire_reason: None, version: Version::V2, headers: invite.headers.clone(), body: Default::default(), @@ -158,6 +159,7 @@ async fn test_cleanup_server_invite_terminated_removes_waiting_ack() -> crate::R let resp = crate::sip::Response { status_code: StatusCode::BusyHere, + wire_reason: None, version: Version::V2, headers: invite.headers.clone(), body: Default::default(), @@ -638,6 +640,7 @@ async fn test_cleanup_server_invite_confirmed_drop_removes_waiting_ack() -> crat let resp = crate::sip::Response { status_code: StatusCode::ServiceUnavailable, + wire_reason: None, version: Version::V2, headers: invite.headers.clone(), body: Default::default(), @@ -699,6 +702,7 @@ async fn test_timer_cleanup_removes_orphaned_waiting_ack() -> crate::Result<()> let resp = crate::sip::Response { status_code: StatusCode::ServiceUnavailable, + wire_reason: None, version: Version::V2, headers: invite.headers.clone(), body: Default::default(), diff --git a/src/transport/tests/test_via_received.rs b/src/transport/tests/test_via_received.rs index 7f85eb58..92e426ea 100644 --- a/src/transport/tests/test_via_received.rs +++ b/src/transport/tests/test_via_received.rs @@ -144,6 +144,7 @@ fn test_via_received_ws() { fn test_via_response_not_modified() { let response = crate::sip::message::Response { status_code: crate::sip::StatusCode::try_from(200).unwrap(), + wire_reason: None, headers: vec![Via::new("SIP/2.0/UDP 127.0.0.1:5060;branch=z9hG4bK-test").into()].into(), version: crate::sip::Version::V2, body: Default::default(),