1use sipx_sip::{HeaderName, Request, Uri};
21
22#[derive(Debug, Clone)]
24pub struct Referral {
25 pub target: Uri,
27 pub referred_by: Option<String>,
32 pub(crate) event_id: u32,
35 pub(crate) key: sipx_sip::transaction::TransactionKey,
39 pub(crate) request: Request,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Replaces {
54 pub call_id: Vec<u8>,
56 pub to_tag: Vec<u8>,
58 pub from_tag: Vec<u8>,
60 pub early_only: bool,
62}
63
64impl Replaces {
65 #[must_use]
71 pub fn of(request: &Request) -> Option<Self> {
72 let value = request.headers.value(&HeaderName::Replaces)?;
73 Self::parse(&value)
74 }
75
76 #[must_use]
78 pub fn parse(value: &[u8]) -> Option<Self> {
79 let text = std::str::from_utf8(value).ok()?;
80 let mut parts = text.split(';');
81 let call_id = parts.next()?.trim();
82 if call_id.is_empty() {
83 return None;
84 }
85
86 let (mut to_tag, mut from_tag, mut early_only) = (None, None, false);
87 for part in parts {
88 let part = part.trim();
89 let (name, value) = match part.split_once('=') {
91 Some((name, value)) => (name.trim(), Some(value.trim())),
92 None => (part, None),
93 };
94 match (name.to_ascii_lowercase().as_str(), value) {
95 ("to-tag", Some(value)) if !value.is_empty() => {
96 to_tag = Some(value.as_bytes().to_vec());
97 }
98 ("from-tag", Some(value)) if !value.is_empty() => {
99 from_tag = Some(value.as_bytes().to_vec());
100 }
101 ("early-only", _) => early_only = true,
102 _ => {}
103 }
104 }
105
106 Some(Self {
107 call_id: call_id.as_bytes().to_vec(),
108 to_tag: to_tag?,
109 from_tag: from_tag?,
110 early_only,
111 })
112 }
113
114 #[must_use]
123 pub fn matches(&self, dialog: &crate::dialog::Dialog) -> bool {
124 dialog.id.call_id == self.call_id
128 && dialog.id.local_tag == self.to_tag
129 && dialog.id.remote_tag == self.from_tag
130 }
131
132 #[must_use]
134 pub fn to_header(&self) -> String {
135 let mut out = format!(
136 "{};to-tag={};from-tag={}",
137 String::from_utf8_lossy(&self.call_id),
138 String::from_utf8_lossy(&self.to_tag),
139 String::from_utf8_lossy(&self.from_tag),
140 );
141 if self.early_only {
142 out.push_str(";early-only");
143 }
144 out
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum TransferState {
151 Trying,
153 Ringing,
155 Succeeded,
157 Failed {
159 status: u16,
161 reason: String,
163 },
164}
165
166impl TransferState {
167 #[must_use]
169 pub fn from_status(status: u16, reason: &str) -> Self {
170 match status {
171 100..=199 if status == 180 || status == 183 => Self::Ringing,
172 100..=199 => Self::Trying,
173 200..=299 => Self::Succeeded,
174 _ => Self::Failed {
175 status,
176 reason: reason.to_owned(),
177 },
178 }
179 }
180
181 #[must_use]
183 pub fn is_final(&self) -> bool {
184 matches!(self, Self::Succeeded | Self::Failed { .. })
185 }
186}
187
188#[derive(Debug, Clone)]
190pub struct Transfer {
191 pub state: TransferState,
193 pub finished: bool,
199}
200
201#[must_use]
206pub fn sipfrag(status: u16, reason: &str) -> String {
207 format!("SIP/2.0 {status} {reason}\r\n")
208}
209
210#[must_use]
216pub fn parse_sipfrag(body: &[u8]) -> Option<(u16, String)> {
217 let text = std::str::from_utf8(body).ok()?;
218 let line = text.lines().next()?.trim();
219 let rest = line.strip_prefix("SIP/2.0 ")?;
220 let (code, reason) = rest.split_once(' ').unwrap_or((rest, ""));
221 let status: u16 = code.trim().parse().ok()?;
222 if !(100..=699).contains(&status) {
223 return None;
224 }
225 Some((status, reason.trim().to_owned()))
226}
227
228#[must_use]
234pub fn is_terminated(subscription_state: &[u8]) -> bool {
235 sipx_sip::event::Subscription::parse(subscription_state)
236 .is_some_and(|subscription| subscription.state == sipx_sip::event::State::Terminated)
237}
238
239#[must_use]
249pub fn subscription_suppressed(request: &sipx_sip::Request, response: &sipx_sip::Response) -> bool {
250 says_false(request.headers.value(&HeaderName::ReferSub).as_deref())
251 && says_false(response.headers.value(&HeaderName::ReferSub).as_deref())
252}
253
254fn says_false(value: Option<&[u8]>) -> bool {
255 value.is_some_and(|value| {
256 String::from_utf8_lossy(value)
257 .split(';')
258 .next()
259 .unwrap_or_default()
260 .trim()
261 .eq_ignore_ascii_case("false")
262 })
263}
264
265#[cfg(test)]
266#[allow(
267 clippy::unwrap_used,
268 clippy::expect_used,
269 clippy::panic,
270 clippy::indexing_slicing
271)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn a_status_line_round_trips() {
277 let (status, reason) = parse_sipfrag(sipfrag(200, "OK").as_bytes()).expect("parses");
278 assert_eq!((status, reason.as_str()), (200, "OK"));
279 }
280
281 #[test]
284 fn headers_after_the_status_line_are_ignored() {
285 let body = b"SIP/2.0 486 Busy Here\r\nContact: <sip:a@b>\r\n\r\n";
286 assert_eq!(
287 parse_sipfrag(body).expect("parses"),
288 (486, "Busy Here".to_owned())
289 );
290 }
291
292 #[test]
293 fn a_reason_phrase_may_have_spaces_or_be_absent() {
294 assert_eq!(
295 parse_sipfrag(b"SIP/2.0 480 Temporarily Unavailable\r\n")
296 .expect("parses")
297 .1,
298 "Temporarily Unavailable"
299 );
300 assert_eq!(
301 parse_sipfrag(b"SIP/2.0 200\r\n").expect("parses"),
302 (200, String::new())
303 );
304 }
305
306 #[test]
307 fn something_that_is_not_a_status_line_is_refused() {
308 assert!(parse_sipfrag(b"200 OK\r\n").is_none(), "no SIP version");
309 assert!(parse_sipfrag(b"SIP/2.0 wat\r\n").is_none(), "not a number");
310 assert!(parse_sipfrag(b"SIP/2.0 99 Too Low\r\n").is_none());
311 assert!(parse_sipfrag(b"SIP/2.0 700 Too High\r\n").is_none());
312 assert!(parse_sipfrag(b"").is_none());
313 }
314
315 #[test]
318 fn a_status_becomes_the_state_it_means() {
319 assert_eq!(
320 TransferState::from_status(100, "Trying"),
321 TransferState::Trying
322 );
323 assert_eq!(
324 TransferState::from_status(180, "Ringing"),
325 TransferState::Ringing
326 );
327 assert_eq!(
328 TransferState::from_status(200, "OK"),
329 TransferState::Succeeded
330 );
331 assert_eq!(
332 TransferState::from_status(486, "Busy Here"),
333 TransferState::Failed {
334 status: 486,
335 reason: "Busy Here".to_owned()
336 }
337 );
338 }
339
340 #[test]
341 fn only_a_final_state_is_final() {
342 assert!(!TransferState::Trying.is_final());
343 assert!(!TransferState::Ringing.is_final());
344 assert!(TransferState::Succeeded.is_final());
345 }
346
347 fn dialog(call_id: &str, local: &str, remote: &str) -> crate::dialog::Dialog {
348 crate::dialog::Dialog {
349 role: crate::dialog::Role::Callee,
350 id: crate::dialog::DialogId {
351 call_id: call_id.as_bytes().to_vec(),
352 local_tag: local.as_bytes().to_vec(),
353 remote_tag: remote.as_bytes().to_vec(),
354 },
355 local_uri: "<sip:a@b>".to_owned(),
356 remote_uri: "<sip:c@d>".to_owned(),
357 remote_target: Uri::parse(bytes::Bytes::from_static(b"sip:c@d")).expect("valid"),
358 local_cseq: 1,
359 remote_cseq: None,
360 route_set: Vec::new(),
361 }
362 }
363
364 #[test]
365 fn a_replaces_header_round_trips() {
366 let replaces = Replaces {
367 call_id: b"abc@host".to_vec(),
368 to_tag: b"tttt".to_vec(),
369 from_tag: b"ffff".to_vec(),
370 early_only: false,
371 };
372 let parsed = Replaces::parse(replaces.to_header().as_bytes()).expect("parses");
373 assert_eq!(parsed, replaces);
374 }
375
376 #[test]
377 fn early_only_survives_the_round_trip() {
378 let replaces = Replaces {
379 call_id: b"abc@host".to_vec(),
380 to_tag: b"t".to_vec(),
381 from_tag: b"f".to_vec(),
382 early_only: true,
383 };
384 assert!(replaces.to_header().contains(";early-only"));
385 assert!(
386 Replaces::parse(replaces.to_header().as_bytes())
387 .expect("parses")
388 .early_only
389 );
390 }
391
392 #[test]
395 fn a_header_missing_a_tag_is_not_a_replaces() {
396 assert!(
397 Replaces::parse(b"abc@host;to-tag=t").is_none(),
398 "no from-tag"
399 );
400 assert!(
401 Replaces::parse(b"abc@host;from-tag=f").is_none(),
402 "no to-tag"
403 );
404 assert!(Replaces::parse(b"abc@host").is_none(), "neither");
405 assert!(
406 Replaces::parse(b"abc@host;to-tag=;from-tag=f").is_none(),
407 "empty"
408 );
409 assert!(
410 Replaces::parse(b";to-tag=t;from-tag=f").is_none(),
411 "no Call-ID"
412 );
413 assert!(Replaces::parse(b"").is_none());
414 }
415
416 #[test]
417 fn parameter_names_are_case_insensitive_and_values_are_not() {
418 let parsed = Replaces::parse(b"abc@host;To-Tag=Abc;FROM-TAG=Def").expect("parses");
419 assert_eq!(parsed.to_tag, b"Abc".to_vec(), "the value keeps its case");
420 assert_eq!(parsed.from_tag, b"Def".to_vec());
421 }
422
423 #[test]
426 fn the_tags_match_the_dialog_from_the_receivers_point_of_view() {
427 let dialog = dialog("call-1", "mine", "theirs");
428 let replaces = Replaces {
429 call_id: b"call-1".to_vec(),
430 to_tag: b"mine".to_vec(),
431 from_tag: b"theirs".to_vec(),
432 early_only: false,
433 };
434 assert!(replaces.matches(&dialog));
435
436 let swapped = Replaces {
438 to_tag: b"theirs".to_vec(),
439 from_tag: b"mine".to_vec(),
440 ..replaces.clone()
441 };
442 assert!(!swapped.matches(&dialog));
443 }
444
445 #[test]
449 fn a_matching_call_id_with_wrong_tags_does_not_match() {
450 let dialog = dialog("call-1", "mine", "theirs");
451 for (to, from) in [
452 ("guessed", "theirs"),
453 ("mine", "guessed"),
454 ("guessed", "guessed"),
455 ("", ""),
456 ] {
457 let attempt = Replaces {
458 call_id: b"call-1".to_vec(),
459 to_tag: to.as_bytes().to_vec(),
460 from_tag: from.as_bytes().to_vec(),
461 early_only: false,
462 };
463 assert!(
464 !attempt.matches(&dialog),
465 "the Call-ID alone must not be enough: to={to} from={from}"
466 );
467 }
468 }
469
470 #[test]
471 fn a_different_call_does_not_match_however_right_the_tags_look() {
472 let dialog = dialog("call-1", "mine", "theirs");
473 let other = Replaces {
474 call_id: b"call-2".to_vec(),
475 to_tag: b"mine".to_vec(),
476 from_tag: b"theirs".to_vec(),
477 early_only: false,
478 };
479 assert!(!other.matches(&dialog));
480 }
481
482 #[test]
483 fn a_terminated_subscription_is_recognised_however_it_is_spelled() {
484 assert!(is_terminated(b"terminated;reason=noresource"));
485 assert!(is_terminated(b"Terminated"));
486 assert!(is_terminated(b" terminated ;reason=timeout"));
487 assert!(!is_terminated(b"active;expires=60"));
488 assert!(!is_terminated(b"pending"));
489 }
490}
491
492#[cfg(test)]
493#[allow(
494 clippy::unwrap_used,
495 clippy::expect_used,
496 clippy::panic,
497 clippy::indexing_slicing
498)]
499mod refer_sub_tests {
500 use super::*;
501 use bytes::Bytes;
502 use sipx_sip::{Limits, Message, parse_datagram};
503
504 fn refer(refer_sub: Option<&str>) -> sipx_sip::Request {
505 let line = refer_sub.map_or_else(String::new, |value| format!("Refer-Sub: {value}\r\n"));
506 let text = format!(
507 "REFER sip:bob@example.com SIP/2.0\r\n\
508 Via: SIP/2.0/UDP a.example;branch=z9hG4bKx\r\n\
509 To: <sip:bob@example.com>;tag=b\r\n\
510 From: <sip:alice@example.net>;tag=a\r\n\
511 Call-ID: xfer@sipx\r\n\
512 CSeq: 2 REFER\r\n\
513 Refer-To: <sip:carol@example.org>\r\n\
514 {line}\
515 Max-Forwards: 70\r\n\
516 Content-Length: 0\r\n\r\n"
517 );
518 match parse_datagram(Bytes::from(text), &Limits::datagram()).expect("parses") {
519 Message::Request(request) => request,
520 Message::Response(_) => panic!("a request"),
521 }
522 }
523
524 fn accepted(refer_sub: Option<&str>) -> sipx_sip::Response {
525 let line = refer_sub.map_or_else(String::new, |value| format!("Refer-Sub: {value}\r\n"));
526 let text = format!(
527 "SIP/2.0 202 Accepted\r\n\
528 Via: SIP/2.0/UDP a.example;branch=z9hG4bKx\r\n\
529 To: <sip:bob@example.com>;tag=b\r\n\
530 From: <sip:alice@example.net>;tag=a\r\n\
531 Call-ID: xfer@sipx\r\n\
532 CSeq: 2 REFER\r\n\
533 {line}\
534 Content-Length: 0\r\n\r\n"
535 );
536 match parse_datagram(Bytes::from(text), &Limits::datagram()).expect("parses") {
537 Message::Response(response) => response,
538 Message::Request(_) => panic!("a response"),
539 }
540 }
541
542 #[test]
546 fn suppression_needs_both_sides_to_say_so() {
547 assert!(
548 subscription_suppressed(&refer(Some("false")), &accepted(Some("false"))),
549 "asked and agreed"
550 );
551 assert!(
552 !subscription_suppressed(&refer(Some("false")), &accepted(None)),
553 "asked, and the transferee said nothing — so it is still notifying"
554 );
555 assert!(
556 !subscription_suppressed(&refer(None), &accepted(Some("false"))),
557 "not asked for"
558 );
559 assert!(
560 !subscription_suppressed(&refer(Some("true")), &accepted(Some("true"))),
561 "`true` asks *for* the subscription"
562 );
563 assert!(!subscription_suppressed(&refer(None), &accepted(None)));
564 }
565
566 #[test]
569 fn the_implicit_subscription_uses_the_frameworks_notion_of_terminated() {
570 assert!(is_terminated(b"terminated;reason=noresource"));
571 assert!(is_terminated(b"TERMINATED"));
572 assert!(!is_terminated(b"active;expires=60"));
573 assert!(!is_terminated(b"pending"));
574 assert!(!is_terminated(b"finished"));
577 assert!(!is_terminated(b""));
578 }
579}