Skip to content
File

Blob: firmware/vendor/str0m/tests/sdp-negotiation.rs

rust1531 lines
1use std::net::Ipv4Addr;
2use std::time::Instant;
3 
4mod common;
5use common::TestRtc;
6use common::init_crypto_default;
7use common::init_log;
8use common::negotiate;
9use common::progress;
10use common::{extract_sctp_init, remove_sctp_init, replace_sctp_init};
11use str0m::Rtc;
12use str0m::change::SdpOffer;
13use str0m::format::Codec;
14use str0m::format::CodecSpec;
15use str0m::format::FormatParams;
16use str0m::format::PayloadParams;
17use str0m::media::Direction;
18use str0m::media::Frequency;
19use str0m::media::MediaKind;
20use str0m::media::Pt;
21use str0m::rtp::{Extension, ExtensionMap};
22use str0m::{Event, RtcError};
23use tracing::Span;
24use tracing::info_span;
25 
26#[test]
27pub fn change_default_pt() {
28 init_log();
29 init_crypto_default();
30 
31 // First proposed PT is 100, R side adjusts its default from 102 -> 100
32 let (l, r) = with_params(
33 //
34 info_span!("L"),
35 &[opus(100)],
36 info_span!("R"),
37 &[opus(102)],
38 );
39 
40 // Test left side.
41 assert_eq!(&[opus(100)], &**l.codec_config());
42 assert!(l.codec_config()[0]._is_locked());
43 
44 // Test right side.
45 assert_eq!(&[opus(100)], &**r.codec_config());
46 assert!(r.codec_config()[0]._is_locked());
47}
48 
49#[test]
50pub fn g722_negotiates_as_16khz_with_8khz_rtpmap() {
51 init_log();
52 init_crypto_default();
53 
54 // Both sides support G722. It negotiates fine and, internally, is a 16 kHz codec.
55 let (l, r) = with_params(info_span!("L"), &[g722(9)], info_span!("R"), &[g722(9)]);
56 
57 for rtc in [&l, &r] {
58 let p = &rtc.codec_config()[0];
59 assert_eq!(p.spec().codec, Codec::G722);
60 // Internally G722 is treated as a 16 kHz codec.
61 assert_eq!(p.spec().clock_rate, Frequency::SIXTEEN_KHZ);
62 }
63}
64 
65#[test]
66pub fn g722_offer_rtpmap_uses_8khz() {
67 init_log();
68 init_crypto_default();
69 
70 // The SDP a=rtpmap for G722 must use the 8000 Hz RTP clock rate (RFC 3551 §4.5.2),
71 // even though str0m treats G722 as a 16 kHz codec internally. See
72 // https://en.wikipedia.org/wiki/RTP_payload_formats#cite_note-55
73 let mut r = build_params(info_span!("R"), &[g722(9)]);
74 
75 let mut change = r.sdp_api();
76 change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
77 let (offer, _pending) = change.apply().unwrap();
78 
79 let sdp = offer.to_sdp_string();
80 
81 assert!(sdp.contains("a=rtpmap:9 G722/8000"), "SDP was:\n{sdp}");
82 assert!(!sdp.contains("G722/16000"), "SDP was:\n{sdp}");
83}
84 
85#[test]
86pub fn g722_offer_accepts_explicit_mono_channel() {
87 init_log();
88 init_crypto_default();
89 
90 let mut l = build_params(info_span!("L"), &[g722(9)]);
91 let mut r = build_params(info_span!("R"), &[g722(9)]);
92 
93 let mut change = l.sdp_api();
94 change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
95 let (offer, pending) = change.apply().unwrap();
96 let offer_sdp = offer.to_sdp_string().replace("G722/8000", "G722/8000/1");
97 let offer = SdpOffer::from_sdp_string(&offer_sdp).unwrap();
98 
99 let answer = r.rtc.sdp_api().accept_offer(offer).unwrap();
100 let answer_sdp = answer.to_sdp_string();
101 
102 assert!(
103 answer_sdp.contains("a=rtpmap:9 G722/8000"),
104 "SDP was:\n{answer_sdp}"
105 );
106 assert!(!answer_sdp.contains("m=audio 0"), "SDP was:\n{answer_sdp}");
107 
108 l.rtc.sdp_api().accept_answer(pending, answer).unwrap();
109}
110 
111#[test]
112pub fn answer_change_order() {
113 init_log();
114 init_crypto_default();
115 
116 // First proposed PT are 100/102, but R side has a different order.
117 let (l, r) = with_params(
118 //
119 info_span!("L"),
120 &[vp8(100), h264(102)],
121 info_span!("R"),
122 &[h264(96), vp8(98)],
123 );
124 
125 let mid = l._mids()[0];
126 
127 // Test left side.
128 assert_eq!(&[vp8(100), h264(102)], &**l.codec_config());
129 assert!(l.codec_config().iter().all(|p| p._is_locked()));
130 assert_eq!(
131 l.media(mid).unwrap().remote_pts(),
132 // R side has expressed its preference order, but amended the PT to match the OFFER.
133 &[102.into(), 100.into()]
134 );
135 
136 // Test right side.
137 assert_eq!(&[h264(102), vp8(100)], &**r.codec_config());
138 assert!(r.codec_config().iter().all(|p| p._is_locked()));
139 assert_eq!(
140 r.media(mid).unwrap().remote_pts(),
141 // OFFER straight up.
142 &[100.into(), 102.into()]
143 );
144}
145 
146#[test]
147pub fn answer_narrow() {
148 init_log();
149 init_crypto_default();
150 
151 // First proposed PT are 100/102, the R side removes unsupported ones.
152 let (l, r) = with_params(
153 //
154 info_span!("L"),
155 &[vp8(100), h264(102)],
156 info_span!("R"),
157 &[h264(96)],
158 );
159 
160 let mid = l._mids()[0];
161 
162 // Test left side.
163 assert_eq!(&[vp8(100), h264(102)], &**l.codec_config());
164 assert_eq!(
165 l.codec_config()
166 .iter()
167 .map(|p| p._is_locked())
168 .collect::<Vec<_>>(),
169 // VP8 is not locked, H264 is
170 vec![false, true]
171 );
172 assert_eq!(
173 l.media(mid).unwrap().remote_pts(),
174 // R side has narrowed the remote_pts to only the supported.
175 &[102.into()]
176 );
177 
178 // Test right side. The PT of h264 is updated with what L OFFERed.
179 assert_eq!(&[h264(102)], &**r.codec_config());
180 assert!(r.codec_config().iter().all(|p| p._is_locked()));
181 assert_eq!(
182 r.media(mid).unwrap().remote_pts(),
183 // OFFER straight up.
184 &[102.into()]
185 );
186}
187 
188#[test]
189pub fn answer_no_match() {
190 init_log();
191 init_crypto_default();
192 
193 // L has one codec, and that is not matched by R. This should disable the m-line.
194 let mut l = build_params(info_span!("L"), &[vp8(100)]);
195 let mut r = build_params(info_span!("R"), &[h264(96)]);
196 
197 // Create offer from L
198 let (offer, pending) = l.span.in_scope(|| {
199 let mut change = l.rtc.sdp_api();
200 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
201 change.apply().unwrap()
202 });
203 
204 // R accepts the offer and generates an answer
205 let answer = r
206 .span
207 .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap());
208 
209 // Check that the answer SDP has port 0 for the rejected m-line
210 let answer_sdp = answer.to_sdp_string();
211 assert!(
212 answer_sdp.contains("m=video 0 "),
213 "Expected rejected m-line with port 0, got:\n{}",
214 answer_sdp
215 );
216 
217 // Rejected m-line should not be in the BUNDLE group
218 assert!(
219 !answer_sdp.contains("a=group:BUNDLE 0"),
220 "Rejected m-line should not be in BUNDLE group:\n{}",
221 answer_sdp
222 );
223 
224 // L accepts the answer
225 l.span.in_scope(|| {
226 l.rtc.sdp_api().accept_answer(pending, answer).unwrap();
227 });
228 
229 let mid = l._mids()[0];
230 
231 // Test left side. Nothing has changed. The codec is not locked.
232 assert_eq!(&[vp8(100)], &**l.codec_config());
233 assert!(!l.codec_config()[0]._is_locked());
234 // No remote PTs.
235 assert_eq!(l.media(mid).unwrap().remote_pts(), &[]);
236 
237 // After receiving a rejected answer (port=0), direction should be Inactive.
238 assert_eq!(
239 l.media(mid).unwrap().direction(),
240 Direction::Inactive,
241 "Offerer's m-line should be Inactive after receiving rejected answer"
242 );
243 
244 // Test right side. Nothing has changed. The codec is not locked.
245 assert_eq!(&[h264(96)], &**r.codec_config());
246 assert!(!r.codec_config()[0]._is_locked());
247 // No remote PTs.
248 assert_eq!(r.media(mid).unwrap().remote_pts(), &[]);
249}
250 
251#[test]
252pub fn stop_media() {
253 init_log();
254 init_crypto_default();
255 
256 // L and R negotiate a video m-line, then L stops it. The next offer
257 // must emit port 0 and leave the m-line out of the BUNDLE group.
258 let (mut l, mut r) = with_params(
259 //
260 info_span!("L"),
261 &[vp8(100)],
262 info_span!("R"),
263 &[vp8(100)],
264 );
265 
266 let mid = l._mids()[0];
267 
268 assert!(!l.media(mid).unwrap().stopped());
269 assert_eq!(l.media(mid).unwrap().direction(), Direction::SendRecv);
270 
271 // Stop and create a subsequent offer from L
272 let (offer, pending) = l.span.in_scope(|| {
273 let mut change = l.rtc.sdp_api();
274 change.stop_media(mid);
275 change.apply().unwrap()
276 });
277 
278 // Stopping an already-stopped m-line is a no-op
279 l.span.in_scope(|| {
280 let mut change = l.rtc.sdp_api();
281 change.stop_media(mid);
282 assert!(change.apply().is_none());
283 });
284 
285 // Check that the offer SDP has port 0 and drops the mid from BUNDLE.
286 // The PT list is preserved so the m-line satisfies the SDP grammar
287 // (RFC 4566 §5.14 requires at least one fmt).
288 let offer_sdp = offer.to_sdp_string();
289 assert!(
290 offer_sdp.contains("m=video 0 UDP/TLS/RTP/SAVPF 100\r\n"),
291 "Expected stopped m-line with port 0 and PT 100, got:\n{}",
292 offer_sdp
293 );
294 assert!(
295 !offer_sdp.contains(&format!("a=group:BUNDLE {mid}")),
296 "Stopped m-line should not be in BUNDLE group:\n{}",
297 offer_sdp
298 );
299 
300 // Test left side. Stopped, direction Inactive, PTs preserved.
301 assert!(l.media(mid).unwrap().stopped());
302 assert_eq!(l.media(mid).unwrap().direction(), Direction::Inactive);
303 assert_eq!(
304 l.media(mid).unwrap().remote_pts(),
305 &[Pt::new_with_value(100)]
306 );
307 
308 // R accepts the offer and generates an answer
309 let answer = r
310 .span
311 .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap());
312 
313 // The answer also has port 0 (with preserved PT list) for the stopped m-line
314 let answer_sdp = answer.to_sdp_string();
315 assert!(
316 answer_sdp.contains("m=video 0 UDP/TLS/RTP/SAVPF 100\r\n"),
317 "Expected stopped m-line with port 0 and PT 100 in answer, got:\n{}",
318 answer_sdp
319 );
320 
321 // L accepts the answer
322 l.span.in_scope(|| {
323 l.rtc.sdp_api().accept_answer(pending, answer).unwrap();
324 });
325 
326 // Test right side. Same stopped state as L.
327 assert!(r.media(mid).unwrap().stopped());
328 assert_eq!(r.media(mid).unwrap().direction(), Direction::Inactive);
329 assert_eq!(
330 r.media(mid).unwrap().remote_pts(),
331 &[Pt::new_with_value(100)]
332 );
333}
334 
335#[test]
336pub fn answer_different_pt_to_offer() {
337 init_log();
338 init_crypto_default();
339 
340 // This test case checks a scenario happening with Firefox.
341 // 1. SDP -> FF: OFFER to sendonly VP8 PT 96.
342 // 2. FF -> SDP: ANSWER to recvonly VP8 PT 96. (confirming the desired according to spec).
343 // 3. FF -> SDP: OFFER to sendonly VP8 PT 120. This is legal, since PT 120 is just a suggestion.
344 // 4. SDP -> FF: ANSWER <assert> we need to force PT 96 (and not remap to 120).
345 
346 // L has one codec, and that is not matched by R. This should disable the m-line.
347 let (mut l, mut r) = with_params(
348 //
349 info_span!("L"),
350 &[vp8(96)],
351 info_span!("R"),
352 &[vp8(120)],
353 );
354 
355 // Both sides are 96.
356 assert_eq!(&[vp8(96)], &**l.codec_config());
357 assert_eq!(&[vp8(96)], &**r.codec_config());
358 
359 let mut change = r.sdp_api();
360 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
361 let (offer, _pending) = change.apply().unwrap();
362 
363 let sdp = offer.to_sdp_string();
364 
365 let mut split = sdp.split("m=video 9 UDP/TLS/RTP/SAVPF 96");
366 let prelude = split.next().unwrap();
367 let mline_1 = split.next().unwrap();
368 let mline_2 = split.next().unwrap();
369 
370 // m=video 9 UDP/TLS/RTP/SAVPF 96
371 
372 // a=rtpmap:96 VP8/90000
373 // a=rtcp-fb:96 transport-cc
374 // a=rtcp-fb:96 ccm fir
375 // a=rtcp-fb:96 nack
376 // a=rtcp-fb:96 nack pli
377 
378 let mline_2 = mline_2.replace("a=rtpmap:96", "a=rtpmap:120");
379 let mline_2 = mline_2.replace("a=rtcp-fb:96", "a=rtcp-fb:120");
380 
381 let munged = format!(
382 "{}m=video 9 UDP/TLS/RTP/SAVPF 96{}m=video 9 UDP/TLS/RTP/SAVPF 120{}",
383 prelude, mline_1, mline_2
384 );
385 
386 let offer = SdpOffer::from_sdp_string(&munged).unwrap();
387 
388 let answer = l.sdp_api().accept_offer(offer).unwrap();
389 
390 // L remains 96.
391 assert_eq!(&[vp8(96)], &**l.codec_config());
392 
393 let sdp = answer.to_sdp_string();
394 
395 println!("{}", sdp);
396 
397 // All 3 m-lines should be with 96, ignoring the 120.
398 let mut split = sdp.split("m=video 9 UDP/TLS/RTP/SAVPF 96");
399 split.next().expect("SDP answer prelude"); // prelude
400 split.next().expect("First m-line"); // m-line 1
401 split.next().expect("Second m-line"); // m-line 2
402}
403 
404#[test]
405fn answer_remaps() {
406 init_log();
407 init_crypto_default();
408 
409 use Extension::*;
410 
411 let exts_l = ExtensionMap::standard();
412 let mut exts_r = ExtensionMap::empty();
413 
414 // Not same number as the default.
415 exts_r.set(14, TransportSequenceNumber);
416 exts_r.set(12, AudioLevel);
417 
418 // This negotiates a video track.
419 let (l, r) = with_exts(exts_l, exts_r);
420 
421 let v_l: Vec<_> = l._exts().iter_video().collect();
422 let v_r: Vec<_> = r._exts().iter_video().collect();
423 let a_l: Vec<_> = l._exts().iter_audio().collect();
424 let a_r: Vec<_> = r._exts().iter_audio().collect();
425 
426 // L locks 3 and changes it from 14
427 // R keeps 3 and changes it from 14.
428 assert_eq!(
429 v_l,
430 vec![
431 (2, &AbsoluteSendTime),
432 (3, &TransportSequenceNumber),
433 (4, &RtpMid),
434 (10, &RtpStreamId),
435 (11, &RepairedRtpStreamId),
436 (13, &VideoOrientation)
437 ]
438 );
439 assert_eq!(v_r, vec![(3, &TransportSequenceNumber)]);
440 
441 // L audio exts are left untouched (the defaults), also ones shared with video.
442 // R audio exts are left untouched.
443 assert_eq!(
444 a_l,
445 vec![
446 (1, &AudioLevel),
447 (2, &AbsoluteSendTime),
448 (3, &TransportSequenceNumber),
449 (4, &RtpMid),
450 (10, &RtpStreamId),
451 (11, &RepairedRtpStreamId)
452 ]
453 );
454 assert_eq!(a_r, vec![(3, &TransportSequenceNumber), (12, &AudioLevel)]);
455}
456 
457#[test]
458fn offers_unsupported_extension() {
459 init_log();
460 init_crypto_default();
461 
462 use Extension::*;
463 
464 let mut exts_l = ExtensionMap::empty();
465 let mut exts_r = ExtensionMap::empty();
466 
467 // They agree on VideoOrientation, but R has different number
468 // L has introduces an extension R doesn't support.
469 exts_l.set(3, VideoOrientation);
470 exts_l.set(8, ColorSpace);
471 
472 exts_r.set(5, VideoOrientation);
473 
474 // This negotiates a video track.
475 let (l, r) = with_exts(exts_l, exts_r);
476 
477 assert_eq!(
478 l._exts().iter_video().collect::<Vec<_>>(),
479 vec![(3, &VideoOrientation), (8, &ColorSpace)]
480 );
481 assert_eq!(
482 r._exts().iter_video().collect::<Vec<_>>(),
483 vec![(3, &VideoOrientation)]
484 );
485 
486 let mid = l._mids()[0];
487 let m_l = l.media(mid).unwrap();
488 let m_r = r.media(mid).unwrap();
489 
490 // L media did not get R unsupported ext, despite that being configured on session.
491 assert_eq!(
492 m_l.remote_extmap().iter_video().collect::<Vec<_>>(),
493 vec![(3, &VideoOrientation)]
494 );
495 
496 // R media didn't get the unsupported ext.
497 assert_eq!(
498 m_r.remote_extmap().iter_video().collect::<Vec<_>>(),
499 vec![(3, &VideoOrientation)]
500 );
501}
502 
503#[test]
504fn non_media_creator_cannot_change_inactive_to_recvonly() {
505 init_log();
506 init_crypto_default();
507 
508 let now = Instant::now();
509 let (mut l, mut r) = (
510 TestRtc::new_with_rtc(
511 info_span!("L"),
512 Rtc::builder().clear_codecs().enable_vp8(true).build(now),
513 ),
514 TestRtc::new_with_rtc(
515 info_span!("R"),
516 Rtc::builder().clear_codecs().enable_vp8(true).build(now),
517 ),
518 );
519 
520 negotiate(&mut l, &mut r, |change| {
521 change.add_media(MediaKind::Video, Direction::Inactive, None, None, None);
522 });
523 let mid = r._mids()[0];
524 let m_r = r.media(mid).unwrap();
525 assert_eq!(m_r.direction(), Direction::Inactive);
526 
527 negotiate(&mut r, &mut l, |change| {
528 change.set_direction(mid, Direction::RecvOnly);
529 });
530 
531 // r didn't open the media and isn't allowed to change it from inactive to recvonly.
532 let m_r = r.media(mid).unwrap();
533 assert_eq!(m_r.direction(), Direction::Inactive);
534 
535 let m_l = l.media(mid).unwrap();
536 assert_eq!(m_l.direction(), Direction::Inactive);
537}
538 
539#[test]
540fn media_creator_can_change_inactive_to_recvonly() {
541 init_log();
542 init_crypto_default();
543 
544 let now = Instant::now();
545 let (mut l, mut r) = (
546 TestRtc::new_with_rtc(
547 info_span!("L"),
548 Rtc::builder().clear_codecs().enable_vp8(true).build(now),
549 ),
550 TestRtc::new_with_rtc(
551 info_span!("R"),
552 Rtc::builder().clear_codecs().enable_vp8(true).build(now),
553 ),
554 );
555 
556 negotiate(&mut l, &mut r, |change| {
557 change.add_media(MediaKind::Video, Direction::Inactive, None, None, None);
558 });
559 let mid = r._mids()[0];
560 let m_r = r.media(mid).unwrap();
561 assert_eq!(m_r.direction(), Direction::Inactive);
562 
563 negotiate(&mut l, &mut r, |change| {
564 change.set_direction(mid, Direction::RecvOnly);
565 });
566 
567 // r didn't open the media and isn't allowed to change it from inactive to recvonly.
568 let m_l = l.media(mid).unwrap();
569 assert_eq!(m_l.direction(), Direction::RecvOnly);
570 
571 let m_r = r.media(mid).unwrap();
572 assert_eq!(m_r.direction(), Direction::SendOnly);
573}
574 
575/// Test that max-bundle offers (where secondary m-lines have port=0) are handled correctly.
576/// In max-bundle format (RFC 8843), m-lines after the first one use port=0 to indicate
577/// they share transport with the first m-line. This is NOT a rejection.
578#[test]
579fn max_bundle_offer_accepted() {
580 init_log();
581 init_crypto_default();
582 
583 // Create an Rtc that supports both opus and VP8
584 let mut r = TestRtc::new_with_rtc(
585 info_span!("R"),
586 Rtc::builder()
587 .clear_codecs()
588 .enable_opus(true)
589 .enable_vp8(true)
590 .build(Instant::now()),
591 );
592 
593 // This is a max-bundle offer where the video m-line has port=0
594 // to indicate it shares transport with the audio m-line.
595 let max_bundle_offer = "\
596 v=0\r\n\
597 o=- 123456789 2 IN IP4 127.0.0.1\r\n\
598 s=-\r\n\
599 t=0 0\r\n\
600 a=group:BUNDLE 0 1\r\n\
601 a=msid-semantic:WMS *\r\n\
602 a=fingerprint:sha-256 00:00:00:00:00:00:00:00\
603 :00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\
604 :00:00:00:00:00:00:00:00:00\r\n\
605 a=ice-ufrag:testufrag\r\n\
606 a=ice-pwd:testpassword12345678\r\n\
607 a=setup:actpass\r\n\
608 m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
609 c=IN IP4 0.0.0.0\r\n\
610 a=mid:0\r\n\
611 a=sendrecv\r\n\
612 a=rtcp-mux\r\n\
613 a=rtpmap:111 opus/48000/2\r\n\
614 a=fmtp:111 minptime=10;useinbandfec=1\r\n\
615 m=video 0 UDP/TLS/RTP/SAVPF 96\r\n\
616 c=IN IP4 0.0.0.0\r\n\
617 a=mid:1\r\n\
618 a=sendrecv\r\n\
619 a=rtcp-mux\r\n\
620 a=rtpmap:96 VP8/90000\r\n\
621 ";
622 
623 // Parse and accept the max-bundle offer
624 let offer = SdpOffer::from_sdp_string(max_bundle_offer).expect("should parse");
625 let answer = r.rtc.sdp_api().accept_offer(offer).expect("should accept");
626 
627 // Verify the answer includes both m-lines in the BUNDLE group
628 let answer_sdp = answer.to_sdp_string();
629 assert!(
630 answer_sdp.contains("a=group:BUNDLE 0 1"),
631 "Answer should have both MIDs in BUNDLE group, got:\n{}",
632 answer_sdp
633 );
634 
635 // Both m-lines should be active (not rejected)
636 let mids = r._mids();
637 assert_eq!(mids.len(), 2, "Should have 2 media lines");
638 
639 let audio = r.media(mids[0]).unwrap();
640 assert_eq!(
641 audio.direction(),
642 Direction::SendRecv,
643 "Audio should be SendRecv, not disabled"
644 );
645 
646 let video = r.media(mids[1]).unwrap();
647 assert_eq!(
648 video.direction(),
649 Direction::SendRecv,
650 "Video should be SendRecv even though offer had port=0 (max-bundle)"
651 );
652}
653 
654/// A remote offer advertising a codec at a payload type outside the 7-bit RTP range must be
655/// rejected, not panic. Before the fix the out-of-range PT was claimed against a 128-entry table
656/// and indexed out of bounds; now the malformed `a=rtpmap` is ignored, leaving the m-line PT
657/// without a codec, so the offer is rejected at parse instead.
658#[test]
659fn offer_with_out_of_range_pt_is_rejected() {
660 init_log();
661 init_crypto_default();
662 
663 let offer = "\
664 v=0\r\n\
665 o=- 123456789 2 IN IP4 127.0.0.1\r\n\
666 s=-\r\n\
667 t=0 0\r\n\
668 a=group:BUNDLE 0\r\n\
669 a=msid-semantic:WMS *\r\n\
670 a=fingerprint:sha-256 00:00:00:00:00:00:00:00\
671 :00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\
672 :00:00:00:00:00:00:00:00:00\r\n\
673 a=ice-ufrag:testufrag\r\n\
674 a=ice-pwd:testpassword12345678\r\n\
675 a=setup:actpass\r\n\
676 m=audio 9 UDP/TLS/RTP/SAVPF 200\r\n\
677 c=IN IP4 0.0.0.0\r\n\
678 a=mid:0\r\n\
679 a=sendrecv\r\n\
680 a=rtcp-mux\r\n\
681 a=rtpmap:200 opus/48000/2\r\n\
682 ";
683 
684 // Before the fix this parsed and then panicked when the PT was claimed; now it is rejected.
685 let result = SdpOffer::from_sdp_string(offer);
686 assert!(
687 result.is_err(),
688 "offer with out-of-range PT must be rejected: {result:?}"
689 );
690}
691 
692/// Regression test for issue #1015: an H.264 offer whose level exceeds the
693/// locally configured level must still negotiate. RFC 6184 §8.2.2 makes the
694/// level a negotiable (downgradable) parameter, so a remote `42e034`
695/// (Constrained Baseline, level 5.2) must match local `42e01f` (level 3.1)
696/// rather than reject the whole m-line; the Constrained High profile
697/// (`640c34`) must also parse instead of dropping the payload.
698#[test]
699fn h264_offer_with_higher_level_negotiates() {
700 init_log();
701 init_crypto_default();
702 
703 // Only H.264 is enabled, so the video m-line survives *only* if an H.264
704 // payload negotiates. str0m's default H.264 config is at level 3.1.
705 let mut r = TestRtc::new_with_rtc(
706 info_span!("R"),
707 Rtc::builder()
708 .clear_codecs()
709 .enable_h264(true)
710 .build(Instant::now()),
711 );
712 
713 // A remote offer (taken verbatim from Safari/WebKit) advertising
714 // Constrained High (640c34) and Constrained Baseline (42e034), both at
715 // level 5.2, in packetization modes 1 and 0.
716 let remote_offer = "\
717 v=0\r\n\
718 o=- 123456789 2 IN IP4 127.0.0.1\r\n\
719 s=-\r\n\
720 t=0 0\r\n\
721 a=group:BUNDLE 0\r\n\
722 a=msid-semantic:WMS *\r\n\
723 a=fingerprint:sha-256 00:00:00:00:00:00:00:00\
724 :00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\
725 :00:00:00:00:00:00:00:00:00\r\n\
726 a=ice-ufrag:testufrag\r\n\
727 a=ice-pwd:testpassword12345678\r\n\
728 a=setup:actpass\r\n\
729 m=video 9 UDP/TLS/RTP/SAVPF 96 98 100 102\r\n\
730 c=IN IP4 0.0.0.0\r\n\
731 a=mid:0\r\n\
732 a=sendrecv\r\n\
733 a=rtcp-mux\r\n\
734 a=rtpmap:96 H264/90000\r\n\
735 a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c34\r\n\
736 a=rtpmap:98 H264/90000\r\n\
737 a=fmtp:98 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=640c34\r\n\
738 a=rtpmap:100 H264/90000\r\n\
739 a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e034\r\n\
740 a=rtpmap:102 H264/90000\r\n\
741 a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e034\r\n\
742 ";
743 
744 let offer = SdpOffer::from_sdp_string(remote_offer).expect("offer should parse");
745 let answer = r
746 .span
747 .in_scope(|| r.rtc.sdp_api().accept_offer(offer).expect("should accept"));
748 
749 let answer_sdp = answer.to_sdp_string();
750 
751 // The video m-line must NOT be rejected (port 0). No local H.264 payload
752 // matches unless the level-5.2 offer is accepted against local level 3.1.
753 assert!(
754 !answer_sdp.contains("m=video 0 "),
755 "H.264 offer should negotiate, not reject the m-line:\n{answer_sdp}"
756 );
757 
758 // The negotiated payload is str0m's Constrained Baseline (42e01f), matched
759 // against the remote's 42e034 with the level difference only penalizing the
760 // score per RFC 6184 §8.2.2.
761 assert!(
762 answer_sdp.contains("profile-level-id=42e01f"),
763 "Answer should negotiate Constrained Baseline (42e01f):\n{answer_sdp}"
764 );
765 
766 // The answerer receives, so it adopts the remote's payload types. The
767 // Constrained Baseline packetization-mode=1 payload is PT 100 in the offer.
768 assert!(
769 answer_sdp.contains("a=rtpmap:100 H264/90000"),
770 "Answer should adopt the remote's Constrained Baseline PT 100:\n{answer_sdp}"
771 );
772 
773 // The m-line stays active on the receiving side.
774 let mid = r._mids()[0];
775 assert_eq!(
776 r.media(mid).unwrap().direction(),
777 Direction::SendRecv,
778 "Video should stay active after negotiating the H.264 offer"
779 );
780}
781 
782// ---------------------------------------------------------------------------
783// SNAP (SCTP Negotiation Acceleration Protocol) tests
784// ---------------------------------------------------------------------------
785 
786/// Both sides enable SNAP - offer and answer must contain `a=sctp-init`,
787/// and a data-channel message round-trips successfully.
788#[test]
789fn snap_sdp_both_sides_enabled() -> Result<(), RtcError> {
790 init_log();
791 init_crypto_default();
792 
793 let now = Instant::now();
794 let mut l = TestRtc::new_with_rtc(
795 info_span!("L"),
796 Rtc::builder().set_snap_enabled(true).build(now),
797 );
798 let mut r = TestRtc::new_with_rtc(
799 info_span!("R"),
800 Rtc::builder().set_snap_enabled(true).build(now),
801 );
802 
803 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
804 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
805 
806 // Manual offer/answer so we can inspect the SDP.
807 let mut change = l.sdp_api();
808 let cid = change.add_channel("snap-test".into());
809 let (offer, pending) = change.apply().unwrap();
810 
811 let offer_sdp = offer.to_sdp_string();
812 assert!(
813 extract_sctp_init(&offer_sdp).is_some(),
814 "Offer must contain a=sctp-init when SNAP is enabled"
815 );
816 
817 let answer = r.rtc.sdp_api().accept_offer(offer)?;
818 let answer_sdp = answer.to_sdp_string();
819 assert!(
820 extract_sctp_init(&answer_sdp).is_some(),
821 "Answer must contain a=sctp-init when both sides enable SNAP"
822 );
823 
824 l.rtc.sdp_api().accept_answer(pending, answer)?;
825 
826 // Drive to connected.
827 for _ in 0..1000 {
828 if l.is_connected() && r.is_connected() {
829 break;
830 }
831 progress(&mut l, &mut r)?;
832 }
833 
834 // Send a data-channel message and verify receipt.
835 l.channel(cid).unwrap().write(false, b"hello-snap").unwrap();
836 
837 for _ in 0..200 {
838 progress(&mut l, &mut r)?;
839 }
840 
841 let received = r
842 .events
843 .iter()
844 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"hello-snap"));
845 assert!(received, "Right side must receive data-channel message");
846 
847 Ok(())
848}
849 
850/// Only the offerer enables SNAP. The answerer should reciprocate by including
851/// `a=sctp-init` in its answer (per the draft, an endpoint that receives
852/// `a=sctp-init` should respond with one).
853#[test]
854fn snap_sdp_offerer_only_enabled() -> Result<(), RtcError> {
855 init_log();
856 init_crypto_default();
857 
858 let now = Instant::now();
859 let mut l = TestRtc::new_with_rtc(
860 info_span!("L"),
861 Rtc::builder().set_snap_enabled(true).build(now),
862 );
863 let mut r = TestRtc::new_with_rtc(
864 info_span!("R"),
865 Rtc::builder().build(now), // SNAP not explicitly enabled
866 );
867 
868 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
869 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
870 
871 let mut change = l.sdp_api();
872 let cid = change.add_channel("snap-offerer-only".into());
873 let (offer, pending) = change.apply().unwrap();
874 
875 let offer_sdp = offer.to_sdp_string();
876 assert!(
877 extract_sctp_init(&offer_sdp).is_some(),
878 "Offer must contain a=sctp-init"
879 );
880 
881 let answer = r.rtc.sdp_api().accept_offer(offer)?;
882 let answer_sdp = answer.to_sdp_string();
883 assert!(
884 extract_sctp_init(&answer_sdp).is_some(),
885 "Answerer should reciprocate a=sctp-init even without snap_enabled"
886 );
887 
888 l.rtc.sdp_api().accept_answer(pending, answer)?;
889 
890 // Drive to connected and verify data channel works.
891 for _ in 0..1000 {
892 if l.is_connected() && r.is_connected() {
893 break;
894 }
895 progress(&mut l, &mut r)?;
896 }
897 
898 l.channel(cid)
899 .unwrap()
900 .write(false, b"offerer-snap")
901 .unwrap();
902 
903 for _ in 0..200 {
904 progress(&mut l, &mut r)?;
905 }
906 
907 let received = r
908 .events
909 .iter()
910 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"offerer-snap"));
911 assert!(received, "Data channel must work with offerer-only SNAP");
912 
913 Ok(())
914}
915 
916#[test]
917fn snap_sdp_missing_answer_falls_back_to_regular_handshake() -> Result<(), RtcError> {
918 init_log();
919 init_crypto_default();
920 
921 let now = Instant::now();
922 let mut l = TestRtc::new_with_rtc(
923 info_span!("L"),
924 Rtc::builder().set_snap_enabled(true).build(now),
925 );
926 let mut r = TestRtc::new_with_rtc(
927 info_span!("R"),
928 Rtc::builder().set_snap_enabled(true).build(now),
929 );
930 
931 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
932 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
933 
934 let mut change = l.sdp_api();
935 let cid = change.add_channel("snap-fallback".into());
936 let (offer, pending) = change.apply().unwrap();
937 let offer_sdp = offer.to_sdp_string();
938 
939 assert!(
940 extract_sctp_init(&offer_sdp).is_some(),
941 "Offer must contain a=sctp-init"
942 );
943 
944 let stripped_offer = remove_sctp_init(&offer_sdp);
945 let stripped_offer = SdpOffer::from_sdp_string(&stripped_offer).unwrap();
946 
947 let answer = r.rtc.sdp_api().accept_offer(stripped_offer)?;
948 let answer_sdp = answer.to_sdp_string();
949 assert!(
950 extract_sctp_init(&answer_sdp).is_none(),
951 "Answer must omit a=sctp-init when the remote does not negotiate SNAP"
952 );
953 
954 l.rtc.sdp_api().accept_answer(pending, answer)?;
955 
956 let mut ready = false;
957 for _ in 0..200 {
958 if l.is_connected() && r.is_connected() && l.channel(cid).is_some() {
959 ready = true;
960 break;
961 }
962 progress(&mut l, &mut r)?;
963 }
964 
965 assert!(
966 ready,
967 "Fallback path must establish both peers and the local data channel"
968 );
969 
970 l.channel(cid)
971 .expect("local channel to exist after fallback")
972 .write(false, b"snap-fallback-msg")
973 .unwrap();
974 
975 for _ in 0..200 {
976 progress(&mut l, &mut r)?;
977 }
978 
979 let received = r
980 .events
981 .iter()
982 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"snap-fallback-msg"));
983 assert!(
984 received,
985 "Data channel must work after SNAP falls back to regular SCTP"
986 );
987 
988 Ok(())
989}
990 
991/// Neither side enables SNAP - SDP must NOT contain `a=sctp-init`, and the
992/// normal SCTP 4-way handshake is used instead.
993#[test]
994fn snap_sdp_neither_enabled() -> Result<(), RtcError> {
995 init_log();
996 init_crypto_default();
997 
998 let now = Instant::now();
999 let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::builder().build(now));
1000 let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::builder().build(now));
1001 
1002 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1003 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1004 
1005 let mut change = l.sdp_api();
1006 let cid = change.add_channel("no-snap".into());
1007 let (offer, pending) = change.apply().unwrap();
1008 
1009 let offer_sdp = offer.to_sdp_string();
1010 assert!(
1011 extract_sctp_init(&offer_sdp).is_none(),
1012 "Offer must NOT contain a=sctp-init when SNAP is disabled"
1013 );
1014 
1015 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1016 let answer_sdp = answer.to_sdp_string();
1017 assert!(
1018 extract_sctp_init(&answer_sdp).is_none(),
1019 "Answer must NOT contain a=sctp-init when SNAP is disabled"
1020 );
1021 
1022 l.rtc.sdp_api().accept_answer(pending, answer)?;
1023 
1024 // Normal handshake should still work.
1025 for _ in 0..1000 {
1026 if l.is_connected() && r.is_connected() {
1027 break;
1028 }
1029 progress(&mut l, &mut r)?;
1030 }
1031 
1032 l.channel(cid)
1033 .unwrap()
1034 .write(false, b"no-snap-msg")
1035 .unwrap();
1036 
1037 for _ in 0..200 {
1038 progress(&mut l, &mut r)?;
1039 }
1040 
1041 let received = r
1042 .events
1043 .iter()
1044 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"no-snap-msg"));
1045 assert!(received, "Data channel must work without SNAP");
1046 
1047 Ok(())
1048}
1049 
1050/// Section 5.6: Re-offer after SNAP establishment must re-send the same `a=sctp-init`
1051/// values. Verify that a re-negotiation preserves the cached `sctp-init` and
1052/// the data channel keeps working.
1053#[test]
1054fn snap_sdp_renegotiation_preserves_sctp_init() -> Result<(), RtcError> {
1055 init_log();
1056 init_crypto_default();
1057 
1058 let now = Instant::now();
1059 let mut l = TestRtc::new_with_rtc(
1060 info_span!("L"),
1061 Rtc::builder().set_snap_enabled(true).build(now),
1062 );
1063 let mut r = TestRtc::new_with_rtc(
1064 info_span!("R"),
1065 Rtc::builder().set_snap_enabled(true).build(now),
1066 );
1067 
1068 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1069 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1070 
1071 // Initial offer/answer with SNAP.
1072 let mut change = l.sdp_api();
1073 let cid = change.add_channel("snap-renego".into());
1074 let (offer, pending) = change.apply().unwrap();
1075 
1076 let initial_offer_init =
1077 extract_sctp_init(&offer.to_sdp_string()).expect("Initial offer must contain a=sctp-init");
1078 
1079 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1080 let initial_answer_init = extract_sctp_init(&answer.to_sdp_string())
1081 .expect("Initial answer must contain a=sctp-init");
1082 
1083 l.rtc.sdp_api().accept_answer(pending, answer)?;
1084 
1085 // Drive to connected.
1086 for _ in 0..1000 {
1087 if l.is_connected() && r.is_connected() {
1088 break;
1089 }
1090 progress(&mut l, &mut r)?;
1091 }
1092 
1093 // Send a message before re-negotiation.
1094 l.channel(cid)
1095 .unwrap()
1096 .write(false, b"before-renego")
1097 .unwrap();
1098 for _ in 0..100 {
1099 progress(&mut l, &mut r)?;
1100 }
1101 
1102 // Re-offer: add a video track to trigger re-negotiation.
1103 let mut change = l.sdp_api();
1104 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
1105 let (re_offer, re_pending) = change.apply().unwrap();
1106 
1107 let re_offer_init = extract_sctp_init(&re_offer.to_sdp_string());
1108 assert_eq!(
1109 re_offer_init.as_deref(),
1110 Some(initial_offer_init.as_str()),
1111 "Re-offer must contain the same a=sctp-init as the initial offer (Section 5.6)"
1112 );
1113 
1114 let re_answer = r.rtc.sdp_api().accept_offer(re_offer)?;
1115 let re_answer_init = extract_sctp_init(&re_answer.to_sdp_string());
1116 assert_eq!(
1117 re_answer_init.as_deref(),
1118 Some(initial_answer_init.as_str()),
1119 "Re-answer must contain the same a=sctp-init as the initial answer (Section 5.6)"
1120 );
1121 
1122 l.rtc.sdp_api().accept_answer(re_pending, re_answer)?;
1123 
1124 // Verify data channel still works after re-negotiation.
1125 l.channel(cid)
1126 .unwrap()
1127 .write(false, b"after-renego")
1128 .unwrap();
1129 for _ in 0..200 {
1130 progress(&mut l, &mut r)?;
1131 }
1132 
1133 let received_after = r
1134 .events
1135 .iter()
1136 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"after-renego"));
1137 assert!(
1138 received_after,
1139 "Data channel must survive re-negotiation with SNAP"
1140 );
1141 
1142 Ok(())
1143}
1144 
1145#[test]
1146fn snap_sdp_renegotiation_changed_sctp_init_rejected() -> Result<(), RtcError> {
1147 init_log();
1148 init_crypto_default();
1149 
1150 let now = Instant::now();
1151 let mut l = TestRtc::new_with_rtc(
1152 info_span!("L"),
1153 Rtc::builder().set_snap_enabled(true).build(now),
1154 );
1155 let mut r = TestRtc::new_with_rtc(
1156 info_span!("R"),
1157 Rtc::builder().set_snap_enabled(true).build(now),
1158 );
1159 
1160 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1161 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1162 
1163 let mut change = l.sdp_api();
1164 change.add_channel("snap-change-reject".into());
1165 let (offer, pending) = change.apply().unwrap();
1166 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1167 l.rtc.sdp_api().accept_answer(pending, answer)?;
1168 
1169 for _ in 0..1000 {
1170 if l.is_connected() && r.is_connected() {
1171 break;
1172 }
1173 progress(&mut l, &mut r)?;
1174 }
1175 
1176 let mut change = l.sdp_api();
1177 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
1178 let (re_offer, _re_pending) = change.apply().unwrap();
1179 
1180 let re_offer_sdp = re_offer.to_sdp_string();
1181 let tampered = replace_sctp_init(&re_offer_sdp, "AQ==");
1182 let tampered_offer = SdpOffer::from_sdp_string(&tampered).unwrap();
1183 
1184 let err = r.rtc.sdp_api().accept_offer(tampered_offer).unwrap_err();
1185 assert!(
1186 err.to_string()
1187 .contains("Changed a=sctp-init for existing SCTP association"),
1188 "unexpected error: {err}"
1189 );
1190 
1191 Ok(())
1192}
1193 
1194#[test]
1195fn snap_sdp_renegotiation_missing_sctp_init_rejected() -> Result<(), RtcError> {
1196 init_log();
1197 init_crypto_default();
1198 
1199 let now = Instant::now();
1200 let mut l = TestRtc::new_with_rtc(
1201 info_span!("L"),
1202 Rtc::builder().set_snap_enabled(true).build(now),
1203 );
1204 let mut r = TestRtc::new_with_rtc(
1205 info_span!("R"),
1206 Rtc::builder().set_snap_enabled(true).build(now),
1207 );
1208 
1209 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1210 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1211 
1212 let mut change = l.sdp_api();
1213 change.add_channel("snap-missing-reject".into());
1214 let (offer, pending) = change.apply().unwrap();
1215 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1216 l.rtc.sdp_api().accept_answer(pending, answer)?;
1217 
1218 for _ in 0..1000 {
1219 if l.is_connected() && r.is_connected() {
1220 break;
1221 }
1222 progress(&mut l, &mut r)?;
1223 }
1224 
1225 let mut change = l.sdp_api();
1226 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
1227 let (re_offer, re_pending) = change.apply().unwrap();
1228 
1229 let re_answer = r.rtc.sdp_api().accept_offer(re_offer)?;
1230 let re_answer_sdp = re_answer.to_sdp_string();
1231 let tampered = remove_sctp_init(&re_answer_sdp);
1232 let tampered_answer = str0m::change::SdpAnswer::from_sdp_string(&tampered).unwrap();
1233 
1234 let err = l
1235 .rtc
1236 .sdp_api()
1237 .accept_answer(re_pending, tampered_answer)
1238 .unwrap_err();
1239 assert!(
1240 err.to_string()
1241 .contains("Missing a=sctp-init for established SNAP SCTP association"),
1242 "unexpected error: {err}"
1243 );
1244 
1245 Ok(())
1246}
1247 
1248/// Only the answerer enables SNAP - the offer has no `a=sctp-init`, so the
1249/// answerer must NOT inject one either. Normal 4-way handshake is used.
1250#[test]
1251fn snap_sdp_answerer_only_enabled() -> Result<(), RtcError> {
1252 init_log();
1253 init_crypto_default();
1254 
1255 let now = Instant::now();
1256 let mut l = TestRtc::new_with_rtc(
1257 info_span!("L"),
1258 Rtc::builder().build(now), // SNAP disabled
1259 );
1260 let mut r = TestRtc::new_with_rtc(
1261 info_span!("R"),
1262 Rtc::builder().set_snap_enabled(true).build(now), // SNAP enabled
1263 );
1264 
1265 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1266 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1267 
1268 let mut change = l.sdp_api();
1269 let cid = change.add_channel("answerer-only".into());
1270 let (offer, pending) = change.apply().unwrap();
1271 
1272 let offer_sdp = offer.to_sdp_string();
1273 assert!(
1274 extract_sctp_init(&offer_sdp).is_none(),
1275 "Offer must NOT contain a=sctp-init when offerer has SNAP disabled"
1276 );
1277 
1278 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1279 let answer_sdp = answer.to_sdp_string();
1280 assert!(
1281 extract_sctp_init(&answer_sdp).is_none(),
1282 "Answer must NOT contain a=sctp-init when the offer didn't include one"
1283 );
1284 
1285 l.rtc.sdp_api().accept_answer(pending, answer)?;
1286 
1287 // Normal handshake should work.
1288 for _ in 0..1000 {
1289 if l.is_connected() && r.is_connected() {
1290 break;
1291 }
1292 progress(&mut l, &mut r)?;
1293 }
1294 
1295 l.channel(cid)
1296 .unwrap()
1297 .write(false, b"answerer-only-msg")
1298 .unwrap();
1299 
1300 for _ in 0..200 {
1301 progress(&mut l, &mut r)?;
1302 }
1303 
1304 let received = r
1305 .events
1306 .iter()
1307 .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"answerer-only-msg"));
1308 assert!(
1309 received,
1310 "Data channel must work when only answerer has SNAP enabled"
1311 );
1312 
1313 Ok(())
1314}
1315 
1316/// Malformed base64 in `a=sctp-init` is silently ignored by the SDP parser
1317/// (treated as an unknown attribute). The answerer should proceed without SNAP.
1318#[test]
1319fn snap_sdp_malformed_base64_ignored() -> Result<(), RtcError> {
1320 init_log();
1321 init_crypto_default();
1322 
1323 let now = Instant::now();
1324 let mut l = TestRtc::new_with_rtc(
1325 info_span!("L"),
1326 Rtc::builder().set_snap_enabled(true).build(now),
1327 );
1328 let mut r = TestRtc::new_with_rtc(
1329 info_span!("R"),
1330 Rtc::builder().set_snap_enabled(true).build(now),
1331 );
1332 
1333 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1334 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1335 
1336 let mut change = l.sdp_api();
1337 let _cid = change.add_channel("bad-b64".into());
1338 let (offer, _pending) = change.apply().unwrap();
1339 
1340 let offer_sdp = offer.to_sdp_string();
1341 // Replace the valid base64 with garbage - it's parsed as a string, but fails
1342 // to decode at runtime, causing SNAP to gracefully degrade.
1343 let tampered = replace_sctp_init(&offer_sdp, "!!!not-valid-base64!!!");
1344 let tampered_offer = SdpOffer::from_sdp_string(&tampered).unwrap();
1345 
1346 // The answerer should NOT reciprocate since the sctp-init failed to decode.
1347 let answer = r.rtc.sdp_api().accept_offer(tampered_offer)?;
1348 let answer_sdp = answer.to_sdp_string();
1349 assert!(
1350 extract_sctp_init(&answer_sdp).is_none(),
1351 "Answer must NOT contain a=sctp-init when the offer's was malformed"
1352 );
1353 
1354 Ok(())
1355}
1356 
1357/// Reverse direction: R re-offers with a tampered `a=sctp-init` and L
1358/// (the original offerer) rejects it when processing the answer.
1359#[test]
1360fn snap_sdp_renegotiation_reverse_direction_changed_sctp_init_rejected() -> Result<(), RtcError> {
1361 init_log();
1362 init_crypto_default();
1363 
1364 let now = Instant::now();
1365 let mut l = TestRtc::new_with_rtc(
1366 info_span!("L"),
1367 Rtc::builder().set_snap_enabled(true).build(now),
1368 );
1369 let mut r = TestRtc::new_with_rtc(
1370 info_span!("R"),
1371 Rtc::builder().set_snap_enabled(true).build(now),
1372 );
1373 
1374 l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
1375 r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
1376 
1377 // Initial offer/answer from L -> R.
1378 let mut change = l.sdp_api();
1379 change.add_channel("snap-reverse".into());
1380 let (offer, pending) = change.apply().unwrap();
1381 let answer = r.rtc.sdp_api().accept_offer(offer)?;
1382 l.rtc.sdp_api().accept_answer(pending, answer)?;
1383 
1384 // Drive to connected.
1385 for _ in 0..1000 {
1386 if l.is_connected() && r.is_connected() {
1387 break;
1388 }
1389 progress(&mut l, &mut r)?;
1390 }
1391 
1392 // R re-offers (reverse direction).
1393 let mut change = r.sdp_api();
1394 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
1395 let (re_offer, re_pending) = change.apply().unwrap();
1396 
1397 // L answers the re-offer normally.
1398 let re_answer = l.rtc.sdp_api().accept_offer(re_offer)?;
1399 
1400 // Tamper with the answer's sctp-init before R processes it.
1401 let re_answer_sdp = re_answer.to_sdp_string();
1402 let tampered = replace_sctp_init(&re_answer_sdp, "AQ==");
1403 let tampered_answer = str0m::change::SdpAnswer::from_sdp_string(&tampered).unwrap();
1404 
1405 let err = r
1406 .rtc
1407 .sdp_api()
1408 .accept_answer(re_pending, tampered_answer)
1409 .unwrap_err();
1410 assert!(
1411 err.to_string()
1412 .contains("Changed a=sctp-init for existing SCTP association"),
1413 "unexpected error: {err}"
1414 );
1415 
1416 Ok(())
1417}
1418 
1419fn with_params(
1420 span_l: Span,
1421 params_l: &[PayloadParams],
1422 span_r: Span,
1423 params_r: &[PayloadParams],
1424) -> (TestRtc, TestRtc) {
1425 let mut l = build_params(span_l, params_l);
1426 let mut r = build_params(span_r, params_r);
1427 
1428 let kind = params_l
1429 .first()
1430 .map(|p| p.spec().codec.kind())
1431 .unwrap_or(MediaKind::Audio);
1432 
1433 negotiate(&mut l, &mut r, |change| {
1434 change.add_media(kind, Direction::SendRecv, None, None, None);
1435 });
1436 
1437 (l, r)
1438}
1439 
1440fn with_exts(exts_l: ExtensionMap, exts_r: ExtensionMap) -> (TestRtc, TestRtc) {
1441 let mut l = build_exts(info_span!("L"), exts_l);
1442 let mut r = build_exts(info_span!("R"), exts_r);
1443 
1444 negotiate(&mut l, &mut r, |change| {
1445 change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
1446 });
1447 
1448 (l, r)
1449}
1450 
1451fn build_params(span: Span, params: &[PayloadParams]) -> TestRtc {
1452 let mut b = Rtc::builder().clear_codecs();
1453 let config = b.codec_config();
1454 for p in params {
1455 config.add_config(
1456 p.pt(),
1457 p.resend(),
1458 p.spec().codec,
1459 p.spec().clock_rate,
1460 p.spec().channels,
1461 p.spec().format,
1462 );
1463 }
1464 let rtc = b.build(Instant::now());
1465 
1466 TestRtc::new_with_rtc(span, rtc)
1467}
1468 
1469fn build_exts(span: Span, exts: ExtensionMap) -> TestRtc {
1470 let mut b = Rtc::builder().clear_codecs();
1471 b = b.enable_vp8(true);
1472 let e = b.extension_map();
1473 *e = exts;
1474 let rtc = b.build(Instant::now());
1475 
1476 TestRtc::new_with_rtc(span, rtc)
1477}
1478 
1479fn opus(pt: u8) -> PayloadParams {
1480 PayloadParams::new(
1481 pt.into(),
1482 None,
1483 CodecSpec {
1484 codec: Codec::Opus,
1485 channels: Some(2),
1486 clock_rate: Frequency::FORTY_EIGHT_KHZ,
1487 format: FormatParams::default(),
1488 },
1489 )
1490}
1491 
1492fn g722(pt: u8) -> PayloadParams {
1493 PayloadParams::new(
1494 pt.into(),
1495 None,
1496 CodecSpec {
1497 codec: Codec::G722,
1498 channels: None,
1499 // G722 is a 16 kHz codec, even though its RTP clock rate is 8000 Hz.
1500 clock_rate: Frequency::SIXTEEN_KHZ,
1501 format: FormatParams::default(),
1502 },
1503 )
1504}
1505 
1506fn vp8(pt: u8) -> PayloadParams {
1507 PayloadParams::new(
1508 pt.into(),
1509 None,
1510 CodecSpec {
1511 codec: Codec::Vp8,
1512 channels: None,
1513 clock_rate: Frequency::NINETY_KHZ,
1514 format: FormatParams::default(),
1515 },
1516 )
1517}
1518 
1519fn h264(pt: u8) -> PayloadParams {
1520 PayloadParams::new(
1521 pt.into(),
1522 None,
1523 CodecSpec {
1524 codec: Codec::H264,
1525 channels: None,
1526 clock_rate: Frequency::NINETY_KHZ,
1527 format: FormatParams::default(),
1528 },
1529 )
1530}