Skip to main content

freeswitch_sofia_trace_parser/sip/
json.rs

1use std::borrow::Cow;
2
3use crate::sip::HasHeaders;
4use crate::types::{MimePart, ParsedSipMessage};
5
6impl ParsedSipMessage {
7    /// Content-type-aware body text. For JSON content types (`application/json`
8    /// and `application/*+json`), unescapes RFC 8259 string sequences
9    /// (`\r\n` to CRLF, `\t` to tab, `\uXXXX` to Unicode). Passthrough for
10    /// all other content types.
11    pub fn body_text(&self) -> Cow<'_, str> {
12        HasHeaders::body_text(self)
13    }
14
15    /// Parse the body as JSON and return the unescaped string value of a
16    /// top-level key. Returns `None` if the content type is not JSON, the
17    /// body is invalid JSON, the key is missing, or the value is not a string.
18    pub fn json_field(&self, key: &str) -> Option<String> {
19        HasHeaders::json_field(self, key)
20    }
21}
22
23impl MimePart {
24    /// Content-type-aware body text, as for
25    /// [`ParsedSipMessage::body_text`]: JSON parts come back unescaped,
26    /// everything else passes through as lossy UTF-8.
27    pub fn body_text(&self) -> Cow<'_, str> {
28        HasHeaders::body_text(self)
29    }
30
31    /// Parse this part's body as JSON and return the unescaped string value of
32    /// a top-level key, as for [`ParsedSipMessage::json_field`].
33    pub fn json_field(&self, key: &str) -> Option<String> {
34        HasHeaders::json_field(self, key)
35    }
36}
37
38pub(crate) fn unescape_json_body(input: &[u8]) -> String {
39    let s = String::from_utf8_lossy(input);
40    let mut out = String::with_capacity(s.len());
41    let mut chars = s.chars();
42
43    while let Some(c) = chars.next() {
44        if c != '\\' {
45            out.push(c);
46            continue;
47        }
48        match chars.next() {
49            Some('"') => out.push('"'),
50            Some('\\') => out.push('\\'),
51            Some('/') => out.push('/'),
52            Some('b') => out.push('\x08'),
53            Some('f') => out.push('\x0C'),
54            Some('n') => out.push('\n'),
55            Some('r') => out.push('\r'),
56            Some('t') => out.push('\t'),
57            Some('u') => unescape_unicode(&mut chars, &mut out),
58            Some(other) => {
59                out.push('\\');
60                out.push(other);
61            }
62            None => out.push('\\'),
63        }
64    }
65    out
66}
67
68fn unescape_unicode(chars: &mut std::str::Chars<'_>, out: &mut String) {
69    let hex: String = chars.by_ref().take(4).collect();
70    let Some(code_point) = parse_hex4(&hex) else {
71        out.push_str("\\u");
72        out.push_str(&hex);
73        return;
74    };
75
76    if (0xD800..=0xDBFF).contains(&code_point) {
77        let mut peek = chars.clone();
78        if peek.next() == Some('\\') && peek.next() == Some('u') {
79            let hex2: String = peek.by_ref().take(4).collect();
80            if let Some(low) = parse_hex4(&hex2) {
81                if (0xDC00..=0xDFFF).contains(&low) {
82                    let combined =
83                        0x10000 + ((code_point as u32 - 0xD800) << 10) + (low as u32 - 0xDC00);
84                    if let Some(ch) = char::from_u32(combined) {
85                        out.push(ch);
86                        *chars = peek;
87                        return;
88                    }
89                }
90            }
91        }
92        out.push_str("\\u");
93        out.push_str(&hex);
94    } else if let Some(ch) = char::from_u32(code_point as u32) {
95        out.push(ch);
96    } else {
97        out.push_str("\\u");
98        out.push_str(&hex);
99    }
100}
101
102fn parse_hex4(hex: &str) -> Option<u16> {
103    if hex.len() == 4 {
104        u16::from_str_radix(hex, 16).ok()
105    } else {
106        None
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::sip::test_support::parsed_with_headers;
114
115    #[test]
116    fn unescape_json_basic_escapes() {
117        let input = br#"{"key":"line1\r\nline2\ttab\"\\"}"#;
118        let result = unescape_json_body(input);
119        assert!(
120            result.contains("line1\r\nline2\ttab\"\\"),
121            "basic escapes not unescaped: {result:?}"
122        );
123    }
124
125    #[test]
126    fn unescape_json_slash_and_control() {
127        let input = br#"{"a":"\/path","b":"\b\f"}"#;
128        let result = unescape_json_body(input);
129        assert!(result.contains("/path"), "\\/ should become /");
130        assert!(result.contains('\x08'), "\\b should become backspace");
131        assert!(result.contains('\x0C'), "\\f should become form feed");
132    }
133
134    #[test]
135    fn unescape_json_unicode_basic() {
136        // \u0041 = 'A'
137        let input = br#"{"x":"\u0041"}"#;
138        let result = unescape_json_body(input);
139        assert!(
140            result.contains('A'),
141            "\\u0041 should become 'A': {result:?}"
142        );
143    }
144
145    #[test]
146    fn unescape_json_unicode_surrogate_pair() {
147        // U+1F600 (grinning face) = \uD83D\uDE00
148        let input = br#"{"emoji":"\uD83D\uDE00"}"#;
149        let result = unescape_json_body(input);
150        assert!(
151            result.contains('\u{1F600}'),
152            "surrogate pair should produce U+1F600: {result:?}"
153        );
154    }
155
156    #[test]
157    fn unescape_json_passthrough_non_escape() {
158        let input = b"no escapes here";
159        let result = unescape_json_body(input);
160        assert_eq!(result, "no escapes here");
161    }
162
163    #[test]
164    fn body_text_non_json_passthrough() {
165        let msg = parsed_with_headers(
166            "bt-sdp",
167            &["Content-Type: application/sdp"],
168            b"v=0\r\ns=-\r\n",
169        );
170        assert_eq!(msg.body_text().as_ref(), msg.body_data().as_ref());
171    }
172
173    #[test]
174    fn body_text_json_unescapes_newlines() {
175        let msg = parsed_with_headers(
176            "bt-json",
177            &["Content-Type: application/json"],
178            br#"{"invite":"INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"}"#,
179        );
180        let text = msg.body_text();
181        assert!(
182            text.contains("INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"),
183            "JSON \\r\\n should be unescaped to actual CRLF, got: {text:?}"
184        );
185    }
186
187    #[test]
188    fn body_text_plus_json_content_type() {
189        let msg = parsed_with_headers(
190            "bt-plus-json",
191            &["Content-Type: application/emergencyCallData.AbandonedCall+json"],
192            br#"{"invite":"line1\nline2"}"#,
193        );
194        let text = msg.body_text();
195        assert!(
196            text.contains("line1\nline2"),
197            "application/*+json should trigger unescaping, got: {text:?}"
198        );
199    }
200
201    #[test]
202    fn json_field_extract_string() {
203        let parsed = parsed_with_headers(
204            "jf-test",
205            &["Content-Type: application/json"],
206            br#"{"event":"AbandonedCall","id":"123"}"#,
207        );
208        assert_eq!(
209            parsed.json_field("event"),
210            Some("AbandonedCall".to_string())
211        );
212        assert_eq!(parsed.json_field("id"), Some("123".to_string()));
213    }
214
215    #[test]
216    fn json_field_missing_key() {
217        let parsed = parsed_with_headers(
218            "jf-miss",
219            &["Content-Type: application/json"],
220            br#"{"event":"AbandonedCall"}"#,
221        );
222        assert_eq!(parsed.json_field("nonexistent"), None);
223    }
224
225    #[test]
226    fn json_field_non_string_value() {
227        let parsed = parsed_with_headers(
228            "jf-nonstr",
229            &["Content-Type: application/json"],
230            br#"{"count":42,"active":true}"#,
231        );
232        assert_eq!(parsed.json_field("count"), None);
233        assert_eq!(parsed.json_field("active"), None);
234    }
235
236    #[test]
237    fn json_field_non_json_content_type() {
238        let parsed = parsed_with_headers(
239            "jf-nonjson",
240            &["Content-Type: text/plain"],
241            br#"{"event":"AbandonedCall"}"#,
242        );
243        assert_eq!(parsed.json_field("event"), None);
244    }
245
246    #[test]
247    fn json_field_unescapes_value() {
248        let parsed = parsed_with_headers(
249            "jf-unescape",
250            &["Content-Type: application/json"],
251            br#"{"invite":"INVITE sip:host\r\nTo: <sip:host>\r\n"}"#,
252        );
253        let invite = parsed.json_field("invite").unwrap();
254        assert!(
255            invite.contains("INVITE sip:host\r\nTo: <sip:host>\r\n"),
256            "json_field should return unescaped string: {invite:?}"
257        );
258    }
259
260    #[test]
261    fn json_field_plus_json_content_type() {
262        let parsed = parsed_with_headers(
263            "jf-plus",
264            &["Content-Type: application/emergencyCallData.AbandonedCall+json"],
265            br#"{"cancelTimestamp":"2025-12-14T05:35:03.269Z"}"#,
266        );
267        assert_eq!(
268            parsed.json_field("cancelTimestamp"),
269            Some("2025-12-14T05:35:03.269Z".to_string())
270        );
271    }
272}