freeswitch_sofia_trace_parser/sip/
json.rs1use std::borrow::Cow;
2
3use crate::sip::HasHeaders;
4use crate::types::{MimePart, ParsedSipMessage};
5
6impl ParsedSipMessage {
7 pub fn body_text(&self) -> Cow<'_, str> {
12 HasHeaders::body_text(self)
13 }
14
15 pub fn json_field(&self, key: &str) -> Option<String> {
19 HasHeaders::json_field(self, key)
20 }
21}
22
23impl MimePart {
24 pub fn body_text(&self) -> Cow<'_, str> {
28 HasHeaders::body_text(self)
29 }
30
31 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 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 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}