Skip to main content

sip_header/
target_dialog.rs

1//! RFC 4538 `Target-Dialog` header parser.
2
3use std::fmt;
4
5use crate::replaces::{
6    decode_uri_header_value, parse_dialog_id, validate_call_id, write_params, DialogIdError,
7};
8
9/// Error parsing a Target-Dialog header.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum SipTargetDialogError {
13    /// The Target-Dialog header value is empty.
14    Empty,
15    /// The Target-Dialog header value has an invalid format.
16    InvalidFormat(String),
17}
18
19impl fmt::Display for SipTargetDialogError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::Empty => write!(f, "Target-Dialog header is empty"),
23            Self::InvalidFormat(msg) => write!(f, "Invalid Target-Dialog format: {}", msg),
24        }
25    }
26}
27
28impl std::error::Error for SipTargetDialogError {}
29
30impl From<DialogIdError> for SipTargetDialogError {
31    fn from(e: DialogIdError) -> Self {
32        match e {
33            DialogIdError::Empty => Self::Empty,
34            DialogIdError::Invalid(msg) => Self::InvalidFormat(msg),
35        }
36    }
37}
38
39/// A parsed `Target-Dialog` header value (RFC 4538 §7).
40///
41/// Identifies an existing dialog: Call-ID plus the mandatory `local-tag`
42/// and `remote-tag`, both from the perspective of the request recipient.
43#[derive(Debug, Clone, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct SipTargetDialog {
46    call_id: String,
47    local_tag: String,
48    remote_tag: String,
49    params: Vec<(String, Option<String>)>,
50    uri_header_framing: bool,
51}
52
53impl SipTargetDialog {
54    /// Parse a wire-form header value: `callid;local-tag=x;remote-tag=y`.
55    pub fn parse(raw: &str) -> Result<Self, SipTargetDialogError> {
56        let id = parse_dialog_id(raw, "local-tag", "remote-tag", false)?;
57        Ok(Self {
58            call_id: id.call_id,
59            local_tag: id.first_tag,
60            remote_tag: id.second_tag,
61            params: id.params,
62            uri_header_framing: false,
63        })
64    }
65
66    /// Parse the percent-encoded framing found in a URI header,
67    /// e.g. `callid%40host%3Blocal-tag%3Dx%3Bremote-tag%3Dy`.
68    ///
69    /// Accepts the canonicalised value returned by
70    /// [`sip_uri::SipUri::header`]; [`Display`](fmt::Display) re-encodes to
71    /// that same canonical form (uppercase hex).
72    pub fn parse_uri_header(raw: &str) -> Result<Self, SipTargetDialogError> {
73        let decoded = decode_uri_header_value(raw)?;
74        let mut parsed = Self::parse(&decoded)?;
75        parsed.uri_header_framing = true;
76        Ok(parsed)
77    }
78
79    /// The Call-ID of the target dialog.
80    pub fn call_id(&self) -> &str {
81        &self.call_id
82    }
83
84    /// Returns this value with a different Call-ID.
85    ///
86    /// Framing, both tags and all generic parameters are preserved, so
87    /// [`Display`](fmt::Display) re-emits the parsed input with only the
88    /// Call-ID changed.
89    ///
90    /// Errors unless `call_id` is an RFC 3261 §25.1
91    /// `callid = word [ "@" word ]`. [`parse`](Self::parse) is lenient about
92    /// this token; a value that never came off the wire is not.
93    ///
94    /// ```
95    /// use sip_header::SipTargetDialog;
96    ///
97    /// let t = SipTargetDialog::parse("abc@203.0.113.5;local-tag=l1;remote-tag=r1")?
98    ///     .with_call_id("abc@example.com")?;
99    /// assert_eq!(t.to_string(), "abc@example.com;local-tag=l1;remote-tag=r1");
100    /// # Ok::<(), sip_header::SipTargetDialogError>(())
101    /// ```
102    pub fn with_call_id(
103        mut self,
104        call_id: impl Into<String>,
105    ) -> Result<Self, SipTargetDialogError> {
106        let call_id = call_id.into();
107        validate_call_id(&call_id)?;
108        self.call_id = call_id;
109        Ok(self)
110    }
111
112    /// The host part of the Call-ID (after `@`), if present.
113    pub fn host(&self) -> Option<&str> {
114        self.call_id
115            .split_once('@')
116            .map(|(_, host)| host)
117    }
118
119    /// The mandatory `local-tag` value.
120    pub fn local_tag(&self) -> &str {
121        &self.local_tag
122    }
123
124    /// The mandatory `remote-tag` value.
125    pub fn remote_tag(&self) -> &str {
126        &self.remote_tag
127    }
128
129    /// Returns all generic parameters (tags excluded).
130    pub fn params(&self) -> &[(String, Option<String>)] {
131        &self.params
132    }
133
134    /// Returns a specific generic parameter by key (case-insensitive).
135    pub fn param(&self, key: &str) -> Option<Option<&str>> {
136        let key_lower = key.to_ascii_lowercase();
137        self.params
138            .iter()
139            .find(|(k, _)| k == &key_lower)
140            .map(|(_, v)| v.as_deref())
141    }
142
143    fn wire_form(&self) -> String {
144        let mut s = format!(
145            "{};local-tag={};remote-tag={}",
146            self.call_id, self.local_tag, self.remote_tag
147        );
148        write_params(&mut s, &self.params);
149        s
150    }
151}
152
153impl fmt::Display for SipTargetDialog {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        let wire = self.wire_form();
156        if self.uri_header_framing {
157            f.write_str(&sip_uri::encode_uri_header(&wire))
158        } else {
159            f.write_str(&wire)
160        }
161    }
162}
163
164impl_from_str_via_parse!(SipTargetDialog, SipTargetDialogError);
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn parse_basic() {
172        let t = SipTargetDialog::parse("abc123@203.0.113.5;local-tag=l1;remote-tag=r1").unwrap();
173        assert_eq!(t.call_id(), "abc123@203.0.113.5");
174        assert_eq!(t.host(), Some("203.0.113.5"));
175        assert_eq!(t.local_tag(), "l1");
176        assert_eq!(t.remote_tag(), "r1");
177    }
178
179    #[test]
180    fn missing_local_tag_fails() {
181        assert!(SipTargetDialog::parse("abc@example.com;remote-tag=r1").is_err());
182    }
183
184    #[test]
185    fn missing_remote_tag_fails() {
186        assert!(SipTargetDialog::parse("abc@example.com;local-tag=l1").is_err());
187    }
188
189    #[test]
190    fn empty_fails() {
191        assert!(matches!(
192            SipTargetDialog::parse(""),
193            Err(SipTargetDialogError::Empty)
194        ));
195    }
196
197    #[test]
198    fn generic_params_preserved() {
199        let t =
200            SipTargetDialog::parse("abc@example.com;local-tag=l1;remote-tag=r1;foo=bar").unwrap();
201        assert_eq!(t.param("foo"), Some(Some("bar")));
202    }
203
204    #[test]
205    fn parse_uri_header_encoded() {
206        let t = SipTargetDialog::parse_uri_header(
207            "abc123%40203.0.113.5%3Blocal-tag%3Dl1%3Bremote-tag%3Dr1",
208        )
209        .unwrap();
210        assert_eq!(t.host(), Some("203.0.113.5"));
211        assert_eq!(t.local_tag(), "l1");
212        assert_eq!(t.remote_tag(), "r1");
213    }
214
215    #[test]
216    fn display_roundtrip_wire() {
217        let input = "abc123@203.0.113.5;local-tag=l1;remote-tag=r1;foo=bar";
218        let t = SipTargetDialog::parse(input).unwrap();
219        assert_eq!(t.to_string(), input);
220        assert_eq!(SipTargetDialog::parse(&t.to_string()).unwrap(), t);
221    }
222
223    #[test]
224    fn display_roundtrip_uri_header() {
225        let input = "abc123%40203.0.113.5%3Blocal-tag%3Dl1%3Bremote-tag%3Dr1";
226        let t = SipTargetDialog::parse_uri_header(input).unwrap();
227        assert_eq!(t.to_string(), input);
228    }
229
230    #[test]
231    fn with_call_id_wire_changes_only_call_id() {
232        let input = "abc123@203.0.113.5;local-tag=l1;remote-tag=r1;foo=bar";
233        let t = SipTargetDialog::parse(input)
234            .unwrap()
235            .with_call_id("xyz789@example.com")
236            .unwrap();
237        assert_eq!(
238            t.to_string(),
239            "xyz789@example.com;local-tag=l1;remote-tag=r1;foo=bar"
240        );
241    }
242
243    #[test]
244    fn with_call_id_keeps_uri_header_framing() {
245        let input = "abc123%40203.0.113.5%3Blocal-tag%3Dl1%3Bremote-tag%3Dr1";
246        let t = SipTargetDialog::parse_uri_header(input)
247            .unwrap()
248            .with_call_id("abc123@example.com")
249            .unwrap();
250        assert_eq!(
251            t.to_string(),
252            "abc123%40example.com%3Blocal-tag%3Dl1%3Bremote-tag%3Dr1"
253        );
254    }
255
256    #[test]
257    fn with_call_id_rejects_non_word() {
258        let t = SipTargetDialog::parse("abc@example.com;local-tag=l1;remote-tag=r1").unwrap();
259        for bad in ["", "a;local-tag=l2", "a b", "a@b@c", "@b"] {
260            assert!(
261                t.clone()
262                    .with_call_id(bad)
263                    .is_err(),
264                "accepted {bad:?}"
265            );
266        }
267    }
268
269    #[test]
270    fn from_str_is_wire_framing() {
271        let t: SipTargetDialog = "abc123@203.0.113.5;local-tag=l1;remote-tag=r1"
272            .parse()
273            .unwrap();
274        assert_eq!(t.local_tag(), "l1");
275    }
276}