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