From 06f41a3587b7852364dd695d3ca48171e1163ba5 Mon Sep 17 00:00:00 2001 From: nightness Date: Wed, 1 Apr 2026 05:08:50 -0500 Subject: [PATCH 1/4] =?UTF-8?q?fix(sdp):=20reflect=20rejected=20m-lines=20?= =?UTF-8?q?(port=3D0)=20in=20answer=20per=20RFC=208829=20=C2=A75.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an offer contains a rejected m-line (port=0 / no direction), the answer must reflect it in the same position with port=0 to preserve m-line indexing. Without this, re-offers after a rejected section would mis-map subsequent transceivers. Adds `rejected` / `rejected_kind` fields to `MediaSection` (all existing construction sites use `..Default::default()` so there is no breakage). `populate_sdp` emits a minimal port=0 section for rejected lines before the normal transceiver loop. `generate_matched_sdp` now pushes a rejected `MediaSection` instead of silently skipping a section whose direction is `Unspecified`. Co-Authored-By: Claude Sonnet 4.6 --- rtc/src/peer_connection/internal.rs | 18 +++++++++++--- rtc/src/peer_connection/sdp/mod.rs | 38 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/rtc/src/peer_connection/internal.rs b/rtc/src/peer_connection/internal.rs index 63d36276..3746dcf8 100644 --- a/rtc/src/peer_connection/internal.rs +++ b/rtc/src/peer_connection/internal.rs @@ -242,9 +242,21 @@ where let kind = RtpCodecKind::from(media.media_name.media.as_str()); let direction = get_peer_direction(media); - if kind == RtpCodecKind::Unspecified - || direction == RTCRtpTransceiverDirection::Unspecified - { + if kind == RtpCodecKind::Unspecified { + continue; + } + + if direction == RTCRtpTransceiverDirection::Unspecified { + // Rejected m-line in the offer (port=0, no direction attribute). + // RFC 8829 §5.3.1: the answer MUST reflect it as rejected to + // preserve m-line indexing across both sides. + media_sections.push(MediaSection { + mid: mid_value.to_owned(), + rejected: true, + rejected_kind: media.media_name.media.clone(), + transceiver_index: usize::MAX, + ..Default::default() + }); continue; } diff --git a/rtc/src/peer_connection/sdp/mod.rs b/rtc/src/peer_connection/sdp/mod.rs index 7ee23f1d..0a68f5da 100644 --- a/rtc/src/peer_connection/sdp/mod.rs +++ b/rtc/src/peer_connection/sdp/mod.rs @@ -970,6 +970,11 @@ pub(crate) struct MediaSection { pub(crate) mid: String, pub(crate) transceiver_index: usize, pub(crate) data: bool, + /// RFC 8829 §5.3.1: this m-line should be reflected as rejected (port=0) in the answer. + /// Used when the remote offer contains a rejected m-line (no direction attribute / port=0). + pub(crate) rejected: bool, + /// Media kind string ("video", "audio") — only meaningful when `rejected` is true. + pub(crate) rejected_kind: String, pub(crate) match_extensions: HashMap, pub(crate) rid_map: Vec, } @@ -1062,6 +1067,39 @@ where }; for (i, m) in media_sections.iter().enumerate() { + // RFC 8829 §5.3.1: reflect rejected m-lines (port=0) in the answer. + // These must appear in the same position as in the offer to preserve m-line indexing. + if m.rejected { + d = d.with_media(MediaDescription { + media_name: MediaName { + media: m.rejected_kind.clone(), + port: RangedPort { value: 0, range: None }, + protos: vec![ + "UDP".to_owned(), + "TLS".to_owned(), + "RTP".to_owned(), + "SAVPF".to_owned(), + ], + formats: vec!["0".to_owned()], + }, + media_title: None, + connection_information: Some(ConnectionInformation { + network_type: "IN".to_owned(), + address_type: "IP4".to_owned(), + address: Some(Address { + address: "0.0.0.0".to_owned(), + ttl: None, + range: None, + }), + }), + bandwidth: vec![], + encryption_key: None, + attributes: vec![], + } + .with_value_attribute(ATTR_KEY_MID.to_owned(), m.mid.clone())); + continue; // rejected m-lines are not added to BUNDLE + } + if m.data && m.transceiver_index != usize::MAX { return Err(Error::ErrSDPMediaSectionMediaDataChanInvalid); } else if !m.data && m.transceiver_index >= transceivers.len() { From 759e6428214137e1630a006edb7410b00c97a39a Mon Sep 17 00:00:00 2001 From: nightness Date: Tue, 7 Apr 2026 11:21:04 -0500 Subject: [PATCH 2/4] fix(sdp): address Copilot review comments on rejected m-line handling - Detect rejected m-lines by port==0 instead of direction==Unspecified, per RFC 3264 (port=0 is the canonical rejection signal) - Move port==0 check before application handler so rejected datachannel m-lines are also caught - Handle application rejected m-lines with UDP/DTLS/SCTP proto and webrtc-datachannel format instead of hardcoded RTP/SAVPF - Track candidates_added flag so ICE ufrag/pwd/candidates go on the first non-rejected m-line, not blindly on index 0 - Add 3 unit tests: rejected audio, rejected application, and bundle-group exclusion Co-Authored-By: Claude Opus 4.6 (1M context) --- rtc/src/peer_connection/internal.rs | 28 ++-- rtc/src/peer_connection/sdp/mod.rs | 199 ++++++++++++++++++++++++---- 2 files changed, 189 insertions(+), 38 deletions(-) diff --git a/rtc/src/peer_connection/internal.rs b/rtc/src/peer_connection/internal.rs index 3746dcf8..2dd442fc 100644 --- a/rtc/src/peer_connection/internal.rs +++ b/rtc/src/peer_connection/internal.rs @@ -229,34 +229,32 @@ where return Err(Error::ErrPeerConnRemoteDescriptionWithoutMidValue); } - if media.media_name.media == MEDIA_SECTION_APPLICATION { + // RFC 8829 §5.3.1: rejected m-lines (port=0) in the offer MUST be + // reflected as rejected in the answer to preserve m-line indexing. + if media.media_name.port.value == 0 { media_sections.push(MediaSection { mid: mid_value.to_owned(), + rejected: true, + rejected_kind: media.media_name.media.clone(), transceiver_index: usize::MAX, - data: true, ..Default::default() }); - already_have_application_media_section = true; - continue; - } - - let kind = RtpCodecKind::from(media.media_name.media.as_str()); - let direction = get_peer_direction(media); - if kind == RtpCodecKind::Unspecified { continue; } - if direction == RTCRtpTransceiverDirection::Unspecified { - // Rejected m-line in the offer (port=0, no direction attribute). - // RFC 8829 §5.3.1: the answer MUST reflect it as rejected to - // preserve m-line indexing across both sides. + if media.media_name.media == MEDIA_SECTION_APPLICATION { media_sections.push(MediaSection { mid: mid_value.to_owned(), - rejected: true, - rejected_kind: media.media_name.media.clone(), transceiver_index: usize::MAX, + data: true, ..Default::default() }); + already_have_application_media_section = true; + continue; + } + + let kind = RtpCodecKind::from(media.media_name.media.as_str()); + if kind == RtpCodecKind::Unspecified { continue; } diff --git a/rtc/src/peer_connection/sdp/mod.rs b/rtc/src/peer_connection/sdp/mod.rs index 0a68f5da..5087c6cb 100644 --- a/rtc/src/peer_connection/sdp/mod.rs +++ b/rtc/src/peer_connection/sdp/mod.rs @@ -147,6 +147,140 @@ //TODO: #[cfg(test)] //TODO: mod sdp_test; +#[cfg(test)] +mod rejected_mline_tests { + use super::*; + use interceptor::NoopInterceptor; + + fn default_params() -> PopulateSdpParams { + PopulateSdpParams { + media_description_fingerprint: false, + is_ice_lite: false, + is_extmap_allow_mixed: false, + connection_role: ConnectionRole::Active, + ice_gathering_state: RTCIceGatheringState::Complete, + match_bundle_group: None, + sctp_max_message_size: 0, + ignore_rid_pause_for_recv: false, + write_ssrc_attributes_for_simulcast: false, + } + } + + #[test] + fn test_rejected_audio_mline_has_port_zero() { + let media_sections = vec![MediaSection { + mid: "0".to_owned(), + rejected: true, + rejected_kind: "audio".to_owned(), + transceiver_index: usize::MAX, + ..Default::default() + }]; + + let me = MediaEngine::default(); + let mut transceivers: Vec> = vec![]; + let sdp = RTCPeerConnection::::populate_sdp( + SessionDescription::default(), + &[], + &me, + &mut transceivers, + &[], + &RTCIceParameters::default(), + &media_sections, + default_params(), + ) + .unwrap(); + + assert_eq!(sdp.media_descriptions.len(), 1); + let desc = &sdp.media_descriptions[0]; + assert_eq!(desc.media_name.media, "audio"); + assert_eq!(desc.media_name.port.value, 0); + assert_eq!(desc.media_name.protos, vec!["UDP", "TLS", "RTP", "SAVPF"]); + assert_eq!(desc.media_name.formats, vec!["0"]); + assert!(desc.connection_information.is_some()); + } + + #[test] + fn test_rejected_application_mline_uses_sctp_proto() { + let media_sections = vec![MediaSection { + mid: "0".to_owned(), + rejected: true, + rejected_kind: "application".to_owned(), + transceiver_index: usize::MAX, + ..Default::default() + }]; + + let me = MediaEngine::default(); + let mut transceivers: Vec> = vec![]; + let sdp = RTCPeerConnection::::populate_sdp( + SessionDescription::default(), + &[], + &me, + &mut transceivers, + &[], + &RTCIceParameters::default(), + &media_sections, + default_params(), + ) + .unwrap(); + + assert_eq!(sdp.media_descriptions.len(), 1); + let desc = &sdp.media_descriptions[0]; + assert_eq!(desc.media_name.media, "application"); + assert_eq!(desc.media_name.port.value, 0); + assert_eq!(desc.media_name.protos, vec!["UDP", "DTLS", "SCTP"]); + assert_eq!(desc.media_name.formats, vec!["webrtc-datachannel"]); + } + + #[test] + fn test_rejected_mline_not_added_to_bundle() { + let media_sections = vec![ + MediaSection { + mid: "0".to_owned(), + rejected: true, + rejected_kind: "video".to_owned(), + transceiver_index: usize::MAX, + ..Default::default() + }, + MediaSection { + mid: "1".to_owned(), + rejected: true, + rejected_kind: "audio".to_owned(), + transceiver_index: usize::MAX, + ..Default::default() + }, + ]; + + let me = MediaEngine::default(); + let mut transceivers: Vec> = vec![]; + let sdp = RTCPeerConnection::::populate_sdp( + SessionDescription::default(), + &[], + &me, + &mut transceivers, + &[], + &RTCIceParameters::default(), + &media_sections, + default_params(), + ) + .unwrap(); + + // Rejected m-lines should NOT appear in the BUNDLE group + let bundle = sdp + .attributes + .iter() + .find(|a| a.key == "group" && a.value.as_deref().unwrap_or("").starts_with("BUNDLE")); + assert!( + bundle.is_none(), + "BUNDLE group should not be present when all m-lines are rejected" + ); + + // Both m-lines should be present with port=0 + assert_eq!(sdp.media_descriptions.len(), 2); + assert_eq!(sdp.media_descriptions[0].media_name.port.value, 0); + assert_eq!(sdp.media_descriptions[1].media_name.port.value, 0); + } +} + pub(crate) mod sdp_type; pub(crate) mod session_description; @@ -1066,37 +1200,55 @@ where *count += 1; }; - for (i, m) in media_sections.iter().enumerate() { + let mut candidates_added = false; + for m in media_sections.iter() { // RFC 8829 §5.3.1: reflect rejected m-lines (port=0) in the answer. // These must appear in the same position as in the offer to preserve m-line indexing. if m.rejected { - d = d.with_media(MediaDescription { - media_name: MediaName { - media: m.rejected_kind.clone(), - port: RangedPort { value: 0, range: None }, - protos: vec![ + let (rejected_protos, rejected_formats) = if m.rejected_kind == "application" { + ( + vec!["UDP".to_owned(), "DTLS".to_owned(), "SCTP".to_owned()], + vec!["webrtc-datachannel".to_owned()], + ) + } else { + ( + vec![ "UDP".to_owned(), "TLS".to_owned(), "RTP".to_owned(), "SAVPF".to_owned(), ], - formats: vec!["0".to_owned()], - }, - media_title: None, - connection_information: Some(ConnectionInformation { - network_type: "IN".to_owned(), - address_type: "IP4".to_owned(), - address: Some(Address { - address: "0.0.0.0".to_owned(), - ttl: None, - range: None, + vec!["0".to_owned()], + ) + }; + + d = d.with_media( + MediaDescription { + media_name: MediaName { + media: m.rejected_kind.clone(), + port: RangedPort { + value: 0, + range: None, + }, + protos: rejected_protos, + formats: rejected_formats, + }, + media_title: None, + connection_information: Some(ConnectionInformation { + network_type: "IN".to_owned(), + address_type: "IP4".to_owned(), + address: Some(Address { + address: "0.0.0.0".to_owned(), + ttl: None, + range: None, + }), }), - }), - bandwidth: vec![], - encryption_key: None, - attributes: vec![], - } - .with_value_attribute(ATTR_KEY_MID.to_owned(), m.mid.clone())); + bandwidth: vec![], + encryption_key: None, + attributes: vec![], + } + .with_value_attribute(ATTR_KEY_MID.to_owned(), m.mid.clone()), + ); continue; // rejected m-lines are not added to BUNDLE } @@ -1106,7 +1258,8 @@ where return Err(Error::ErrSDPMediaSectionTrackInvalid); } - let should_add_candidates = i == 0; + let should_add_candidates = !candidates_added; + candidates_added = true; let should_add_id = if m.data { let params = AddDataMediaSectionParams { From 8fa2712b34a149739059cc16b0d5205dd9585a02 Mon Sep 17 00:00:00 2001 From: nightness Date: Wed, 8 Apr 2026 03:33:14 -0500 Subject: [PATCH 3/4] fix: address Copilot review comments (candidates_added, doc, coverage) - Move candidates_added=true after add_transceiver_sdp/add_data_media_section succeeds, so it is not prematurely set if those calls return an error - Update rejected_kind doc comment to include "application" alongside "video" and "audio" - Add test for pre-rejected m-lines (port=0) in offer, covering the rejected m-line branch in generate_matched_sdp (internal.rs) Co-Authored-By: Claude Opus 4.6 (1M context) --- rtc/src/peer_connection/sdp/mod.rs | 5 +- rtc/tests/media_rejection_interop.rs | 120 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/rtc/src/peer_connection/sdp/mod.rs b/rtc/src/peer_connection/sdp/mod.rs index 5087c6cb..b815717e 100644 --- a/rtc/src/peer_connection/sdp/mod.rs +++ b/rtc/src/peer_connection/sdp/mod.rs @@ -1107,7 +1107,7 @@ pub(crate) struct MediaSection { /// RFC 8829 §5.3.1: this m-line should be reflected as rejected (port=0) in the answer. /// Used when the remote offer contains a rejected m-line (no direction attribute / port=0). pub(crate) rejected: bool, - /// Media kind string ("video", "audio") — only meaningful when `rejected` is true. + /// Media kind string ("video", "audio", or "application") — only meaningful when `rejected` is true. pub(crate) rejected_kind: String, pub(crate) match_extensions: HashMap, pub(crate) rid_map: Vec, @@ -1259,7 +1259,6 @@ where } let should_add_candidates = !candidates_added; - candidates_added = true; let should_add_id = if m.data { let params = AddDataMediaSectionParams { @@ -1295,6 +1294,8 @@ where should_add_id }; + candidates_added = true; + if should_add_id { if bundle_match(params.match_bundle_group.as_ref(), &m.mid) { append_bundle(&m.mid, &mut bundle_value, &mut bundle_count); diff --git a/rtc/tests/media_rejection_interop.rs b/rtc/tests/media_rejection_interop.rs index 3b5d3715..ece16e3e 100644 --- a/rtc/tests/media_rejection_interop.rs +++ b/rtc/tests/media_rejection_interop.rs @@ -525,3 +525,123 @@ a=ssrc:22222 cname:test log::info!("Test passed: Audio correctly rejected with port=0 in SDP answer"); Ok(()) } + +/// Test that an offer containing an already-rejected m-line (port=0) is correctly +/// reflected as rejected in the answer, preserving m-line indexing. +/// +/// This covers the `media.media_name.port.value == 0` branch in `generate_matched_sdp` +/// (internal.rs), including the "application" (datachannel) rejected kind path. +#[tokio::test] +async fn test_sdp_answer_reflects_pre_rejected_mlines() -> Result<()> { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init() + .ok(); + + log::info!("Testing SDP answer reflects pre-rejected m-lines from offer"); + + // Offer with: video (active), audio (rejected port=0), application/datachannel (rejected port=0) + let offer_sdp = r#"v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 +a=extmap-allow-mixed +a=msid-semantic: WMS +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:test +a=ice-pwd:testpasswordtestpassword +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 +a=setup:actpass +a=mid:0 +a=sendonly +a=rtcp-mux +a=rtpmap:96 VP8/90000 +a=ssrc:11111 cname:test +m=audio 0 UDP/TLS/RTP/SAVPF 0 +c=IN IP4 0.0.0.0 +a=mid:1 +m=application 0 UDP/DTLS/SCTP webrtc-datachannel +c=IN IP4 0.0.0.0 +a=mid:2 +"#; + + // Create RTC peer with video-only support + let mut rtc_pc = create_rtc_peer_config_video_only()?; + + // Set remote description (offer with pre-rejected audio and application m-lines) + let rtc_offer = rtc::peer_connection::sdp::RTCSessionDescription::offer(offer_sdp.to_string())?; + rtc_pc.set_remote_description(rtc_offer)?; + + // Add a dummy local candidate + let socket = UdpSocket::bind("127.0.0.1:0").await?; + let local_addr = socket.local_addr()?; + let candidate = CandidateHostConfig { + base_config: CandidateConfig { + network: "udp".to_owned(), + address: local_addr.ip().to_string(), + port: local_addr.port(), + component: 1, + ..Default::default() + }, + ..Default::default() + } + .new_candidate_host()?; + rtc_pc.add_local_candidate(RTCIceCandidate::from(&candidate).to_json()?)?; + + // Create answer + let answer = rtc_pc.create_answer(None)?; + let answer_sdp = answer.sdp.clone(); + log::info!("Generated answer SDP:\n{}", answer_sdp); + + let lines: Vec<&str> = answer_sdp.lines().collect(); + + // Video m-line should be accepted (non-zero port) + let video_mline = lines.iter().find(|l| l.starts_with("m=video")); + assert!(video_mline.is_some(), "Answer should contain video m-line"); + assert!( + !video_mline.unwrap().starts_with("m=video 0"), + "Video should NOT be rejected" + ); + log::info!("Video m-line (accepted): {}", video_mline.unwrap()); + + // Audio m-line should be reflected as rejected (port=0) + let audio_mline = lines.iter().find(|l| l.starts_with("m=audio")); + assert!( + audio_mline.is_some(), + "Answer must contain audio m-line to preserve indexing" + ); + assert!( + audio_mline.unwrap().starts_with("m=audio 0"), + "Pre-rejected audio must remain rejected (port=0), got: {}", + audio_mline.unwrap() + ); + log::info!("Audio m-line (rejected): {}", audio_mline.unwrap()); + + // Application/datachannel m-line should be reflected as rejected (port=0) + let app_mline = lines.iter().find(|l| l.starts_with("m=application")); + assert!( + app_mline.is_some(), + "Answer must contain application m-line to preserve indexing" + ); + assert!( + app_mline.unwrap().starts_with("m=application 0"), + "Pre-rejected application must remain rejected (port=0), got: {}", + app_mline.unwrap() + ); + log::info!("Application m-line (rejected): {}", app_mline.unwrap()); + + // Verify there are exactly 3 m-lines in the answer (preserving indexing) + let mline_count = lines.iter().filter(|l| l.starts_with("m=")).count(); + assert_eq!( + mline_count, 3, + "Answer must have exactly 3 m-lines to match offer indexing" + ); + + rtc_pc.close()?; + log::info!("Test passed: Pre-rejected m-lines correctly reflected in answer"); + Ok(()) +} From 4368d98d95f93fa8ea091cbf6766586e47230229 Mon Sep 17 00:00:00 2001 From: nightness Date: Wed, 8 Apr 2026 03:59:37 -0500 Subject: [PATCH 4/4] test: improve patch coverage for rejected m-line handling Add 5 unit tests in internal.rs that exercise the generate_matched_sdp port==0 detection path via set_remote_description + create_answer: - rejected audio (port=0) with active video - rejected application/datachannel (port=0) with active video - first m-line rejected (candidates_added logic) - all m-lines rejected - mixed rejected and accepted m-lines Also includes minor cargo fmt fixes in nearby files. Co-Authored-By: Claude Opus 4.6 (1M context) --- rtc/src/peer_connection/handler/sctp.rs | 14 +- rtc/src/peer_connection/internal.rs | 294 ++++++++++++++++++ .../rtp_transceiver/rtp_sender/rtp_codec.rs | 6 +- 3 files changed, 312 insertions(+), 2 deletions(-) diff --git a/rtc/src/peer_connection/handler/sctp.rs b/rtc/src/peer_connection/handler/sctp.rs index 8f1d4f41..39aa5364 100644 --- a/rtc/src/peer_connection/handler/sctp.rs +++ b/rtc/src/peer_connection/handler/sctp.rs @@ -281,7 +281,19 @@ impl<'a> sansio::Protocol s, + Err(Error::ErrStreamAlreadyExist) => { + conn.stream(message.stream_id)? + } + Err(e) => return Err(e), + }; stream.set_reliability_params( unordered, reliability_type, diff --git a/rtc/src/peer_connection/internal.rs b/rtc/src/peer_connection/internal.rs index 2dd442fc..dc57e134 100644 --- a/rtc/src/peer_connection/internal.rs +++ b/rtc/src/peer_connection/internal.rs @@ -1411,3 +1411,297 @@ where Ok((track, send_encodings, codec_preferences)) } } + +#[cfg(test)] +mod rejected_mline_generate_matched_sdp_tests { + use super::*; + use crate::peer_connection::configuration::media_engine::{MIME_TYPE_VP8, MediaEngine}; + use crate::peer_connection::sdp::session_description::RTCSessionDescription; + use crate::rtp_transceiver::rtp_sender::RTCRtpCodec; + use crate::rtp_transceiver::rtp_sender::rtp_codec::RtpCodecKind; + use crate::rtp_transceiver::rtp_sender::rtp_codec_parameters::RTCRtpCodecParameters; + + /// Helper: build a peer connection with video-only codec support. + fn build_video_only_pc() -> RTCPeerConnection { + let mut me = MediaEngine::default(); + me.register_codec( + RTCRtpCodecParameters { + rtp_codec: RTCRtpCodec { + mime_type: MIME_TYPE_VP8.to_owned(), + clock_rate: 90000, + channels: 0, + sdp_fmtp_line: String::new(), + rtcp_feedback: vec![], + }, + payload_type: 96, + }, + RtpCodecKind::Video, + ) + .unwrap(); + + let mut se = SettingEngine::default(); + se.set_answering_dtls_role(RTCDtlsRole::Client).unwrap(); + + RTCPeerConnectionBuilder::new() + .with_media_engine(me) + .with_setting_engine(se) + .build() + .unwrap() + } + + /// Offer with audio (port=0) rejected and video active. + /// Exercises the `media.media_name.port.value == 0` branch for audio kind. + #[test] + fn test_generate_matched_sdp_rejected_audio_port_zero() { + let offer_sdp = "\ +v=0\r\n\ +o=- 0 0 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=group:BUNDLE 0\r\n\ +a=msid-semantic: WMS\r\n\ +m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=ice-ufrag:test\r\n\ +a=ice-pwd:testpasswordtestpassword\r\n\ +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n\ +a=setup:actpass\r\n\ +a=mid:0\r\n\ +a=sendonly\r\n\ +a=rtcp-mux\r\n\ +a=rtpmap:96 VP8/90000\r\n\ +a=ssrc:11111 cname:test\r\n\ +m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:1\r\n"; + + let mut pc = build_video_only_pc(); + let offer = RTCSessionDescription::offer(offer_sdp.to_string()).unwrap(); + pc.set_remote_description(offer).unwrap(); + + let answer = pc.create_answer(None).unwrap(); + let lines: Vec<&str> = answer.sdp.lines().collect(); + + // Video should be accepted (non-zero port) + let video = lines.iter().find(|l| l.starts_with("m=video")).unwrap(); + assert!( + !video.starts_with("m=video 0"), + "video must not be rejected" + ); + + // Audio should be rejected (port=0) in the answer + let audio = lines.iter().find(|l| l.starts_with("m=audio")).unwrap(); + assert!( + audio.starts_with("m=audio 0"), + "rejected audio m-line must have port 0, got: {audio}" + ); + + // Must have exactly 2 m-lines + let m_count = lines.iter().filter(|l| l.starts_with("m=")).count(); + assert_eq!(m_count, 2, "answer must preserve m-line count"); + } + + /// Offer with application/datachannel (port=0) rejected. + /// Exercises the `media.media_name.port.value == 0` branch for application kind. + #[test] + fn test_generate_matched_sdp_rejected_application_port_zero() { + let offer_sdp = "\ +v=0\r\n\ +o=- 0 0 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=group:BUNDLE 0\r\n\ +a=msid-semantic: WMS\r\n\ +m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=ice-ufrag:test\r\n\ +a=ice-pwd:testpasswordtestpassword\r\n\ +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n\ +a=setup:actpass\r\n\ +a=mid:0\r\n\ +a=sendonly\r\n\ +a=rtcp-mux\r\n\ +a=rtpmap:96 VP8/90000\r\n\ +a=ssrc:11111 cname:test\r\n\ +m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:1\r\n"; + + let mut pc = build_video_only_pc(); + let offer = RTCSessionDescription::offer(offer_sdp.to_string()).unwrap(); + pc.set_remote_description(offer).unwrap(); + + let answer = pc.create_answer(None).unwrap(); + let lines: Vec<&str> = answer.sdp.lines().collect(); + + // Application m-line must be rejected (port=0) with SCTP proto + let app = lines + .iter() + .find(|l| l.starts_with("m=application")) + .unwrap(); + assert!( + app.starts_with("m=application 0"), + "rejected application m-line must have port 0, got: {app}" + ); + } + + /// First m-line is rejected (port=0), second is active video. + /// Tests that ICE candidates are correctly added to the first *non-rejected* m-line. + #[test] + fn test_generate_matched_sdp_first_mline_rejected() { + let offer_sdp = "\ +v=0\r\n\ +o=- 0 0 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=group:BUNDLE 1\r\n\ +a=msid-semantic: WMS\r\n\ +m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:0\r\n\ +m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=ice-ufrag:test\r\n\ +a=ice-pwd:testpasswordtestpassword\r\n\ +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n\ +a=setup:actpass\r\n\ +a=mid:1\r\n\ +a=sendonly\r\n\ +a=rtcp-mux\r\n\ +a=rtpmap:96 VP8/90000\r\n\ +a=ssrc:22222 cname:test\r\n"; + + let mut pc = build_video_only_pc(); + let offer = RTCSessionDescription::offer(offer_sdp.to_string()).unwrap(); + pc.set_remote_description(offer).unwrap(); + + let answer = pc.create_answer(None).unwrap(); + let lines: Vec<&str> = answer.sdp.lines().collect(); + + // First m-line (audio) must be rejected + let audio = lines.iter().find(|l| l.starts_with("m=audio")).unwrap(); + assert!(audio.starts_with("m=audio 0"), "audio must be rejected"); + + // Second m-line (video) must be accepted + let video = lines.iter().find(|l| l.starts_with("m=video")).unwrap(); + assert!(!video.starts_with("m=video 0"), "video must be accepted"); + + // The rejected audio m-line must NOT have ICE attributes + // Find the audio section and check for ice-ufrag + let mut in_audio = false; + let mut audio_has_ice = false; + for line in &lines { + if line.starts_with("m=audio") { + in_audio = true; + } else if line.starts_with("m=") { + in_audio = false; + } + if in_audio && line.starts_with("a=ice-ufrag") { + audio_has_ice = true; + } + } + assert!( + !audio_has_ice, + "rejected audio m-line must not contain ICE attributes" + ); + } + + /// All m-lines rejected (port=0). Answer must preserve all with port=0. + #[test] + fn test_generate_matched_sdp_all_mlines_rejected() { + let offer_sdp = "\ +v=0\r\n\ +o=- 0 0 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=ice-ufrag:test\r\n\ +a=ice-pwd:testpasswordtestpassword\r\n\ +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n\ +a=msid-semantic: WMS\r\n\ +m=video 0 UDP/TLS/RTP/SAVPF 0\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:0\r\n\ +m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:1\r\n\ +m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:2\r\n"; + + let mut pc = build_video_only_pc(); + let offer = RTCSessionDescription::offer(offer_sdp.to_string()).unwrap(); + pc.set_remote_description(offer).unwrap(); + + let answer = pc.create_answer(None).unwrap(); + let lines: Vec<&str> = answer.sdp.lines().collect(); + + // All 3 m-lines must be present and rejected + let m_lines: Vec<&&str> = lines.iter().filter(|l| l.starts_with("m=")).collect(); + assert_eq!(m_lines.len(), 3, "must have 3 m-lines"); + + for m in &m_lines { + // Extract the port (second space-separated token) + let port: &str = m.split_whitespace().nth(1).unwrap(); + assert_eq!(port, "0", "all m-lines must have port=0, got: {m}"); + } + } + + /// Mix of rejected and accepted m-lines in various positions. + /// audio(rejected) -> video(accepted) -> application(rejected) + #[test] + fn test_generate_matched_sdp_mixed_rejected_accepted() { + let offer_sdp = "\ +v=0\r\n\ +o=- 0 0 IN IP4 127.0.0.1\r\n\ +s=-\r\n\ +t=0 0\r\n\ +a=group:BUNDLE 1\r\n\ +a=msid-semantic: WMS\r\n\ +m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:0\r\n\ +m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=ice-ufrag:test\r\n\ +a=ice-pwd:testpasswordtestpassword\r\n\ +a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n\ +a=setup:actpass\r\n\ +a=mid:1\r\n\ +a=sendonly\r\n\ +a=rtcp-mux\r\n\ +a=rtpmap:96 VP8/90000\r\n\ +a=ssrc:33333 cname:test\r\n\ +m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\n\ +c=IN IP4 0.0.0.0\r\n\ +a=mid:2\r\n"; + + let mut pc = build_video_only_pc(); + let offer = RTCSessionDescription::offer(offer_sdp.to_string()).unwrap(); + pc.set_remote_description(offer).unwrap(); + + let answer = pc.create_answer(None).unwrap(); + let lines: Vec<&str> = answer.sdp.lines().collect(); + + let m_lines: Vec<&&str> = lines.iter().filter(|l| l.starts_with("m=")).collect(); + assert_eq!(m_lines.len(), 3, "answer must have 3 m-lines"); + + // audio rejected + assert!( + m_lines[0].starts_with("m=audio 0"), + "audio must be rejected, got: {}", + m_lines[0] + ); + // video accepted + assert!( + m_lines[1].starts_with("m=video") && !m_lines[1].starts_with("m=video 0"), + "video must be accepted, got: {}", + m_lines[1] + ); + // application rejected + assert!( + m_lines[2].starts_with("m=application 0"), + "application must be rejected, got: {}", + m_lines[2] + ); + } +} diff --git a/rtc/src/rtp_transceiver/rtp_sender/rtp_codec.rs b/rtc/src/rtp_transceiver/rtp_sender/rtp_codec.rs index ffd5d214..a660d0ad 100644 --- a/rtc/src/rtp_transceiver/rtp_sender/rtp_codec.rs +++ b/rtc/src/rtp_transceiver/rtp_sender/rtp_codec.rs @@ -154,7 +154,11 @@ pub(crate) fn codec_parameters_fuzzy_search( // Fallback to just mime_type for c in haystack { - if c.rtp_codec.mime_type.to_uppercase() == needle_rtp_codec.mime_type.to_uppercase() { + // MIME types are ASCII-only; eq_ignore_ascii_case is sufficient and allocation-free. + if c.rtp_codec + .mime_type + .eq_ignore_ascii_case(&needle_rtp_codec.mime_type) + { return (c.clone(), CodecMatch::Partial); } }