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)]
34#[non_exhaustive]
35pub struct SipAuthValue {
36 scheme: String,
37 params: Vec<(String, String)>,
38 quoted: Vec<bool>,
39 token68: Option<String>,
40}
41
42impl SipAuthValue {
43 pub fn scheme(&self) -> &str {
45 &self.scheme
46 }
47
48 pub fn token68(&self) -> Option<&str> {
51 self.token68
52 .as_deref()
53 }
54
55 pub fn params(&self) -> &[(String, String)] {
59 &self.params
60 }
61
62 pub fn param(&self, key: &str) -> Option<&str> {
66 let key_lower = key.to_ascii_lowercase();
67 self.params
68 .iter()
69 .find(|(k, _)| k == &key_lower)
70 .map(|(_, v)| v.as_str())
71 }
72
73 pub fn realm(&self) -> Option<&str> {
75 self.param("realm")
76 }
77
78 pub fn nonce(&self) -> Option<&str> {
80 self.param("nonce")
81 }
82
83 pub fn algorithm(&self) -> Option<&str> {
85 self.param("algorithm")
86 }
87
88 pub fn username(&self) -> Option<&str> {
90 self.param("username")
91 }
92
93 pub fn opaque(&self) -> Option<&str> {
95 self.param("opaque")
96 }
97
98 pub fn qop(&self) -> Option<&str> {
100 self.param("qop")
101 }
102}
103
104impl SipAuthValue {
105 pub fn parse(s: &str) -> Result<Self, SipAuthError> {
107 let s = s.trim();
108 if s.is_empty() {
109 return Err(SipAuthError::Empty);
110 }
111
112 let (scheme, rest) = match s.split_once(|c: char| c.is_ascii_whitespace()) {
114 Some((scheme, rest)) => (scheme, rest.trim_start()),
115 None => {
116 return Ok(SipAuthValue {
117 scheme: s.to_string(),
118 params: Vec::new(),
119 quoted: Vec::new(),
120 token68: None,
121 });
122 }
123 };
124
125 if is_token68(rest) {
126 return Ok(SipAuthValue {
127 scheme: scheme.to_string(),
128 params: Vec::new(),
129 quoted: Vec::new(),
130 token68: Some(rest.to_string()),
131 });
132 }
133
134 let mut params = Vec::new();
135 let mut quoted = Vec::new();
136
137 for (i, param_str) in crate::split_comma_entries(rest)
138 .into_iter()
139 .enumerate()
140 {
141 let param_str = param_str.trim();
142 if param_str.is_empty() {
143 continue;
144 }
145
146 let (key, value) = param_str
147 .split_once('=')
148 .ok_or_else(|| {
149 SipAuthError::InvalidFormat(format!("missing '=' in parameter {}", i + 1))
150 })?;
151
152 let key = key
153 .trim()
154 .to_ascii_lowercase();
155 let value = value.trim();
156
157 let (value, was_quoted) =
158 if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
159 (
160 crate::unescape_quoted_pair(&value[1..value.len() - 1]),
161 true,
162 )
163 } else {
164 (value.to_string(), false)
165 };
166
167 params.push((key, value));
168 quoted.push(was_quoted);
169 }
170
171 Ok(SipAuthValue {
172 scheme: scheme.to_string(),
173 params,
174 quoted,
175 token68: None,
176 })
177 }
178}
179
180fn is_token68(s: &str) -> bool {
183 let body = s.trim_end_matches('=');
184 !body.is_empty()
185 && body
186 .bytes()
187 .all(|b| b.is_ascii_alphanumeric() || b"-._~+/".contains(&b))
188}
189
190impl_from_str_via_parse!(SipAuthValue, SipAuthError);
191
192const MUST_QUOTE_PARAMS: &[&str] = &[
197 "realm", "domain", "nonce", "opaque", "username", "uri", "response", "cnonce",
198];
199
200impl fmt::Display for SipAuthValue {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 write!(f, "{}", self.scheme)?;
203
204 if let Some(token68) = &self.token68 {
205 return write!(f, " {token68}");
206 }
207
208 if !self
209 .params
210 .is_empty()
211 {
212 write!(f, " ")?;
213 for (i, ((key, value), &was_quoted)) in self
214 .params
215 .iter()
216 .zip(&self.quoted)
217 .enumerate()
218 {
219 if i > 0 {
220 write!(f, ", ")?;
221 }
222
223 if was_quoted
224 || MUST_QUOTE_PARAMS.contains(&key.as_str())
225 || value.contains(|c: char| c.is_ascii_whitespace() || c == ',' || c == '"')
226 || value.is_empty()
227 {
228 write!(f, "{key}=")?;
229 crate::write_quoted_pair(f, value)?;
230 } else {
231 write!(f, "{key}={value}")?;
232 }
233 }
234 }
235
236 Ok(())
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn parse_digest_full() {
246 let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b", uri="sip:example.com", response="6629f""#;
247 let auth: SipAuthValue = input
248 .parse()
249 .unwrap();
250
251 assert_eq!(auth.scheme(), "Digest");
252 assert_eq!(auth.username(), Some("alice"));
253 assert_eq!(auth.realm(), Some("example.com"));
254 assert_eq!(auth.nonce(), Some("dcd98b"));
255 assert_eq!(auth.param("uri"), Some("sip:example.com"));
256 assert_eq!(auth.param("response"), Some("6629f"));
257 }
258
259 #[test]
260 fn parse_digest_with_algorithm() {
261 let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5, qop="auth""#;
262 let auth: SipAuthValue = input
263 .parse()
264 .unwrap();
265
266 assert_eq!(auth.scheme(), "Digest");
267 assert_eq!(auth.realm(), Some("example.com"));
268 assert_eq!(auth.nonce(), Some("abc123"));
269 assert_eq!(auth.algorithm(), Some("MD5"));
270 assert_eq!(auth.qop(), Some("auth"));
271 }
272
273 #[test]
274 fn parse_bearer_no_params() {
275 let input = "Bearer";
276 let auth: SipAuthValue = input
277 .parse()
278 .unwrap();
279
280 assert_eq!(auth.scheme(), "Bearer");
281 assert_eq!(
282 auth.params()
283 .len(),
284 0
285 );
286 }
287
288 #[test]
289 fn parse_scheme_with_single_param() {
290 let input = "Bearer token=abc123";
291 let auth: SipAuthValue = input
292 .parse()
293 .unwrap();
294
295 assert_eq!(auth.scheme(), "Bearer");
296 assert_eq!(auth.param("token"), Some("abc123"));
297 }
298
299 #[test]
300 fn parse_empty_input() {
301 let result: Result<SipAuthValue, _> = "".parse();
302 assert_eq!(result, Err(SipAuthError::Empty));
303
304 let result: Result<SipAuthValue, _> = " ".parse();
305 assert_eq!(result, Err(SipAuthError::Empty));
306 }
307
308 #[test]
309 fn parse_invalid_param() {
310 let input = "Digest username=alice, invalid";
311 let result: Result<SipAuthValue, _> = input.parse();
312 assert!(matches!(result, Err(SipAuthError::InvalidFormat(_))));
313 }
314
315 #[test]
316 fn display_roundtrip_quoted() {
317 let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b""#;
318 let auth: SipAuthValue = input
319 .parse()
320 .unwrap();
321 let output = auth.to_string();
322
323 let auth2: SipAuthValue = output
325 .parse()
326 .unwrap();
327 assert_eq!(auth, auth2);
328 }
329
330 #[test]
331 fn display_roundtrip_mixed() {
332 let input = r#"Digest realm="example.com", algorithm=MD5, qop="auth""#;
333 let auth: SipAuthValue = input
334 .parse()
335 .unwrap();
336 let output = auth.to_string();
337
338 let auth2: SipAuthValue = output
339 .parse()
340 .unwrap();
341 assert_eq!(auth, auth2);
342 }
343
344 #[test]
345 fn display_always_quotes_rfc_required_fields() {
346 let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5"#;
347 let auth: SipAuthValue = input
348 .parse()
349 .unwrap();
350 let output = auth.to_string();
351 assert!(output.contains(r#"realm="example.com""#));
352 assert!(output.contains(r#"nonce="abc123""#));
353 assert!(output.contains("algorithm=MD5"));
354 }
355
356 #[test]
357 fn display_quotes_opaque() {
358 let input = r#"Digest realm="example.com", opaque="5ccc""#;
359 let auth: SipAuthValue = input
360 .parse()
361 .unwrap();
362 let output = auth.to_string();
363 assert!(output.contains(r#"opaque="5ccc""#));
364 }
365
366 #[test]
367 fn param_lookup_case_insensitive() {
368 let input = r#"Digest Realm="example.com", NONCE="abc123""#;
369 let auth: SipAuthValue = input
370 .parse()
371 .unwrap();
372
373 assert_eq!(auth.param("realm"), Some("example.com"));
374 assert_eq!(auth.param("REALM"), Some("example.com"));
375 assert_eq!(auth.param("Realm"), Some("example.com"));
376 assert_eq!(auth.param("nonce"), Some("abc123"));
377 assert_eq!(auth.param("NONCE"), Some("abc123"));
378 }
379
380 #[test]
381 fn params_preserves_order() {
382 let input = r#"Digest username="alice", realm="example.com", nonce="test""#;
383 let auth: SipAuthValue = input
384 .parse()
385 .unwrap();
386
387 assert_eq!(
388 auth.params()
389 .len(),
390 3
391 );
392 assert_eq!(auth.params()[0].0, "username");
393 assert_eq!(auth.params()[1].0, "realm");
394 assert_eq!(auth.params()[2].0, "nonce");
395 }
396
397 #[test]
398 fn empty_param_value() {
399 let input = r#"Digest username="", realm="example.com""#;
400 let auth: SipAuthValue = input
401 .parse()
402 .unwrap();
403
404 assert_eq!(auth.username(), Some(""));
405 assert_eq!(auth.realm(), Some("example.com"));
406 }
407
408 #[test]
409 fn unquoted_param() {
410 let input = "Digest algorithm=MD5";
411 let auth: SipAuthValue = input
412 .parse()
413 .unwrap();
414
415 assert_eq!(auth.algorithm(), Some("MD5"));
416 }
417
418 #[test]
419 fn parse_digest_uri_with_comma() {
420 let input = r#"Digest uri="sip:example.com,transport=tcp", realm="test""#;
421 let auth: SipAuthValue = input
422 .parse()
423 .unwrap();
424 assert_eq!(auth.param("uri"), Some("sip:example.com,transport=tcp"));
425 assert_eq!(auth.realm(), Some("test"));
426 }
427
428 #[test]
429 fn parse_quoted_value_with_multiple_commas() {
430 let input = r#"Digest realm="a,b,c", nonce="test""#;
431 let auth: SipAuthValue = input
432 .parse()
433 .unwrap();
434 assert_eq!(auth.realm(), Some("a,b,c"));
435 assert_eq!(auth.nonce(), Some("test"));
436 }
437
438 #[test]
439 fn opaque_param() {
440 let input = r#"Digest realm="example.com", opaque="5ccc09c""#;
441 let auth: SipAuthValue = input
442 .parse()
443 .unwrap();
444
445 assert_eq!(auth.realm(), Some("example.com"));
446 assert_eq!(auth.opaque(), Some("5ccc09c"));
447 }
448
449 #[test]
450 fn unescape_quoted_pair_in_value() {
451 let input = r#"Digest realm="foo\"bar""#;
452 let auth: SipAuthValue = input
453 .parse()
454 .unwrap();
455 assert_eq!(auth.realm(), Some(r#"foo"bar"#));
456 }
457
458 #[test]
459 fn unescape_backslash_in_value() {
460 let input = r#"Digest realm="C:\\path""#;
461 let auth: SipAuthValue = input
462 .parse()
463 .unwrap();
464 assert_eq!(auth.realm(), Some(r#"C:\path"#));
465 }
466
467 #[test]
468 fn roundtrip_with_escaped_quotes() {
469 let input = r#"Digest realm="foo\"bar", nonce="test""#;
470 let auth: SipAuthValue = input
471 .parse()
472 .unwrap();
473 let output = auth.to_string();
474 let auth2: SipAuthValue = output
475 .parse()
476 .unwrap();
477 assert_eq!(auth, auth2);
478 }
479
480 #[test]
481 fn roundtrip_with_escaped_backslash() {
482 let input = r#"Digest realm="C:\\path", nonce="test""#;
483 let auth: SipAuthValue = input
484 .parse()
485 .unwrap();
486 let output = auth.to_string();
487 let auth2: SipAuthValue = output
488 .parse()
489 .unwrap();
490 assert_eq!(auth, auth2);
491 }
492
493 #[test]
494 fn challenge_qop_stays_quoted() {
495 let input = r#"Digest realm="example.com", qop="auth""#;
496 let auth: SipAuthValue = input
497 .parse()
498 .unwrap();
499 assert_eq!(auth.qop(), Some("auth"));
500 assert_eq!(auth.to_string(), input);
501 }
502
503 #[test]
504 fn credential_qop_stays_unquoted() {
505 let input = r#"Digest username="alice", realm="example.com", qop=auth, nc=00000001"#;
506 let auth: SipAuthValue = input
507 .parse()
508 .unwrap();
509 assert_eq!(auth.to_string(), input);
510 }
511
512 #[test]
513 fn qop_quoted_when_contains_comma() {
514 let input = r#"Digest realm="example.com", qop="auth,auth-int""#;
515 let auth: SipAuthValue = input
516 .parse()
517 .unwrap();
518 let output = auth.to_string();
519 assert!(output.contains(r#"qop="auth,auth-int""#));
520 }
521
522 #[test]
523 fn bearer_token68() {
524 let input = "Bearer mF_9.B5f-4.1JqM";
525 let auth: SipAuthValue = input
526 .parse()
527 .unwrap();
528 assert_eq!(auth.scheme(), "Bearer");
529 assert_eq!(auth.token68(), Some("mF_9.B5f-4.1JqM"));
530 assert!(auth
531 .params()
532 .is_empty());
533 assert_eq!(auth.to_string(), input);
534 }
535
536 #[test]
537 fn bearer_token68_with_padding() {
538 let input = "Bearer abc==";
539 let auth: SipAuthValue = input
540 .parse()
541 .unwrap();
542 assert_eq!(auth.token68(), Some("abc=="));
543 assert!(auth
544 .params()
545 .is_empty());
546 assert_eq!(auth.to_string(), input);
547 }
548
549 #[test]
550 fn token68_charset() {
551 let auth: SipAuthValue = "Bearer a-._~+/Z9="
552 .parse()
553 .unwrap();
554 assert_eq!(auth.token68(), Some("a-._~+/Z9="));
555 }
556
557 #[test]
558 fn auth_params_are_not_token68() {
559 let auth: SipAuthValue = r#"Digest realm="x""#
560 .parse()
561 .unwrap();
562 assert_eq!(auth.token68(), None);
563 assert_eq!(auth.realm(), Some("x"));
564
565 let auth: SipAuthValue = "Bearer token=abc123"
566 .parse()
567 .unwrap();
568 assert_eq!(auth.token68(), None);
569 assert_eq!(auth.param("token"), Some("abc123"));
570 }
571
572 #[test]
573 fn scheme_only_has_no_token68() {
574 let auth: SipAuthValue = "Bearer"
575 .parse()
576 .unwrap();
577 assert_eq!(auth.token68(), None);
578 }
579
580 #[test]
581 fn error_display_omits_param_bytes() {
582 let err = "Digest username=alice, secretvalue"
583 .parse::<SipAuthValue>()
584 .unwrap_err();
585 let msg = err.to_string();
586 assert!(!msg.contains("secretvalue"));
587 assert!(msg.contains('2'));
588 }
589}