Skip to main content

freeswitch_types/variables/
sip_passthrough.rs

1//! FreeSWITCH SIP header passthrough variables (`sip_h_*`, `sip_i_*`, etc.).
2//!
3//! FreeSWITCH exposes SIP headers as channel variables through six prefixes,
4//! each controlling a different direction or SIP method:
5//!
6//! | Prefix | Wire example | Purpose |
7//! |---|---|---|
8//! | `sip_i_` | `sip_i_call_info` | Read incoming INVITE headers |
9//! | `sip_h_` | `sip_h_Call-Info` | Inject header on outgoing request |
10//! | `sip_rh_` | `sip_rh_Call-Info` | Inject header on outgoing response |
11//! | `sip_ph_` | `sip_ph_Call-Info` | Inject header on provisional response |
12//! | `sip_bye_h_` | `sip_bye_h_Call-Info` | Inject header on BYE |
13//! | `sip_nobye_h_` | `sip_nobye_h_Call-Info` | Suppress header on BYE |
14//!
15//! The `Invite` prefix uses a different wire format from the others:
16//! lowercase with hyphens replaced by underscores (`sip_i_call_info`),
17//! while all other prefixes preserve the canonical SIP header casing
18//! (`sip_h_Call-Info`).
19
20use sip_header::SipHeader;
21
22/// Error returned when a raw header name is empty or carries a wire terminator.
23///
24/// The rejected name stays on the field; `Display` names only what was wrong
25/// with it, since a header name a caller supplied can carry subscriber identity.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InvalidHeaderName(pub String);
28
29impl std::fmt::Display for InvalidHeaderName {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        if self
32            .0
33            .is_empty()
34        {
35            return f.write_str("empty SIP header name");
36        }
37        write!(
38            f,
39            "invalid SIP header name ({} bytes): contains CR or LF",
40            self.0
41                .len()
42        )
43    }
44}
45
46impl std::error::Error for InvalidHeaderName {}
47
48/// FreeSWITCH SIP header passthrough variable prefix.
49///
50/// Each prefix controls which SIP message direction the header variable
51/// applies to.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[non_exhaustive]
54pub enum SipHeaderPrefix {
55    /// `sip_i_` — raw INVITE header (read-only, set by `parse-all-invite-headers`).
56    Invite,
57    /// `sip_h_` — inject header on outgoing request (INVITE, REFER, etc.).
58    Request,
59    /// `sip_rh_` — inject header on outgoing response (200 OK, etc.).
60    Response,
61    /// `sip_ph_` — inject header on provisional response (180, 183).
62    Provisional,
63    /// `sip_bye_h_` — inject header on outgoing BYE.
64    Bye,
65    /// `sip_nobye_h_` — suppress a specific header on outgoing BYE.
66    NoBye,
67}
68
69impl SipHeaderPrefix {
70    /// Every prefix, in declaration order.
71    pub const ALL: &'static [Self] = &[
72        Self::Invite,
73        Self::Request,
74        Self::Response,
75        Self::Provisional,
76        Self::Bye,
77        Self::NoBye,
78    ];
79
80    /// Wire prefix string including trailing separator.
81    ///
82    /// ```
83    /// use freeswitch_types::SipHeaderPrefix;
84    ///
85    /// const PREFIX: &str = SipHeaderPrefix::Request.as_str();
86    /// assert_eq!(PREFIX, "sip_h_");
87    /// ```
88    pub const fn as_str(&self) -> &'static str {
89        match self {
90            Self::Invite => "sip_i_",
91            Self::Request => "sip_h_",
92            Self::Response => "sip_rh_",
93            Self::Provisional => "sip_ph_",
94            Self::Bye => "sip_bye_h_",
95            Self::NoBye => "sip_nobye_h_",
96        }
97    }
98}
99
100impl std::fmt::Display for SipHeaderPrefix {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106/// Error returned when parsing an unrecognized passthrough header variable name.
107///
108/// The rejected name stays on the field; `Display` names only which rule it
109/// broke, since a variable name off the wire can carry subscriber identity.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ParseSipPassthroughError(pub String);
112
113impl std::fmt::Display for ParseSipPassthroughError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let len = self
116            .0
117            .len();
118        if PREFIX_PATTERNS
119            .iter()
120            .any(|p| {
121                self.0
122                    .eq_ignore_ascii_case(p.as_str())
123            })
124        {
125            return write!(f, "SIP passthrough variable ({len} bytes): no header name");
126        }
127        write!(
128            f,
129            "not a SIP passthrough variable ({len} bytes): no recognised sip_*_ prefix"
130        )
131    }
132}
133
134impl std::error::Error for ParseSipPassthroughError {}
135
136/// A FreeSWITCH SIP passthrough header variable name.
137///
138/// Combines a [`SipHeaderPrefix`] (direction/method) with a SIP header name
139/// to produce the channel variable name used on the wire. Use with
140/// [`HeaderLookup::variable()`](crate::HeaderLookup::variable) for lookups,
141/// or insert into [`Variables`](crate::Variables) for originate commands.
142///
143/// # Typed constructors (known SIP headers)
144///
145/// ```
146/// use freeswitch_types::variables::{SipPassthroughHeader, SipHeaderPrefix};
147/// use sip_header::SipHeader;
148///
149/// let h = SipPassthroughHeader::request(SipHeader::CallInfo);
150/// assert_eq!(h.as_str(), "sip_h_Call-Info");
151///
152/// let h = SipPassthroughHeader::invite(SipHeader::CallInfo);
153/// assert_eq!(h.as_str(), "sip_i_call_info");
154/// ```
155///
156/// # Raw constructors (custom headers)
157///
158/// ```
159/// use freeswitch_types::variables::SipPassthroughHeader;
160///
161/// let h = SipPassthroughHeader::request_raw("X-Tenant").unwrap();
162/// assert_eq!(h.as_str(), "sip_h_X-Tenant");
163/// ```
164#[derive(Debug, Clone, PartialEq, Eq, Hash)]
165pub struct SipPassthroughHeader {
166    prefix: SipHeaderPrefix,
167    canonical_name: String,
168    wire: String,
169}
170
171/// Prefixes longest-first, so `FromStr` never matches a shorter one first.
172const PREFIX_PATTERNS: &[SipHeaderPrefix] = &[
173    SipHeaderPrefix::NoBye,
174    SipHeaderPrefix::Bye,
175    SipHeaderPrefix::Provisional,
176    SipHeaderPrefix::Response,
177    SipHeaderPrefix::Invite,
178    SipHeaderPrefix::Request,
179];
180
181/// Canonical header name for a wire suffix, reversing the `Invite` prefix's
182/// lowercase-and-underscore spelling.
183fn canonical_from_suffix(prefix: SipHeaderPrefix, suffix: &str) -> String {
184    match prefix {
185        SipHeaderPrefix::Invite => {
186            let with_hyphens = suffix.replace('_', "-");
187            match with_hyphens.parse::<SipHeader>() {
188                Ok(h) => h
189                    .as_str()
190                    .to_string(),
191                Err(_) => with_hyphens,
192            }
193        }
194        _ => match suffix.parse::<SipHeader>() {
195            Ok(h) => h
196                .as_str()
197                .to_string(),
198            Err(_) => suffix.to_string(),
199        },
200    }
201}
202
203fn validate_header_name(name: &str) -> Result<(), InvalidHeaderName> {
204    if name.is_empty() || crate::wire_safety::contains_wire_terminator(name) {
205        return Err(InvalidHeaderName(name.to_string()));
206    }
207    Ok(())
208}
209
210fn build_wire(prefix: SipHeaderPrefix, canonical: &str) -> String {
211    match prefix {
212        SipHeaderPrefix::Invite => {
213            let pfx = SipHeaderPrefix::Invite.as_str();
214            let mut wire = String::with_capacity(pfx.len() + canonical.len());
215            wire.push_str(pfx);
216            for ch in canonical.chars() {
217                if ch == '-' {
218                    wire.push('_');
219                } else {
220                    wire.push(ch.to_ascii_lowercase());
221                }
222            }
223            wire
224        }
225        _ => {
226            let pfx = prefix.as_str();
227            let mut wire = String::with_capacity(pfx.len() + canonical.len());
228            wire.push_str(pfx);
229            wire.push_str(canonical);
230            wire
231        }
232    }
233}
234
235impl SipPassthroughHeader {
236    /// Create from a prefix and a known [`SipHeader`].
237    pub fn new(prefix: SipHeaderPrefix, header: SipHeader) -> Self {
238        let canonical = header
239            .as_str()
240            .to_string();
241        let wire = build_wire(prefix, &canonical);
242        Self {
243            prefix,
244            canonical_name: canonical,
245            wire,
246        }
247    }
248
249    /// Create from a prefix and an arbitrary header name.
250    ///
251    /// Returns `Err` if the name contains `\n` or `\r` (wire injection risk).
252    pub fn new_raw(
253        prefix: SipHeaderPrefix,
254        name: impl Into<String>,
255    ) -> Result<Self, InvalidHeaderName> {
256        let canonical = name.into();
257        validate_header_name(&canonical)?;
258        let wire = build_wire(prefix, &canonical);
259        Ok(Self {
260            prefix,
261            canonical_name: canonical,
262            wire,
263        })
264    }
265
266    /// Incoming INVITE header (`sip_i_*`).
267    pub fn invite(header: SipHeader) -> Self {
268        Self::new(SipHeaderPrefix::Invite, header)
269    }
270
271    /// Incoming INVITE header from raw name.
272    pub fn invite_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
273        Self::new_raw(SipHeaderPrefix::Invite, name)
274    }
275
276    /// Outgoing request header (`sip_h_*`).
277    pub fn request(header: SipHeader) -> Self {
278        Self::new(SipHeaderPrefix::Request, header)
279    }
280
281    /// Outgoing request header from raw name.
282    pub fn request_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
283        Self::new_raw(SipHeaderPrefix::Request, name)
284    }
285
286    /// Outgoing response header (`sip_rh_*`).
287    pub fn response(header: SipHeader) -> Self {
288        Self::new(SipHeaderPrefix::Response, header)
289    }
290
291    /// Outgoing response header from raw name.
292    pub fn response_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
293        Self::new_raw(SipHeaderPrefix::Response, name)
294    }
295
296    /// Provisional response header (`sip_ph_*`).
297    pub fn provisional(header: SipHeader) -> Self {
298        Self::new(SipHeaderPrefix::Provisional, header)
299    }
300
301    /// Provisional response header from raw name.
302    pub fn provisional_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
303        Self::new_raw(SipHeaderPrefix::Provisional, name)
304    }
305
306    /// BYE request header (`sip_bye_h_*`).
307    pub fn bye(header: SipHeader) -> Self {
308        Self::new(SipHeaderPrefix::Bye, header)
309    }
310
311    /// BYE request header from raw name.
312    pub fn bye_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
313        Self::new_raw(SipHeaderPrefix::Bye, name)
314    }
315
316    /// Suppress header on BYE (`sip_nobye_h_*`).
317    pub fn no_bye(header: SipHeader) -> Self {
318        Self::new(SipHeaderPrefix::NoBye, header)
319    }
320
321    /// Suppress header on BYE from raw name.
322    pub fn no_bye_raw(name: impl Into<String>) -> Result<Self, InvalidHeaderName> {
323        Self::new_raw(SipHeaderPrefix::NoBye, name)
324    }
325
326    /// The prefix (direction/method) of this variable.
327    pub fn prefix(&self) -> SipHeaderPrefix {
328        self.prefix
329    }
330
331    /// The canonical SIP header name (e.g. `"Call-Info"`, `"X-Tenant"`).
332    pub fn canonical_name(&self) -> &str {
333        &self.canonical_name
334    }
335
336    /// The pre-computed wire variable name (e.g. `"sip_h_Call-Info"`).
337    pub fn as_str(&self) -> &str {
338        &self.wire
339    }
340
341    /// Extract this header's values from a raw SIP message.
342    ///
343    /// Returns each occurrence as a separate entry (RFC 3261 §7.3.1).
344    /// Delegates to [`sip_header::extract_header()`] using the canonical name.
345    pub fn extract_from(&self, message: &str) -> Vec<String> {
346        sip_header::extract_header(message, &self.canonical_name)
347    }
348
349    /// Whether this header may contain ARRAY-encoded values when read from FreeSWITCH.
350    ///
351    /// Only meaningful for the `Invite` prefix — FreeSWITCH stores multi-valued
352    /// incoming SIP headers in `ARRAY::val1|:val2` format. For other prefixes
353    /// (which are write-only), this always returns `false`.
354    pub fn is_array_header(&self) -> bool {
355        if self.prefix != SipHeaderPrefix::Invite {
356            return false;
357        }
358        self.canonical_name
359            .parse::<SipHeader>()
360            .map(|h| h.is_multi_valued())
361            .unwrap_or(false)
362    }
363}
364
365impl super::VariableName for SipPassthroughHeader {
366    fn as_str(&self) -> &str {
367        &self.wire
368    }
369}
370
371impl std::fmt::Display for SipPassthroughHeader {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        f.write_str(&self.wire)
374    }
375}
376
377impl AsRef<str> for SipPassthroughHeader {
378    fn as_ref(&self) -> &str {
379        &self.wire
380    }
381}
382
383impl From<SipPassthroughHeader> for String {
384    fn from(h: SipPassthroughHeader) -> Self {
385        h.wire
386    }
387}
388
389impl std::str::FromStr for SipPassthroughHeader {
390    type Err = ParseSipPassthroughError;
391
392    fn from_str(s: &str) -> Result<Self, Self::Err> {
393        for &prefix in PREFIX_PATTERNS {
394            let pfx = prefix.as_str();
395            if !s
396                .get(..pfx.len())
397                .is_some_and(|head| head.eq_ignore_ascii_case(pfx))
398            {
399                continue;
400            }
401            let suffix = &s[pfx.len()..];
402            if suffix.is_empty() {
403                return Err(ParseSipPassthroughError(s.to_string()));
404            }
405            let canonical = canonical_from_suffix(prefix, suffix);
406            let wire = match prefix {
407                SipHeaderPrefix::Invite => build_wire(prefix, &canonical),
408                _ => format!("{pfx}{suffix}"),
409            };
410            return Ok(Self {
411                prefix,
412                canonical_name: canonical,
413                wire,
414            });
415        }
416
417        Err(ParseSipPassthroughError(s.to_string()))
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    // --- Display / wire format ---
426
427    #[test]
428    fn invite_typed_wire_format() {
429        assert_eq!(
430            SipPassthroughHeader::invite(SipHeader::CallInfo).as_str(),
431            "sip_i_call_info"
432        );
433        assert_eq!(
434            SipPassthroughHeader::invite(SipHeader::PAssertedIdentity).as_str(),
435            "sip_i_p_asserted_identity"
436        );
437        assert_eq!(
438            SipPassthroughHeader::invite(SipHeader::Via).as_str(),
439            "sip_i_via"
440        );
441        assert_eq!(
442            SipPassthroughHeader::invite(SipHeader::CallId).as_str(),
443            "sip_i_call_id"
444        );
445    }
446
447    #[test]
448    fn request_typed_wire_format() {
449        assert_eq!(
450            SipPassthroughHeader::request(SipHeader::CallInfo).as_str(),
451            "sip_h_Call-Info"
452        );
453        assert_eq!(
454            SipPassthroughHeader::request(SipHeader::PAssertedIdentity).as_str(),
455            "sip_h_P-Asserted-Identity"
456        );
457    }
458
459    #[test]
460    fn response_typed_wire_format() {
461        assert_eq!(
462            SipPassthroughHeader::response(SipHeader::CallInfo).as_str(),
463            "sip_rh_Call-Info"
464        );
465    }
466
467    #[test]
468    fn provisional_typed_wire_format() {
469        assert_eq!(
470            SipPassthroughHeader::provisional(SipHeader::AlertInfo).as_str(),
471            "sip_ph_Alert-Info"
472        );
473    }
474
475    #[test]
476    fn bye_typed_wire_format() {
477        assert_eq!(
478            SipPassthroughHeader::bye(SipHeader::Reason).as_str(),
479            "sip_bye_h_Reason"
480        );
481    }
482
483    #[test]
484    fn no_bye_typed_wire_format() {
485        assert_eq!(
486            SipPassthroughHeader::no_bye(SipHeader::Reason).as_str(),
487            "sip_nobye_h_Reason"
488        );
489    }
490
491    #[test]
492    fn raw_custom_header() {
493        assert_eq!(
494            SipPassthroughHeader::request_raw("X-Tenant")
495                .unwrap()
496                .as_str(),
497            "sip_h_X-Tenant"
498        );
499        assert_eq!(
500            SipPassthroughHeader::invite_raw("X-Custom")
501                .unwrap()
502                .as_str(),
503            "sip_i_x_custom"
504        );
505    }
506
507    #[test]
508    fn raw_rejects_newlines() {
509        assert!(SipPassthroughHeader::request_raw("X-Bad\nHeader").is_err());
510        assert!(SipPassthroughHeader::request_raw("X-Bad\rHeader").is_err());
511        assert!(SipPassthroughHeader::request_raw("").is_err());
512    }
513
514    /// A rejected header name is caller-supplied and routinely carries
515    /// subscriber identity, so the value stays on the field.
516    #[test]
517    fn invalid_name_error_names_the_shape_not_the_value() {
518        let err = SipPassthroughHeader::request_raw("X-Tenant\nInjected").unwrap_err();
519        let msg = err.to_string();
520        assert!(!msg.contains("X-Tenant"), "error quoted its input: {msg}");
521        assert!(
522            msg.contains("17 bytes"),
523            "error does not give the byte length: {msg}"
524        );
525        assert_eq!(err.0, "X-Tenant\nInjected");
526    }
527
528    #[test]
529    fn empty_name_error_says_which_rule_it_broke() {
530        let msg = SipPassthroughHeader::request_raw("")
531            .unwrap_err()
532            .to_string();
533        assert!(msg.contains("empty"), "{msg}");
534    }
535
536    #[test]
537    fn parse_error_names_the_shape_not_the_value() {
538        let msg = "X-Tenant: secret"
539            .parse::<SipPassthroughHeader>()
540            .unwrap_err()
541            .to_string();
542        assert!(!msg.contains("secret"), "error quoted its input: {msg}");
543        assert!(
544            msg.contains("16 bytes"),
545            "error does not give the byte length: {msg}"
546        );
547    }
548
549    #[test]
550    fn parse_error_separates_a_missing_header_name_from_a_missing_prefix() {
551        let msg = "sip_h_"
552            .parse::<SipPassthroughHeader>()
553            .unwrap_err()
554            .to_string();
555        assert!(msg.contains("no header name"), "{msg}");
556    }
557
558    // --- Display trait ---
559
560    #[test]
561    fn display_matches_as_str() {
562        let h = SipPassthroughHeader::request(SipHeader::CallInfo);
563        assert_eq!(h.to_string(), h.as_str());
564    }
565
566    // --- FromStr round-trips ---
567
568    #[test]
569    fn from_str_request() {
570        let parsed: SipPassthroughHeader = "sip_h_Call-Info"
571            .parse()
572            .unwrap();
573        assert_eq!(parsed.prefix(), SipHeaderPrefix::Request);
574        assert_eq!(parsed.canonical_name(), "Call-Info");
575        assert_eq!(parsed.as_str(), "sip_h_Call-Info");
576    }
577
578    #[test]
579    fn from_str_invite() {
580        let parsed: SipPassthroughHeader = "sip_i_call_info"
581            .parse()
582            .unwrap();
583        assert_eq!(parsed.prefix(), SipHeaderPrefix::Invite);
584        assert_eq!(parsed.canonical_name(), "Call-Info");
585        assert_eq!(parsed.as_str(), "sip_i_call_info");
586    }
587
588    #[test]
589    fn from_str_invite_p_asserted_identity() {
590        let parsed: SipPassthroughHeader = "sip_i_p_asserted_identity"
591            .parse()
592            .unwrap();
593        assert_eq!(parsed.prefix(), SipHeaderPrefix::Invite);
594        assert_eq!(parsed.canonical_name(), "P-Asserted-Identity");
595    }
596
597    #[test]
598    fn from_str_response() {
599        let parsed: SipPassthroughHeader = "sip_rh_Call-Info"
600            .parse()
601            .unwrap();
602        assert_eq!(parsed.prefix(), SipHeaderPrefix::Response);
603        assert_eq!(parsed.canonical_name(), "Call-Info");
604    }
605
606    #[test]
607    fn from_str_bye() {
608        let parsed: SipPassthroughHeader = "sip_bye_h_Reason"
609            .parse()
610            .unwrap();
611        assert_eq!(parsed.prefix(), SipHeaderPrefix::Bye);
612        assert_eq!(parsed.canonical_name(), "Reason");
613    }
614
615    #[test]
616    fn from_str_no_bye() {
617        let parsed: SipPassthroughHeader = "sip_nobye_h_Reason"
618            .parse()
619            .unwrap();
620        assert_eq!(parsed.prefix(), SipHeaderPrefix::NoBye);
621        assert_eq!(parsed.canonical_name(), "Reason");
622    }
623
624    #[test]
625    fn from_str_unknown_custom_header() {
626        let parsed: SipPassthroughHeader = "sip_h_X-Tenant"
627            .parse()
628            .unwrap();
629        assert_eq!(parsed.prefix(), SipHeaderPrefix::Request);
630        assert_eq!(parsed.canonical_name(), "X-Tenant");
631    }
632
633    #[test]
634    fn from_str_invite_unknown_custom() {
635        let parsed: SipPassthroughHeader = "sip_i_x_custom"
636            .parse()
637            .unwrap();
638        assert_eq!(parsed.prefix(), SipHeaderPrefix::Invite);
639        assert_eq!(parsed.canonical_name(), "x-custom");
640    }
641
642    #[test]
643    fn from_str_case_insensitive_invite() {
644        let parsed: SipPassthroughHeader = "SIP_I_CALL_INFO"
645            .parse()
646            .unwrap();
647        assert_eq!(parsed.prefix(), SipHeaderPrefix::Invite);
648        assert_eq!(parsed.canonical_name(), "Call-Info");
649    }
650
651    #[test]
652    fn from_str_case_insensitive_prefix_keeps_the_suffix_as_written() {
653        let parsed: SipPassthroughHeader = "SIP_H_X-Tenant"
654            .parse()
655            .unwrap();
656        assert_eq!(parsed.prefix(), SipHeaderPrefix::Request);
657        assert_eq!(parsed.canonical_name(), "X-Tenant");
658        assert_eq!(parsed.as_str(), "sip_h_X-Tenant");
659    }
660
661    #[test]
662    fn from_str_rejects_no_prefix() {
663        assert!("call_info"
664            .parse::<SipPassthroughHeader>()
665            .is_err());
666        assert!("sip_call_info"
667            .parse::<SipPassthroughHeader>()
668            .is_err());
669    }
670
671    #[test]
672    fn from_str_rejects_empty_suffix() {
673        assert!("sip_h_"
674            .parse::<SipPassthroughHeader>()
675            .is_err());
676        assert!("sip_i_"
677            .parse::<SipPassthroughHeader>()
678            .is_err());
679    }
680
681    #[test]
682    fn from_str_round_trip_all_prefixes() {
683        let headers = [
684            SipPassthroughHeader::invite(SipHeader::CallInfo),
685            SipPassthroughHeader::request(SipHeader::CallInfo),
686            SipPassthroughHeader::response(SipHeader::CallInfo),
687            SipPassthroughHeader::provisional(SipHeader::AlertInfo),
688            SipPassthroughHeader::bye(SipHeader::Reason),
689            SipPassthroughHeader::no_bye(SipHeader::Reason),
690        ];
691        for h in &headers {
692            let parsed: SipPassthroughHeader = h
693                .as_str()
694                .parse()
695                .unwrap();
696            assert_eq!(&parsed, h, "round-trip failed for {}", h.as_str());
697        }
698    }
699
700    // --- Accessors ---
701
702    #[test]
703    fn prefix_accessor() {
704        assert_eq!(
705            SipPassthroughHeader::invite(SipHeader::Via).prefix(),
706            SipHeaderPrefix::Invite
707        );
708        assert_eq!(
709            SipPassthroughHeader::request(SipHeader::Via).prefix(),
710            SipHeaderPrefix::Request
711        );
712    }
713
714    #[test]
715    fn canonical_name_accessor() {
716        assert_eq!(
717            SipPassthroughHeader::invite(SipHeader::CallInfo).canonical_name(),
718            "Call-Info"
719        );
720        assert_eq!(
721            SipPassthroughHeader::request_raw("X-Tenant")
722                .unwrap()
723                .canonical_name(),
724            "X-Tenant"
725        );
726    }
727
728    // --- is_array_header ---
729
730    #[test]
731    fn is_array_header_invite_multi_valued() {
732        assert!(SipPassthroughHeader::invite(SipHeader::Via).is_array_header());
733        assert!(SipPassthroughHeader::invite(SipHeader::CallInfo).is_array_header());
734        assert!(SipPassthroughHeader::invite(SipHeader::PAssertedIdentity).is_array_header());
735        assert!(SipPassthroughHeader::invite(SipHeader::RecordRoute).is_array_header());
736    }
737
738    #[test]
739    fn is_array_header_invite_single_valued() {
740        assert!(!SipPassthroughHeader::invite(SipHeader::From).is_array_header());
741        assert!(!SipPassthroughHeader::invite(SipHeader::CallId).is_array_header());
742        assert!(!SipPassthroughHeader::invite(SipHeader::ContentType).is_array_header());
743    }
744
745    #[test]
746    fn is_array_header_non_invite_always_false() {
747        assert!(!SipPassthroughHeader::request(SipHeader::Via).is_array_header());
748        assert!(!SipPassthroughHeader::response(SipHeader::CallInfo).is_array_header());
749    }
750
751    #[test]
752    fn is_array_header_raw_unknown() {
753        assert!(!SipPassthroughHeader::invite_raw("X-Custom")
754            .unwrap()
755            .is_array_header());
756    }
757
758    // --- extract_from ---
759
760    #[test]
761    fn extract_from_sip_message() {
762        let msg = "INVITE sip:bob@example.com SIP/2.0\r\n\
763                   Call-Info: <sip:example.com>;answer-after=0\r\n\
764                   \r\n";
765        let h = SipPassthroughHeader::invite(SipHeader::CallInfo);
766        assert_eq!(
767            h.extract_from(msg),
768            vec!["<sip:example.com>;answer-after=0"]
769        );
770    }
771
772    #[test]
773    fn extract_from_missing() {
774        let msg = "INVITE sip:bob@example.com SIP/2.0\r\n\
775                   From: Alice <sip:alice@example.com>\r\n\
776                   \r\n";
777        let h = SipPassthroughHeader::invite(SipHeader::CallInfo);
778        assert!(h
779            .extract_from(msg)
780            .is_empty());
781    }
782
783    // --- PREFIX_PATTERNS completeness ---
784
785    #[test]
786    fn prefix_patterns_covers_all_variants_exactly_once() {
787        for &variant in SipHeaderPrefix::ALL {
788            let count = PREFIX_PATTERNS
789                .iter()
790                .filter(|&&p| p == variant)
791                .count();
792            assert_eq!(
793                count, 1,
794                "{:?} appears {count} times in PREFIX_PATTERNS (expected exactly 1)",
795                variant
796            );
797        }
798        assert_eq!(
799            PREFIX_PATTERNS.len(),
800            SipHeaderPrefix::ALL.len(),
801            "PREFIX_PATTERNS length differs from SipHeaderPrefix::ALL"
802        );
803    }
804
805    // --- VariableName trait ---
806
807    #[test]
808    fn variable_name_trait() {
809        use crate::variables::VariableName;
810        let h = SipPassthroughHeader::request(SipHeader::CallInfo);
811        let name: &str = VariableName::as_str(&h);
812        assert_eq!(name, "sip_h_Call-Info");
813    }
814}