Skip to content
File

Blob: firmware/vendor/str0m/tests/sdp-m-recycle.rs

rust535 lines
1use std::time::Instant;
2 
3mod common;
4use common::TestRtc;
5use common::init_crypto_default;
6use common::init_log;
7use common::negotiate;
8use str0m::Rtc;
9use str0m::change::SdpOffer;
10use str0m::format::Codec;
11use str0m::format::CodecSpec;
12use str0m::format::FormatParams;
13use str0m::format::PayloadParams;
14use str0m::media::Direction;
15use str0m::media::Frequency;
16use str0m::media::MediaKind;
17use str0m::media::Mid;
18use str0m::media::Pt;
19use tracing::Span;
20use tracing::info_span;
21 
22#[test]
23pub fn stop_media_then_remote_recycles_slot() {
24 init_log();
25 init_crypto_default();
26 
27 // L stops its video m-line, then receives an offer that recycles the
28 // resulting port=0 slot with a new mid (RFC 8829 §5.2.2).
29 let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]);
30 
31 let old_mid = l._mids()[0];
32 
33 negotiate(&mut l, &mut r, |change| {
34 change.stop_media(old_mid);
35 });
36 
37 assert!(l.media(old_mid).unwrap().stopped());
38 assert!(r.media(old_mid).unwrap().stopped());
39 
40 // str0m's own SdpApi doesn't produce recycling offers, so take an
41 // offer from R (which appends a new m-line) and rewrite it so the
42 // new m-line lives at the index of the stopped one - what a browser
43 // would emit per RFC 8829 §5.2.2.
44 let offer_from_r = r
45 .span
46 .in_scope(|| {
47 let mut change = r.rtc.sdp_api();
48 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
49 change.apply()
50 })
51 .expect("R has a change to apply");
52 
53 let offer_sdp = offer_from_r.0.to_sdp_string();
54 let recycled_offer_sdp = rewrite_offer_to_recycle_stopped_slot(&offer_sdp, old_mid);
55 
56 let recycled_offer =
57 SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("recycled offer parses");
58 
59 let answer = l.span.in_scope(|| {
60 l.rtc
61 .sdp_api()
62 .accept_offer(recycled_offer)
63 .expect("accept")
64 });
65 
66 let answer_sdp = answer.to_sdp_string();
67 
68 // RFC 3264 §6: the answer must have the same m-line count as the
69 // offer. Without recycling, str0m appends a second m-line and the
70 // answer is invalid.
71 let count_m_lines = |s: &str| s.lines().filter(|l| l.starts_with("m=")).count();
72 let offer_m_lines = count_m_lines(&recycled_offer_sdp);
73 let answer_m_lines = count_m_lines(&answer_sdp);
74 assert_eq!(
75 answer_m_lines, offer_m_lines,
76 "answer m-line count ({answer_m_lines}) differs from offer m-line count ({offer_m_lines})\n\n\
77 rewritten offer:\n{recycled_offer_sdp}\n\nanswer:\n{answer_sdp}",
78 );
79}
80 
81#[test]
82pub fn recycling_rejected_for_non_stopped_slot() {
83 init_log();
84 init_crypto_default();
85 
86 // L holds an active video m-line. An offer arrives that tries to
87 // recycle that active slot with a new mid (no prior stop). str0m must
88 // refuse rather than install a second Media at the same index.
89 let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]);
90 
91 let active_mid = l._mids()[0];
92 
93 // Sanity: the mid is active, not stopped.
94 assert!(!l.media(active_mid).unwrap().stopped());
95 assert!(!l.media(active_mid).unwrap().disabled());
96 
97 // Take an ordinary offer from R adding a new m-line, then rewrite it
98 // so the new m-line squats the slot of L's active mid.
99 let offer_from_r = r
100 .span
101 .in_scope(|| {
102 let mut change = r.rtc.sdp_api();
103 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
104 change.apply()
105 })
106 .expect("R has a change to apply");
107 
108 let offer_sdp = offer_from_r.0.to_sdp_string();
109 let bad_offer_sdp = rewrite_offer_to_recycle_stopped_slot(&offer_sdp, active_mid);
110 let bad_offer = SdpOffer::from_sdp_string(&bad_offer_sdp).expect("offer parses");
111 
112 let result = l.span.in_scope(|| l.rtc.sdp_api().accept_offer(bad_offer));
113 
114 assert!(
115 result.is_err(),
116 "accept_offer must reject a recycling attempt against a non-stopped slot, got Ok:\n{}",
117 bad_offer_sdp
118 );
119}
120 
121#[test]
122pub fn stop_media_recycled_slot_state() {
123 init_log();
124 init_crypto_default();
125 
126 // After a recycled offer is accepted, verify the post-recycle state
127 // of the surviving Media objects. The happy-path test only asserts
128 // answer m-line count; this pins down the full state.
129 let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]);
130 
131 let old_mid = l._mids()[0];
132 
133 negotiate(&mut l, &mut r, |change| {
134 change.stop_media(old_mid);
135 });
136 
137 let offer_from_r = r
138 .span
139 .in_scope(|| {
140 let mut change = r.rtc.sdp_api();
141 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
142 change.apply()
143 })
144 .expect("R has a change to apply");
145 
146 let recycled_offer_sdp =
147 rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid);
148 let recycled_offer = SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("parses");
149 
150 // The new mid is whichever mid in the recycled SDP isn't old_mid.
151 let new_mid = new_mid_in_sdp(&recycled_offer_sdp, old_mid);
152 
153 l.span.in_scope(|| {
154 l.rtc
155 .sdp_api()
156 .accept_offer(recycled_offer)
157 .expect("accept");
158 });
159 
160 // L side: old mid is retired, new mid is live and not stopped.
161 assert!(
162 !l._mids().contains(&old_mid),
163 "retired mid {old_mid} must not appear in L's medias after recycle, got {:?}",
164 l._mids()
165 );
166 assert!(
167 l._mids().contains(&new_mid),
168 "recycled mid {new_mid} must appear in L's medias, got {:?}",
169 l._mids()
170 );
171 assert!(l.media(old_mid).is_none(), "old Media must be gone on L");
172 
173 let new_media_l = l.media(new_mid).expect("new Media on L");
174 assert!(!new_media_l.stopped(), "recycled Media must not be stopped");
175 assert!(
176 !new_media_l.disabled(),
177 "recycled Media must not be disabled"
178 );
179 assert_eq!(
180 new_media_l.kind(),
181 MediaKind::Video,
182 "recycled Media kind must match the offer"
183 );
184 // R offered sendonly, so L's inverted direction is recvonly.
185 assert_eq!(new_media_l.direction(), Direction::RecvOnly);
186 assert_eq!(
187 new_media_l.remote_pts(),
188 &[Pt::new_with_value(100)],
189 "recycled Media must have remote_pts populated"
190 );
191}
192 
193#[test]
194pub fn stop_media_and_recycle_then_negotiate_again() {
195 init_log();
196 init_crypto_default();
197 
198 // After accepting a recycled offer, L must still be able to generate
199 // a valid subsequent offer of its own. This exercises that session
200 // state (medias, streams, index bookkeeping) is consistent for the
201 // side that processed the recycling.
202 //
203 // We don't round-trip R here: R sent a non-recycled offer that was
204 // rewritten out-of-band, so from R's own perspective the resulting
205 // answer is malformed. That's a synthetic-test artifact, not a
206 // real-world scenario (browsers always answer in-shape).
207 let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]);
208 
209 let old_mid = l._mids()[0];
210 
211 negotiate(&mut l, &mut r, |change| {
212 change.stop_media(old_mid);
213 });
214 
215 let offer_from_r = r
216 .span
217 .in_scope(|| {
218 let mut change = r.rtc.sdp_api();
219 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
220 change.apply()
221 })
222 .expect("R has a change to apply");
223 
224 let recycled_offer_sdp =
225 rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid);
226 let recycled_offer = SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("parses");
227 
228 l.span.in_scope(|| {
229 l.rtc
230 .sdp_api()
231 .accept_offer(recycled_offer)
232 .expect("accept")
233 });
234 
235 // L must be able to create another offer cleanly. This would fail if
236 // `as_media_lines` disagrees with the post-recycle `medias` list.
237 let follow_up = l
238 .span
239 .in_scope(|| {
240 let mut change = l.rtc.sdp_api();
241 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
242 change.apply()
243 })
244 .expect("L can produce a follow-up offer");
245 
246 let follow_up_sdp = follow_up.0.to_sdp_string();
247 
248 // Retired mid must not reappear in L's offer.
249 let old_mid_str = old_mid.to_string();
250 assert!(
251 !follow_up_sdp
252 .lines()
253 .any(|l| l.strip_prefix("a=mid:").map(str::trim) == Some(old_mid_str.as_str())),
254 "retired mid resurfaced in L's follow-up offer:\n{}",
255 follow_up_sdp
256 );
257 // The follow-up SDP must have exactly 2 m-lines (recycled + new).
258 let m_line_count = follow_up_sdp
259 .lines()
260 .filter(|l| l.starts_with("m="))
261 .count();
262 assert_eq!(
263 m_line_count, 2,
264 "expected 2 m-lines in follow-up offer, got:\n{}",
265 follow_up_sdp
266 );
267 assert!(!l._mids().contains(&old_mid));
268}
269 
270#[test]
271pub fn recycling_with_media_type_change() {
272 init_log();
273 init_crypto_default();
274 
275 // JSEP §5.2.2 allows a recycled slot to change media type. Stop an
276 // audio m-line and have the remote reoffer with a video m-line at
277 // the same slot. Both peers need audio *and* video codecs, so we
278 // construct them manually rather than via with_params.
279 let build = |span: Span| {
280 let mut b = Rtc::builder().clear_codecs();
281 b.codec_config().add_config(
282 Pt::new_with_value(111),
283 None,
284 Codec::Opus,
285 Frequency::FORTY_EIGHT_KHZ,
286 Some(2),
287 FormatParams::default(),
288 );
289 b.codec_config().add_config(
290 Pt::new_with_value(100),
291 None,
292 Codec::Vp8,
293 Frequency::NINETY_KHZ,
294 None,
295 FormatParams::default(),
296 );
297 TestRtc::new_with_rtc(span, b.build(Instant::now()))
298 };
299 let mut l = build(info_span!("L"));
300 let mut r = build(info_span!("R"));
301 
302 negotiate(&mut l, &mut r, |change| {
303 change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
304 });
305 
306 let old_mid = l._mids()[0];
307 assert_eq!(l.media(old_mid).unwrap().kind(), MediaKind::Audio);
308 
309 negotiate(&mut l, &mut r, |change| {
310 change.stop_media(old_mid);
311 });
312 
313 let offer_from_r = r
314 .span
315 .in_scope(|| {
316 let mut change = r.rtc.sdp_api();
317 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
318 change.apply()
319 })
320 .expect("R has a change to apply");
321 
322 let recycled_sdp =
323 rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid);
324 let recycled_offer = SdpOffer::from_sdp_string(&recycled_sdp).expect("parses");
325 let new_mid = new_mid_in_sdp(&recycled_sdp, old_mid);
326 
327 let result = l
328 .span
329 .in_scope(|| l.rtc.sdp_api().accept_offer(recycled_offer));
330 assert!(
331 result.is_ok(),
332 "accepting a recycle that changes media type (audio->video) should succeed per JSEP §5.2.2: {:?}",
333 result.err()
334 );
335 
336 let recycled = l.media(new_mid).expect("new Media present");
337 assert_eq!(
338 recycled.kind(),
339 MediaKind::Video,
340 "recycled Media kind must reflect the new m-line type"
341 );
342 assert!(!recycled.stopped());
343}
344 
345#[test]
346pub fn recycling_in_answer_produces_error() {
347 init_log();
348 init_crypto_default();
349 
350 // JSEP §5.2.2 only permits recycling in offers. An answer that
351 // introduces a new mid at the index of a stopped slot is malformed.
352 // str0m's `ensure_correct_answer` should reject it rather than
353 // silently retiring our Media.
354 let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]);
355 
356 let old_mid = l._mids()[0];
357 
358 negotiate(&mut l, &mut r, |change| {
359 change.stop_media(old_mid);
360 });
361 
362 // L initiates a fresh offer adding a new video m-line; R will answer.
363 let (offer_from_l, pending_l) = l
364 .span
365 .in_scope(|| {
366 let mut change = l.rtc.sdp_api();
367 change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
368 change.apply()
369 })
370 .expect("L has a change to apply");
371 
372 let answer_from_r = r
373 .span
374 .in_scope(|| r.rtc.sdp_api().accept_offer(offer_from_l).expect("accept"));
375 
376 // Rewrite R's answer so the new mid's m-line squats the old stopped
377 // slot. This is the malformed shape we want L to reject.
378 let malformed_answer_sdp =
379 rewrite_offer_to_recycle_stopped_slot(&answer_from_r.to_sdp_string(), old_mid);
380 let malformed_answer =
381 str0m::change::SdpAnswer::from_sdp_string(&malformed_answer_sdp).expect("parses");
382 
383 let result = l
384 .span
385 .in_scope(|| l.rtc.sdp_api().accept_answer(pending_l, malformed_answer));
386 
387 assert!(
388 result.is_err(),
389 "accept_answer must reject a recycled answer, got Ok:\n{}",
390 malformed_answer_sdp
391 );
392}
393 
394// -------------------------------------------------------------------------
395// Helpers
396// -------------------------------------------------------------------------
397 
398/// Moves the trailing (newly added) m-line into the slot occupied by the
399/// m-line for `old_mid`, and updates the BUNDLE group accordingly. When
400/// `old_mid` belongs to a stopped m-line (port=0) this produces the offer
401/// shape a browser emits when recycling the slot per RFC 8829 §5.2.2.
402fn rewrite_offer_to_recycle_stopped_slot(sdp: &str, old_mid: Mid) -> String {
403 // Split into sections, each starting with "m=". The first section is
404 // the session-level block before any m-line.
405 let mut sections: Vec<String> = Vec::new();
406 let mut current = String::new();
407 for line in sdp.split_inclusive("\r\n") {
408 if line.starts_with("m=") && !current.is_empty() {
409 sections.push(std::mem::take(&mut current));
410 }
411 current.push_str(line);
412 }
413 if !current.is_empty() {
414 sections.push(current);
415 }
416 
417 let session_block = sections.remove(0);
418 
419 // Find the stopped section and the trailing new section.
420 let old_mid_attr = format!("a=mid:{old_mid}\r\n");
421 let stopped_idx = sections
422 .iter()
423 .position(|s| s.contains(&old_mid_attr))
424 .expect("stopped section present");
425 let new_section = sections.pop().expect("new section present");
426 
427 sections[stopped_idx] = new_section;
428 
429 // Parse the new mid so we can rewrite the BUNDLE group.
430 let new_mid = sections[stopped_idx]
431 .lines()
432 .find_map(|l| l.strip_prefix("a=mid:"))
433 .expect("new section has a=mid:")
434 .trim()
435 .to_string();
436 
437 let old_mid_str = old_mid.to_string();
438 let session_block = session_block
439 .lines()
440 .map(|l| {
441 if let Some(rest) = l.strip_prefix("a=group:BUNDLE ") {
442 let mut mids: Vec<&str> = rest
443 .split_whitespace()
444 .filter(|m| *m != old_mid_str)
445 .collect();
446 if !mids.contains(&new_mid.as_str()) {
447 mids.push(new_mid.as_str());
448 }
449 format!("a=group:BUNDLE {}", mids.join(" "))
450 } else {
451 l.to_string()
452 }
453 })
454 .collect::<Vec<_>>()
455 .join("\r\n")
456 + "\r\n";
457 
458 let mut out = session_block;
459 for s in sections {
460 out.push_str(&s);
461 }
462 out
463}
464 
465/// Find the mid in `sdp` that isn't `old_mid`. Panics if zero or more than one match.
466fn new_mid_in_sdp(sdp: &str, old_mid: Mid) -> Mid {
467 let old_str = old_mid.to_string();
468 let mut found: Option<Mid> = None;
469 for line in sdp.lines() {
470 if let Some(rest) = line.strip_prefix("a=mid:") {
471 let m = rest.trim();
472 if m == old_str {
473 continue;
474 }
475 assert!(
476 found.is_none(),
477 "more than one non-old mid in sdp:\n{}",
478 sdp
479 );
480 found = Some(m.into());
481 }
482 }
483 found.expect("a new mid must be present in the recycled sdp")
484}
485 
486fn with_params(
487 span_l: Span,
488 params_l: &[PayloadParams],
489 span_r: Span,
490 params_r: &[PayloadParams],
491) -> (TestRtc, TestRtc) {
492 let mut l = build_params(span_l, params_l);
493 let mut r = build_params(span_r, params_r);
494 
495 let kind = params_l
496 .first()
497 .map(|p| p.spec().codec.kind())
498 .unwrap_or(MediaKind::Audio);
499 
500 negotiate(&mut l, &mut r, |change| {
501 change.add_media(kind, Direction::SendRecv, None, None, None);
502 });
503 
504 (l, r)
505}
506 
507fn build_params(span: Span, params: &[PayloadParams]) -> TestRtc {
508 let mut b = Rtc::builder().clear_codecs();
509 let config = b.codec_config();
510 for p in params {
511 config.add_config(
512 p.pt(),
513 p.resend(),
514 p.spec().codec,
515 p.spec().clock_rate,
516 p.spec().channels,
517 p.spec().format,
518 );
519 }
520 TestRtc::new_with_rtc(span, b.build(Instant::now()))
521}
522 
523fn vp8(pt: u8) -> PayloadParams {
524 PayloadParams::new(
525 pt.into(),
526 None,
527 CodecSpec {
528 codec: Codec::Vp8,
529 channels: None,
530 clock_rate: Frequency::NINETY_KHZ,
531 format: FormatParams::default(),
532 },
533 )
534}