Skip to main content

sipx_call/
transfer.rs

1//! Transfer: REFER (RFC 3515) and the implicit subscription it creates.
2//!
3//! The shape of a blind transfer, and the part that is easy to get wrong:
4//!
5//! 1. The **transferor** sends REFER inside the existing dialog, naming where to go in
6//!    `Refer-To`.
7//! 2. The **transferee** answers `202 Accepted` — which means *"I will try"*, and nothing more.
8//! 3. The transferee places the new call and reports back with NOTIFY.
9//!
10//! Step 2 is where implementations go wrong. A 202 is not success: treating it as success
11//! reports a completed transfer to a user whose call may have been refused, gone to voicemail
12//! or rung out. RFC 3515 §2.4.4 exists precisely so the transferor can tell those apart, and
13//! that is why this module models the outcome as something that arrives *later*.
14//!
15//! The subscription is implicit — REFER creates one without a SUBSCRIBE — and it must end. A
16//! transferee that reports the outcome and then says nothing leaves the transferor holding a
17//! subscription that never terminates, which is a leak on both sides and, on a real network, a
18//! dialog a proxy keeps state for.
19
20use sipx_sip::{HeaderName, Request, Uri};
21
22/// What a REFER asked of us.
23#[derive(Debug, Clone)]
24pub struct Referral {
25    /// Where the transferor wants the call sent.
26    pub target: Uri,
27    /// Who asked, from `Referred-By` (RFC 3892). `None` if they did not say.
28    ///
29    /// Worth surfacing rather than swallowing: a transfer is a request to call somebody on
30    /// another party's say-so, and who said so is the only basis for deciding whether to.
31    pub referred_by: Option<String>,
32    /// The REFER's sequence number, which identifies the subscription it created
33    /// (RFC 3515 §2.4.4: `Event: refer;id=<CSeq>`).
34    pub(crate) event_id: u32,
35    /// The transaction to answer, and the request to answer it with. Both are kept because a
36    /// response is built from the request it answers, and the REFER is gone from the incoming
37    /// queue by the time the application decides.
38    pub(crate) key: sipx_sip::transaction::TransactionKey,
39    pub(crate) request: Request,
40}
41
42/// The dialog an INVITE asks to take the place of (RFC 3891).
43///
44/// **All three fields are load-bearing, and the two tags are the security.** A `Call-ID` is
45/// carried in every message of a dialog and is visible to every element on the path — a proxy,
46/// a load balancer, anything that logged a header. The tags are random and known only to the
47/// two parties. Matching on the `Call-ID` alone would turn this header into a call-hijack
48/// primitive: anyone who had seen one message of a call could ask to be put in the middle of it.
49///
50/// RFC 3891 §5 says as much, and it is the reason this type has no constructor that takes fewer
51/// than three fields.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Replaces {
54    /// The `Call-ID` of the dialog to replace.
55    pub call_id: Vec<u8>,
56    /// The `To` tag, from the point of view of whoever built this header.
57    pub to_tag: Vec<u8>,
58    /// The `From` tag, likewise.
59    pub from_tag: Vec<u8>,
60    /// Whether the sender will only replace a dialog that has not been answered yet.
61    pub early_only: bool,
62}
63
64impl Replaces {
65    /// Read a `Replaces` header out of a request.
66    ///
67    /// `None` when there is none, and also when there is one that is unusable — a header
68    /// missing either tag names no dialog, and treating it as though it named one with an empty
69    /// tag is how the tags stop being a secret.
70    #[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    /// Parse a header value: `call-id;to-tag=x;from-tag=y[;early-only]`.
77    #[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            // Parameter names are case-insensitive (RFC 3261 §7.3.1); the values are not.
90            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    /// Whether this names that dialog.
115    ///
116    /// The tags swap sides. A `Replaces` is built by the party that *observed* the dialog from
117    /// outside it — in an attended transfer, the transferor describing its own call to the
118    /// transferee — so the `to-tag` is the tag of the party receiving this INVITE, which is
119    /// that party's *local* tag. Getting the orientation wrong makes every legitimate transfer
120    /// fail while leaving the hijack case wide open, because a mismatch is a mismatch either
121    /// way and only the successful case would have shown it up.
122    #[must_use]
123    pub fn matches(&self, dialog: &crate::dialog::Dialog) -> bool {
124        // Constant-time comparison is not called for: these are not secrets an attacker can
125        // learn by timing, they are values that are either known or guessed, and a guess has
126        // 2^128 of tag space to find.
127        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    /// Render as a header value, for the INVITE that asks for the replacement.
133    #[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/// How far a transfer has got, as the transferor learns it from NOTIFY.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum TransferState {
151    /// The transferee has taken it on and is trying.
152    Trying,
153    /// The target is ringing.
154    Ringing,
155    /// The target answered. The transfer worked.
156    Succeeded,
157    /// It did not, and this is what the target said.
158    Failed {
159        /// The status the target gave.
160        status: u16,
161        /// Its reason phrase.
162        reason: String,
163    },
164}
165
166impl TransferState {
167    /// Read a state out of a `message/sipfrag` status line.
168    #[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    /// Whether the transfer is over, either way.
182    #[must_use]
183    pub fn is_final(&self) -> bool {
184        matches!(self, Self::Succeeded | Self::Failed { .. })
185    }
186}
187
188/// A transfer this side asked for, and what has become of it.
189#[derive(Debug, Clone)]
190pub struct Transfer {
191    /// The last thing the transferee reported.
192    pub state: TransferState,
193    /// Whether the implicit subscription has ended.
194    ///
195    /// Separate from `state.is_final()` on purpose. A transferee may report a final status and
196    /// still owe a terminating NOTIFY; until this is true the subscription is open, and a
197    /// transferor that stopped listening would miss it.
198    pub finished: bool,
199}
200
201/// The body of a NOTIFY about a transfer: a status line and nothing else.
202///
203/// RFC 3515 §2.4.5 asks for `message/sipfrag` (RFC 3420) — a fragment of a SIP message. Only
204/// the status line is required, and only the status line is useful, so that is what sipx sends.
205#[must_use]
206pub fn sipfrag(status: u16, reason: &str) -> String {
207    format!("SIP/2.0 {status} {reason}\r\n")
208}
209
210/// The status line out of a `message/sipfrag` body.
211///
212/// Tolerant about what follows: a fragment may legally carry headers after the status line, and
213/// a transferee that sends them is not wrong. Strict about the line itself — anything that is
214/// not a SIP status line means the body is not what its `Content-Type` claimed.
215#[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/// Whether a `Subscription-State` says the subscription is over (RFC 6665 §4.1.3).
229///
230/// Asks the event framework rather than reading the header again. The implicit subscription a
231/// REFER creates is a subscription — `S-13` made that a thing sipx has a general answer for — and
232/// two parsers for one header eventually disagree about whether a transfer has finished.
233#[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/// Whether a transferor asked for no implicit subscription (RFC 4488 §3).
240///
241/// `Refer-Sub: false` on a REFER says "do not create one". It exists because the implicit
242/// subscription is the expensive part of a transfer for a network that does many: each one is a
243/// dialog, a NOTIFY, and a terminating NOTIFY, for progress the transferor may not want.
244///
245/// §3 is careful about who decides: the transferor *requests* it and the transferee agrees by
246/// echoing `Refer-Sub: false` in its 2xx. A transferor that assumed agreement would stop watching
247/// for notifications the transferee is still sending.
248#[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    /// A fragment may carry headers after the status line. Rejecting one that does would refuse
282    /// a transferee that is following the RFC more closely than we do.
283    #[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    /// A 202 is not one of these. It answers the REFER, not the call the REFER asked for, and a
316    /// transferor that read it as success would report a transfer that may have been refused.
317    #[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    /// A header missing either tag names no dialog. Accepting it with an empty tag is exactly
393    /// how the tags stop being the thing that makes this safe.
394    #[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    /// The orientation. The `to-tag` is the *local* tag of whoever receives the INVITE, because
424    /// the header was written by a party looking at that dialog from the other side.
425    #[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        // Swapped, which is the mistake that makes every legitimate transfer fail.
437        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    /// The security case. A `Call-ID` is visible to everything on the path; the tags are not.
446    /// Matching on the `Call-ID` alone would let anyone who had seen one message of a call ask
447    /// to be put in the middle of it.
448    #[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    /// RFC 4488 §3: the transferor *requests* suppression and the transferee *agrees*. Both halves
543    /// are required, and that is the whole subtlety — a transferor that assumed agreement would
544    /// stop watching for notifications the transferee is still sending.
545    #[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    /// The implicit subscription now reads `Subscription-State` through the event framework, so
567    /// there is one answer to "is this over" rather than two that can disagree.
568    #[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        // And a value the framework cannot parse is not a termination — a NOTIFY nobody can read
575        // must not be taken as the end of a transfer.
576        assert!(!is_terminated(b"finished"));
577        assert!(!is_terminated(b""));
578    }
579}