rvoip-sip 0.3.8

SIP umbrella for RVoIP: api/* (UnifiedCoordinator, StreamPeer, CallbackPeer, Endpoint), server/* (B2BUA helpers), adapter/* (rvoip-core::ConnectionAdapter impl)
Documentation
//! `HeaderPolicy` — layer-boundary enforcement.
//!
//! Classifies every header into one of three roles:
//!
//! - **StackManaged** — owned by the dialog or transaction layer (Call-ID,
//!   CSeq, Via, Max-Forwards, Content-Length, Route, Record-Route).
//!   Applications cannot stage these; doing so would desync the dialog.
//! - **MethodShaped** — exists in sip-core, but the builder offers a
//!   dedicated typed setter (Authorization → `with_credentials` / `with_auth`,
//!   Contact → `with_contact_uri`, Expires → `with_expires`, …).
//! - **ApplicationControlled** — applications may stage freely
//!   (`Diversion`, `History-Info`, `Subject`, `X-*`, etc.).
//!
//! The matrix is **method-aware** — `Contact` is shaped on initial
//! INVITE/REGISTER but stack-managed in-dialog; `Refer-To` is only
//! shaped on REFER; `Event` only on SUBSCRIBE/NOTIFY.

#![allow(missing_docs)] // policy module is internal infrastructure

use std::fmt;

use rvoip_sip_core::types::headers::{HeaderName, TypedHeader};
use rvoip_sip_core::types::Method;

use super::options::{HeaderNameDiagnostic, MethodDiagnostic};
use super::ViolationReason;

/// Role assigned to a header for a given SIP method.
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HeaderRole {
    /// Owned by the dialog/transaction layer. Hard `Err` even under
    /// `BuilderStrictness::Lenient` because staging would desync the
    /// dialog.
    StackManaged,
    /// Has a dedicated typed setter on this builder. The name of that
    /// setter is reported back to the application.
    MethodShaped { setter: &'static str },
    /// Applications may stage freely.
    ApplicationControlled,
}

/// Validate-outbound result: the requested header is required for this
/// method but missing from the staged set.
#[derive(Clone)]
pub struct MissingRequiredHeader {
    pub method: Method,
    pub name: HeaderName,
    pub reason: &'static str,
}

impl fmt::Debug for MissingRequiredHeader {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("MissingRequiredHeader")
            .field("method", &MethodDiagnostic(&self.method))
            .field("name", &HeaderNameDiagnostic(&self.name))
            .field("reason", &self.reason)
            .finish()
    }
}

/// Classify a header for a given method. See module-level docs.
pub fn classify(method: Method, name: &HeaderName) -> HeaderRole {
    let canonical_name = name.canonical_wire_name();
    if is_always_stack_managed(&canonical_name) {
        return HeaderRole::StackManaged;
    }
    if matches!(&canonical_name, HeaderName::Route) {
        // Route is stack-managed by the dialog route-set logic on every
        // outbound request. Applications use `with_outbound_proxy` /
        // `without_outbound_proxy` instead.
        return HeaderRole::StackManaged;
    }

    // Method-shaped overrides.
    if let Some(setter) = method_shaped_setter(method, &canonical_name) {
        return HeaderRole::MethodShaped { setter };
    }

    HeaderRole::ApplicationControlled
}

/// Whether `name` should be silently filtered when copying from an
/// inbound message via `with_headers_from`. The stack-managed slice is
/// always filtered; method-shaped names go through the normal policy
/// check on the destination builder.
pub fn forbidden_for_carry_through(name: &HeaderName) -> bool {
    let canonical_name = name.canonical_wire_name();
    is_always_stack_managed(&canonical_name) || matches!(canonical_name, HeaderName::Route)
}

/// SIP_API_DESIGN_2 §5.4 — the load-bearing safety check at the
/// dialog-adapter boundary. Catches stack-managed names that slipped
/// past the builder's `with_header` strictness gate (e.g. via direct
/// manipulation of `BuilderHeaderState::headers`). Each forbidden hit
/// is reported as a `MissingRequiredHeader` whose `reason` documents
/// the policy violation; callers convert that into the typed
/// `SessionError::HeaderPolicy` / `SessionError::MissingRequiredHeader`.
///
/// Today the positive (required-on-method) side is enforced at the
/// per-builder setter level — `InfoBuilder` requires a non-empty
/// content-type via the typed field, `SubscribeBuilder` requires an
/// event package, etc. — so this hook only runs the negative pass over
/// the extra-headers slice.
pub fn validate_outbound(
    method: Method,
    headers: &[TypedHeader],
) -> Result<(), Vec<MissingRequiredHeader>> {
    let mut violations = Vec::new();
    for h in headers {
        let name = h.name();
        if matches!(classify(method.clone(), &name), HeaderRole::StackManaged) {
            violations.push(MissingRequiredHeader {
                method: method.clone(),
                name,
                reason: "stack-managed header staged in application extras — \
                         this name is owned by the dialog/transaction layer",
            });
        }
    }
    if violations.is_empty() {
        Ok(())
    } else {
        Err(violations)
    }
}

fn is_always_stack_managed(name: &HeaderName) -> bool {
    matches!(
        name,
        HeaderName::CallId
            | HeaderName::CSeq
            | HeaderName::Via
            | HeaderName::MaxForwards
            | HeaderName::ContentLength
            | HeaderName::RecordRoute
    )
}

fn method_shaped_setter(method: Method, name: &HeaderName) -> Option<&'static str> {
    use HeaderName as H;
    use Method as M;

    if matches!(name, H::Authorization | H::ProxyAuthorization) {
        return Some("with_credentials");
    }

    match (method, name) {
        // Contact: shaped on initial INVITE / REGISTER; SUBSCRIBE init.
        // (In-dialog re-INVITE / BYE / NOTIFY get Contact stack-managed,
        // but the builder-side classification only sees fresh requests.)
        (M::Invite | M::Register | M::Subscribe, H::Contact) => Some("with_contact_uri"),

        // Expires: shaped on REGISTER and SUBSCRIBE only.
        (M::Register | M::Subscribe, H::Expires) => Some("with_expires"),

        // Refer-To: shaped on REFER only.
        (M::Refer, H::ReferTo) => Some("refer(.., refer_to)"),

        // Event / Subscription-State: shaped on NOTIFY and SUBSCRIBE only.
        (M::Notify | M::Subscribe, H::Event) => Some("notify(.., event_package)"),
        (M::Notify, H::SubscriptionState) => Some("with_subscription_state"),

        _ => None,
    }
}

/// Map a method+header pair to a `ViolationReason`. The caller has
/// already determined the role; this is the one-stop converter for
/// builder setters that need the user-facing reason.
pub fn role_to_violation(role: &HeaderRole) -> Option<ViolationReason> {
    match role {
        HeaderRole::StackManaged => Some(ViolationReason::StackManaged),
        HeaderRole::MethodShaped { setter } => Some(ViolationReason::UseDedicatedSetter(setter)),
        HeaderRole::ApplicationControlled => None,
    }
}

#[cfg(test)]
mod diagnostic_tests {
    use super::*;

    #[test]
    fn missing_required_header_debug_redacts_extension_spellings() {
        const METHOD_CANARY: &str = "CUSTOM\r\nX-Method-Canary: exposed";
        const HEADER_CANARY: &str = "X-Header-Canary\r\nInjected";
        let violation = MissingRequiredHeader {
            method: Method::Extension(METHOD_CANARY.to_string()),
            name: HeaderName::Other(HEADER_CANARY.to_string()),
            reason: "fixed-policy-class",
        };

        let rendered = format!("{violation:?}");
        assert!(rendered.starts_with("MissingRequiredHeader"));
        assert!(rendered.contains(&format!("value_len: {}", METHOD_CANARY.len())));
        assert!(rendered.contains(&format!("name_len: {}", HEADER_CANARY.len())));
        assert!(!rendered.contains(METHOD_CANARY));
        assert!(!rendered.contains(HEADER_CANARY));
        assert_eq!(
            violation.method,
            Method::Extension(METHOD_CANARY.to_string())
        );
        assert_eq!(violation.name, HeaderName::Other(HEADER_CANARY.to_string()));
    }

    #[test]
    fn structural_other_aliases_cannot_bypass_stack_ownership() {
        for alias in [
            "call-ID",
            "I",
            "cSeQ",
            "V",
            "MAX-forwards",
            "L",
            "ROUTE",
            "record-ROUTE",
        ] {
            let name = HeaderName::Other(alias.into());
            assert_eq!(classify(Method::Invite, &name), HeaderRole::StackManaged);
            assert!(forbidden_for_carry_through(&name));
        }
    }

    #[test]
    fn credential_other_aliases_require_the_dedicated_setter() {
        for alias in ["AUTHORIZATION", "proxy-AUTHORIZATION"] {
            assert_eq!(
                classify(Method::Invite, &HeaderName::Other(alias.into())),
                HeaderRole::MethodShaped {
                    setter: "with_credentials"
                }
            );
        }
    }
}