use std::fmt;
use rvoip_sip_core::types::headers::{HeaderName, HeaderValue, TypedHeader};
use rvoip_sip_core::types::Method;
use super::policy::{self, HeaderRole};
use super::view::SipHeaderView;
pub(crate) struct MethodDiagnostic<'a>(pub(crate) &'a Method);
impl fmt::Display for MethodDiagnostic<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Method::Extension(value) => write!(formatter, "extension(len={})", value.len()),
method => fmt::Display::fmt(method, formatter),
}
}
}
impl fmt::Debug for MethodDiagnostic<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Method::Extension(value) => formatter
.debug_struct("Extension")
.field("value_len", &value.len())
.finish(),
method => fmt::Debug::fmt(method, formatter),
}
}
}
pub(crate) struct HeaderNameDiagnostic<'a>(pub(crate) &'a HeaderName);
impl fmt::Display for HeaderNameDiagnostic<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
HeaderName::Other(value) => write!(formatter, "custom(len={})", value.len()),
name => formatter.write_str(name.as_str()),
}
}
}
impl fmt::Debug for HeaderNameDiagnostic<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
HeaderName::Other(value) => formatter
.debug_struct("Other")
.field("name_len", &value.len())
.finish(),
name => fmt::Debug::fmt(name, formatter),
}
}
}
pub(crate) struct HeaderNamesDiagnostic<'a>(pub(crate) &'a [HeaderName]);
impl fmt::Debug for HeaderNamesDiagnostic<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut list = formatter.debug_list();
for name in self.0 {
list.entry(&HeaderNameDiagnostic(name));
}
list.finish()
}
}
#[derive(Default, Clone)]
pub struct BuilderHeaderState {
pub headers: Vec<TypedHeader>,
pub strictness: BuilderStrictness,
}
impl fmt::Debug for BuilderHeaderState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BuilderHeaderState")
.field("header_count", &self.headers.len())
.field("strictness", &self.strictness)
.finish()
}
}
#[non_exhaustive]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
pub enum BuilderStrictness {
#[default]
Strict,
Lenient,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViolationReason {
StackManaged,
WrongMethod,
UseDedicatedSetter(&'static str),
}
impl fmt::Display for ViolationReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ViolationReason::StackManaged => f.write_str("owned by the dialog/transaction layer"),
ViolationReason::WrongMethod => f.write_str("not allowed on this SIP method"),
ViolationReason::UseDedicatedSetter(s) => {
write!(f, "use the dedicated `{s}` setter instead")
}
}
}
}
#[derive(Clone)]
pub struct HeaderPolicyViolation {
pub method: Method,
pub header: HeaderName,
pub reason: ViolationReason,
}
impl fmt::Display for HeaderPolicyViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"header policy violation on {}: {} — {}",
MethodDiagnostic(&self.method),
HeaderNameDiagnostic(&self.header),
self.reason
)
}
}
impl fmt::Debug for HeaderPolicyViolation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HeaderPolicyViolation")
.field("method", &MethodDiagnostic(&self.method))
.field("header", &HeaderNameDiagnostic(&self.header))
.field("reason", &self.reason)
.finish()
}
}
impl std::error::Error for HeaderPolicyViolation {}
#[derive(Default, Clone)]
pub struct HeaderCarryThroughReport {
pub copied: Vec<HeaderName>,
pub skipped: Vec<(HeaderName, ViolationReason)>,
}
impl fmt::Debug for HeaderCarryThroughReport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let stack_managed_count = self
.skipped
.iter()
.filter(|(_, reason)| matches!(reason, ViolationReason::StackManaged))
.count();
let wrong_method_count = self
.skipped
.iter()
.filter(|(_, reason)| matches!(reason, ViolationReason::WrongMethod))
.count();
let dedicated_setter_count = self
.skipped
.iter()
.filter(|(_, reason)| matches!(reason, ViolationReason::UseDedicatedSetter(_)))
.count();
formatter
.debug_struct("HeaderCarryThroughReport")
.field("copied_count", &self.copied.len())
.field("skipped_count", &self.skipped.len())
.field("stack_managed_count", &stack_managed_count)
.field("wrong_method_count", &wrong_method_count)
.field("dedicated_setter_count", &dedicated_setter_count)
.finish()
}
}
pub trait SipRequestOptions: Sized + Send + Sync {
fn method(&self) -> Method;
fn header_state_mut(&mut self) -> &mut BuilderHeaderState;
fn header_state(&self) -> &BuilderHeaderState;
fn with_header(mut self, header: TypedHeader) -> Result<Self, HeaderPolicyViolation> {
let header = canonicalize_typed_header_name(header);
let method = self.method();
let name = header.name();
let role = policy::classify(method.clone(), &name);
match (&role, self.header_state().strictness) {
(HeaderRole::StackManaged, _) => Err(HeaderPolicyViolation {
method,
header: name,
reason: ViolationReason::StackManaged,
}),
(HeaderRole::MethodShaped { setter }, BuilderStrictness::Strict) => {
Err(HeaderPolicyViolation {
method,
header: name,
reason: ViolationReason::UseDedicatedSetter(setter),
})
}
(HeaderRole::MethodShaped { setter }, BuilderStrictness::Lenient) => {
tracing::warn!(
method = %MethodDiagnostic(&method),
header = %HeaderNameDiagnostic(&name),
setter = setter,
"Builder Lenient mode: dropping method-shaped header; \
use the dedicated setter instead",
);
Ok(self)
}
(HeaderRole::ApplicationControlled, _) => {
self.header_state_mut().headers.push(header);
Ok(self)
}
}
}
fn with_headers(self, headers: Vec<TypedHeader>) -> Result<Self, HeaderPolicyViolation> {
let mut me = self;
for h in headers {
me = me.with_header(h)?;
}
Ok(me)
}
fn with_raw_header(
self,
name: impl Into<HeaderName>,
value: impl Into<String>,
) -> Result<Self, HeaderPolicyViolation> {
let name = name.into();
let canonical = canonicalize_header_name(name);
let hv = HeaderValue::Raw(value.into().into_bytes());
self.with_header(TypedHeader::Other(canonical, hv))
}
fn strip_header(mut self, name: &HeaderName) -> Self {
let state = self.header_state_mut();
state
.headers
.retain(|h| !super::view::header_name_eq(&h.name(), name));
self
}
fn with_headers_from<S: SipHeaderView>(
mut self,
source: &S,
names: &[HeaderName],
) -> Result<(Self, HeaderCarryThroughReport), HeaderPolicyViolation> {
let method = self.method();
let mut report = HeaderCarryThroughReport::default();
for name in names {
if policy::forbidden_for_carry_through(name) {
report
.skipped
.push((name.clone(), ViolationReason::StackManaged));
continue;
}
for hdr in source.headers_named(name) {
let role = policy::classify(method.clone(), &hdr.name());
match role {
HeaderRole::StackManaged => {
report
.skipped
.push((name.clone(), ViolationReason::StackManaged));
}
HeaderRole::MethodShaped { setter } => {
report
.skipped
.push((name.clone(), ViolationReason::UseDedicatedSetter(setter)));
}
HeaderRole::ApplicationControlled => {
self.header_state_mut().headers.push(hdr.clone());
report.copied.push(name.clone());
}
}
}
}
Ok((self, report))
}
fn staged_headers(&self) -> &[TypedHeader] {
&self.header_state().headers
}
fn with_strictness(mut self, mode: BuilderStrictness) -> Self {
self.header_state_mut().strictness = mode;
self
}
}
pub fn take_staged(state: &mut BuilderHeaderState) -> Vec<TypedHeader> {
std::mem::take(&mut state.headers)
}
fn canonicalize_header_name(name: HeaderName) -> HeaderName {
match name {
HeaderName::Other(s) => match s.parse::<HeaderName>() {
Ok(HeaderName::Other(_)) | Err(_) => HeaderName::Other(canonicalize_other(&s)),
Ok(known) => known,
},
other => other,
}
}
fn canonicalize_typed_header_name(header: TypedHeader) -> TypedHeader {
match header {
TypedHeader::Other(name, value) => {
TypedHeader::Other(canonicalize_header_name(name), value)
}
other => other,
}
}
fn canonicalize_other(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut start_of_word = true;
for ch in s.chars() {
if ch == '-' {
out.push('-');
start_of_word = true;
} else if start_of_word {
for upper in ch.to_uppercase() {
out.push(upper);
}
start_of_word = false;
} else {
for lower in ch.to_lowercase() {
out.push(lower);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonicalize_simple() {
assert_eq!(canonicalize_other("x-customer-id"), "X-Customer-Id");
assert_eq!(canonicalize_other("X-CUSTOMER-ID"), "X-Customer-Id");
assert_eq!(canonicalize_other("history-info"), "History-Info");
}
#[test]
fn canonicalize_recognized_other_names_to_typed_identity() {
for (alias, expected) in [
("call-ID", HeaderName::CallId),
("i", HeaderName::CallId),
("V", HeaderName::Via),
("l", HeaderName::ContentLength),
("route", HeaderName::Route),
("AUTHORIZATION", HeaderName::Authorization),
("Proxy-AUTHORIZATION", HeaderName::ProxyAuthorization),
] {
assert_eq!(
canonicalize_header_name(HeaderName::Other(alias.into())),
expected
);
}
assert_eq!(
canonicalize_header_name(HeaderName::Other("x-CUSTOM".into())),
HeaderName::Other("X-Custom".into())
);
let header = canonicalize_typed_header_name(TypedHeader::Other(
HeaderName::Other("sUbJeCt".into()),
HeaderValue::Raw(b"hello".to_vec()),
));
assert_eq!(header.name(), HeaderName::Subject);
}
#[test]
fn builder_header_state_debug_reports_shape_without_header_values() {
let state = BuilderHeaderState {
headers: vec![TypedHeader::Other(
HeaderName::Other("X-Secret-Canary".into()),
HeaderValue::Raw(b"builder-header-secret-canary".to_vec()),
)],
strictness: BuilderStrictness::Lenient,
};
let debug = format!("{state:?}");
assert!(debug.contains("header_count: 1"));
assert!(debug.contains("strictness: Lenient"));
assert!(!debug.contains("X-Secret-Canary"));
assert!(!debug.contains("builder-header-secret-canary"));
}
#[test]
fn header_policy_diagnostics_redact_extension_method_and_custom_header_spelling() {
const METHOD_CANARY: &str = "CUSTOM\r\nX-Method-Canary: exposed";
const HEADER_CANARY: &str = "X-Header-Canary\r\nInjected";
let violation = HeaderPolicyViolation {
method: Method::Extension(METHOD_CANARY.to_string()),
header: HeaderName::Other(HEADER_CANARY.to_string()),
reason: ViolationReason::StackManaged,
};
let display = violation.to_string();
let debug = format!("{violation:?}");
for rendered in [&display, &debug] {
assert!(
!rendered.contains(METHOD_CANARY),
"method leaked: {rendered}"
);
assert!(
!rendered.contains(HEADER_CANARY),
"header leaked: {rendered}"
);
}
assert!(display.contains(&format!("extension(len={})", METHOD_CANARY.len())));
assert!(display.contains(&format!("custom(len={})", HEADER_CANARY.len())));
assert!(debug.starts_with("HeaderPolicyViolation"));
assert!(debug.contains(&format!("value_len: {}", METHOD_CANARY.len())));
assert!(debug.contains(&format!("name_len: {}", HEADER_CANARY.len())));
assert_eq!(
violation.method,
Method::Extension(METHOD_CANARY.to_string())
);
assert_eq!(
violation.header,
HeaderName::Other(HEADER_CANARY.to_string())
);
}
#[test]
fn standard_header_policy_debug_shape_remains_actionable() {
let violation = HeaderPolicyViolation {
method: Method::Invite,
header: HeaderName::CallId,
reason: ViolationReason::StackManaged,
};
assert_eq!(
violation.to_string(),
"header policy violation on INVITE: Call-ID — owned by the dialog/transaction layer"
);
assert_eq!(
format!("{violation:?}"),
"HeaderPolicyViolation { method: Invite, header: HeaderName(\"Call-ID\"), reason: StackManaged }"
);
}
#[test]
fn carry_through_report_debug_exposes_counts_not_custom_names() {
const COPIED_CANARY: &str = "X-Copied-Canary";
const SKIPPED_CANARY: &str = "X-Skipped-Canary";
let report = HeaderCarryThroughReport {
copied: vec![HeaderName::Other(COPIED_CANARY.to_string())],
skipped: vec![
(
HeaderName::Other(SKIPPED_CANARY.to_string()),
ViolationReason::StackManaged,
),
(
HeaderName::Authorization,
ViolationReason::UseDedicatedSetter("with_credentials"),
),
],
};
let debug = format!("{report:?}");
assert!(debug.starts_with("HeaderCarryThroughReport"));
assert!(debug.contains("copied_count: 1"));
assert!(debug.contains("skipped_count: 2"));
assert!(debug.contains("stack_managed_count: 1"));
assert!(debug.contains("dedicated_setter_count: 1"));
assert!(!debug.contains(COPIED_CANARY));
assert!(!debug.contains(SKIPPED_CANARY));
assert_eq!(
report.copied[0],
HeaderName::Other(COPIED_CANARY.to_string())
);
}
}