1use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum SipAuthError {
9 Empty,
11 InvalidFormat(String),
13}
14
15impl fmt::Display for SipAuthError {
16 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 match self {
18 Self::Empty => write!(f, "empty authentication value"),
19 Self::InvalidFormat(msg) => write!(f, "invalid authentication format: {}", msg),
20 }
21 }
22}
23
24impl std::error::Error for SipAuthError {}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct SipAuthValue {
35 scheme: String,
36 params: Vec<(String, String)>,
37}
38
39impl SipAuthValue {
40 pub fn scheme(&self) -> &str {
42 &self.scheme
43 }
44
45 pub fn params(&self) -> &[(String, String)] {
49 &self.params
50 }
51
52 pub fn param(&self, key: &str) -> Option<&str> {
56 let key_lower = key.to_ascii_lowercase();
57 self.params
58 .iter()
59 .find(|(k, _)| k == &key_lower)
60 .map(|(_, v)| v.as_str())
61 }
62
63 pub fn realm(&self) -> Option<&str> {
65 self.param("realm")
66 }
67
68 pub fn nonce(&self) -> Option<&str> {
70 self.param("nonce")
71 }
72
73 pub fn algorithm(&self) -> Option<&str> {
75 self.param("algorithm")
76 }
77
78 pub fn username(&self) -> Option<&str> {
80 self.param("username")
81 }
82
83 pub fn opaque(&self) -> Option<&str> {
85 self.param("opaque")
86 }
87
88 pub fn qop(&self) -> Option<&str> {
90 self.param("qop")
91 }
92}
93
94impl SipAuthValue {
95 pub fn parse(s: &str) -> Result<Self, SipAuthError> {
97 let s = s.trim();
98 if s.is_empty() {
99 return Err(SipAuthError::Empty);
100 }
101
102 let (scheme, rest) = match s.split_once(|c: char| c.is_ascii_whitespace()) {
104 Some((scheme, rest)) => (scheme, rest.trim_start()),
105 None => {
106 return Ok(SipAuthValue {
108 scheme: s.to_string(),
109 params: Vec::new(),
110 });
111 }
112 };
113
114 let mut params = Vec::new();
115
116 for param_str in crate::split_comma_entries(rest) {
117 let param_str = param_str.trim();
118 if param_str.is_empty() {
119 continue;
120 }
121
122 let (key, value) = param_str
124 .split_once('=')
125 .ok_or_else(|| {
126 SipAuthError::InvalidFormat(format!("missing '=' in parameter: {}", param_str))
127 })?;
128
129 let key = key
130 .trim()
131 .to_ascii_lowercase();
132 let value = value.trim();
133
134 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
136 crate::unescape_quoted_pair(&value[1..value.len() - 1])
137 } else {
138 value.to_string()
139 };
140
141 params.push((key, value));
142 }
143
144 Ok(SipAuthValue {
145 scheme: scheme.to_string(),
146 params,
147 })
148 }
149}
150
151impl_from_str_via_parse!(SipAuthValue, SipAuthError);
152
153const MUST_QUOTE_PARAMS: &[&str] = &[
161 "realm", "domain", "nonce", "opaque", "username", "uri", "response", "cnonce",
162];
163
164impl fmt::Display for SipAuthValue {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 write!(f, "{}", self.scheme)?;
167
168 if !self
169 .params
170 .is_empty()
171 {
172 write!(f, " ")?;
173 for (i, (key, value)) in self
174 .params
175 .iter()
176 .enumerate()
177 {
178 if i > 0 {
179 write!(f, ", ")?;
180 }
181
182 if MUST_QUOTE_PARAMS.contains(&key.as_str())
183 || value.contains(|c: char| c.is_ascii_whitespace() || c == ',' || c == '"')
184 || value.is_empty()
185 {
186 write!(f, "{key}=")?;
187 crate::write_quoted_pair(f, value)?;
188 } else {
189 write!(f, "{key}={value}")?;
190 }
191 }
192 }
193
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn parse_digest_full() {
204 let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b", uri="sip:example.com", response="6629f""#;
205 let auth: SipAuthValue = input
206 .parse()
207 .unwrap();
208
209 assert_eq!(auth.scheme(), "Digest");
210 assert_eq!(auth.username(), Some("alice"));
211 assert_eq!(auth.realm(), Some("example.com"));
212 assert_eq!(auth.nonce(), Some("dcd98b"));
213 assert_eq!(auth.param("uri"), Some("sip:example.com"));
214 assert_eq!(auth.param("response"), Some("6629f"));
215 }
216
217 #[test]
218 fn parse_digest_with_algorithm() {
219 let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5, qop="auth""#;
220 let auth: SipAuthValue = input
221 .parse()
222 .unwrap();
223
224 assert_eq!(auth.scheme(), "Digest");
225 assert_eq!(auth.realm(), Some("example.com"));
226 assert_eq!(auth.nonce(), Some("abc123"));
227 assert_eq!(auth.algorithm(), Some("MD5"));
228 assert_eq!(auth.qop(), Some("auth"));
229 }
230
231 #[test]
232 fn parse_bearer_no_params() {
233 let input = "Bearer";
234 let auth: SipAuthValue = input
235 .parse()
236 .unwrap();
237
238 assert_eq!(auth.scheme(), "Bearer");
239 assert_eq!(
240 auth.params()
241 .len(),
242 0
243 );
244 }
245
246 #[test]
247 fn parse_scheme_with_single_param() {
248 let input = "Bearer token=abc123";
249 let auth: SipAuthValue = input
250 .parse()
251 .unwrap();
252
253 assert_eq!(auth.scheme(), "Bearer");
254 assert_eq!(auth.param("token"), Some("abc123"));
255 }
256
257 #[test]
258 fn parse_empty_input() {
259 let result: Result<SipAuthValue, _> = "".parse();
260 assert_eq!(result, Err(SipAuthError::Empty));
261
262 let result: Result<SipAuthValue, _> = " ".parse();
263 assert_eq!(result, Err(SipAuthError::Empty));
264 }
265
266 #[test]
267 fn parse_invalid_param() {
268 let input = "Digest username=alice, invalid";
269 let result: Result<SipAuthValue, _> = input.parse();
270 assert!(matches!(result, Err(SipAuthError::InvalidFormat(_))));
271 }
272
273 #[test]
274 fn display_roundtrip_quoted() {
275 let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b""#;
276 let auth: SipAuthValue = input
277 .parse()
278 .unwrap();
279 let output = auth.to_string();
280
281 let auth2: SipAuthValue = output
283 .parse()
284 .unwrap();
285 assert_eq!(auth, auth2);
286 }
287
288 #[test]
289 fn display_roundtrip_mixed() {
290 let input = r#"Digest realm="example.com", algorithm=MD5, qop="auth""#;
291 let auth: SipAuthValue = input
292 .parse()
293 .unwrap();
294 let output = auth.to_string();
295
296 let auth2: SipAuthValue = output
297 .parse()
298 .unwrap();
299 assert_eq!(auth, auth2);
300 }
301
302 #[test]
303 fn display_always_quotes_rfc_required_fields() {
304 let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5"#;
305 let auth: SipAuthValue = input
306 .parse()
307 .unwrap();
308 let output = auth.to_string();
309 assert!(output.contains(r#"realm="example.com""#));
310 assert!(output.contains(r#"nonce="abc123""#));
311 assert!(output.contains("algorithm=MD5"));
312 }
313
314 #[test]
315 fn display_quotes_opaque() {
316 let input = r#"Digest realm="example.com", opaque="5ccc""#;
317 let auth: SipAuthValue = input
318 .parse()
319 .unwrap();
320 let output = auth.to_string();
321 assert!(output.contains(r#"opaque="5ccc""#));
322 }
323
324 #[test]
325 fn param_lookup_case_insensitive() {
326 let input = r#"Digest Realm="example.com", NONCE="abc123""#;
327 let auth: SipAuthValue = input
328 .parse()
329 .unwrap();
330
331 assert_eq!(auth.param("realm"), Some("example.com"));
332 assert_eq!(auth.param("REALM"), Some("example.com"));
333 assert_eq!(auth.param("Realm"), Some("example.com"));
334 assert_eq!(auth.param("nonce"), Some("abc123"));
335 assert_eq!(auth.param("NONCE"), Some("abc123"));
336 }
337
338 #[test]
339 fn params_preserves_order() {
340 let input = r#"Digest username="alice", realm="example.com", nonce="test""#;
341 let auth: SipAuthValue = input
342 .parse()
343 .unwrap();
344
345 assert_eq!(
346 auth.params()
347 .len(),
348 3
349 );
350 assert_eq!(auth.params()[0].0, "username");
351 assert_eq!(auth.params()[1].0, "realm");
352 assert_eq!(auth.params()[2].0, "nonce");
353 }
354
355 #[test]
356 fn empty_param_value() {
357 let input = r#"Digest username="", realm="example.com""#;
358 let auth: SipAuthValue = input
359 .parse()
360 .unwrap();
361
362 assert_eq!(auth.username(), Some(""));
363 assert_eq!(auth.realm(), Some("example.com"));
364 }
365
366 #[test]
367 fn unquoted_param() {
368 let input = "Digest algorithm=MD5";
369 let auth: SipAuthValue = input
370 .parse()
371 .unwrap();
372
373 assert_eq!(auth.algorithm(), Some("MD5"));
374 }
375
376 #[test]
377 fn parse_digest_uri_with_comma() {
378 let input = r#"Digest uri="sip:example.com,transport=tcp", realm="test""#;
379 let auth: SipAuthValue = input
380 .parse()
381 .unwrap();
382 assert_eq!(auth.param("uri"), Some("sip:example.com,transport=tcp"));
383 assert_eq!(auth.realm(), Some("test"));
384 }
385
386 #[test]
387 fn parse_quoted_value_with_multiple_commas() {
388 let input = r#"Digest realm="a,b,c", nonce="test""#;
389 let auth: SipAuthValue = input
390 .parse()
391 .unwrap();
392 assert_eq!(auth.realm(), Some("a,b,c"));
393 assert_eq!(auth.nonce(), Some("test"));
394 }
395
396 #[test]
397 fn opaque_param() {
398 let input = r#"Digest realm="example.com", opaque="5ccc09c""#;
399 let auth: SipAuthValue = input
400 .parse()
401 .unwrap();
402
403 assert_eq!(auth.realm(), Some("example.com"));
404 assert_eq!(auth.opaque(), Some("5ccc09c"));
405 }
406
407 #[test]
408 fn unescape_quoted_pair_in_value() {
409 let input = r#"Digest realm="foo\"bar""#;
410 let auth: SipAuthValue = input
411 .parse()
412 .unwrap();
413 assert_eq!(auth.realm(), Some(r#"foo"bar"#));
414 }
415
416 #[test]
417 fn unescape_backslash_in_value() {
418 let input = r#"Digest realm="C:\\path""#;
419 let auth: SipAuthValue = input
420 .parse()
421 .unwrap();
422 assert_eq!(auth.realm(), Some(r#"C:\path"#));
423 }
424
425 #[test]
426 fn roundtrip_with_escaped_quotes() {
427 let input = r#"Digest realm="foo\"bar", nonce="test""#;
428 let auth: SipAuthValue = input
429 .parse()
430 .unwrap();
431 let output = auth.to_string();
432 let auth2: SipAuthValue = output
433 .parse()
434 .unwrap();
435 assert_eq!(auth, auth2);
436 }
437
438 #[test]
439 fn roundtrip_with_escaped_backslash() {
440 let input = r#"Digest realm="C:\\path", nonce="test""#;
441 let auth: SipAuthValue = input
442 .parse()
443 .unwrap();
444 let output = auth.to_string();
445 let auth2: SipAuthValue = output
446 .parse()
447 .unwrap();
448 assert_eq!(auth, auth2);
449 }
450
451 #[test]
452 fn qop_unquoted_in_display() {
453 let input = r#"Digest realm="example.com", qop="auth""#;
454 let auth: SipAuthValue = input
455 .parse()
456 .unwrap();
457 let output = auth.to_string();
458 assert!(output.contains("qop=auth"));
459 assert!(!output.contains("qop=\"auth\""));
460 }
461
462 #[test]
463 fn qop_quoted_when_contains_comma() {
464 let input = r#"Digest realm="example.com", qop="auth,auth-int""#;
465 let auth: SipAuthValue = input
466 .parse()
467 .unwrap();
468 let output = auth.to_string();
469 assert!(output.contains(r#"qop="auth,auth-int""#));
470 }
471}