Skip to main content

rvoip_core_traits/
capability.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::identity::IdentityAssurance;
7
8// =====================================================================
9// Codec types
10// =====================================================================
11
12/// Legacy flat-fields codec entry — used internally by SIP/RTP adapters
13/// that need the parsed `clock_rate_hz` / `channels` numbers directly.
14/// Bridges to/from [`Codec`] (the spec wire shape) via `From`/`TryFrom`.
15#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)]
16pub struct CodecInfo {
17    pub name: String,
18    pub clock_rate_hz: u32,
19    pub channels: u8,
20    pub fmtp: Option<String>,
21}
22
23impl fmt::Debug for CodecInfo {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        formatter
26            .debug_struct("CodecInfo")
27            .field("name_present", &!self.name.is_empty())
28            .field("name_bytes", &self.name.len())
29            .field("clock_rate_hz", &self.clock_rate_hz)
30            .field("channels", &self.channels)
31            .field("fmtp_present", &self.fmtp.is_some())
32            .field(
33                "fmtp_bytes",
34                &self.fmtp.as_ref().map_or(0, std::string::String::len),
35            )
36            .finish()
37    }
38}
39
40/// Reasonable default for adapter and orchestrator paths that need a
41/// codec descriptor before negotiation has run (e.g. `Orchestrator::fanout_frame`
42/// allocating a subscriber-side MediaStream before the publisher's
43/// negotiated codec has propagated). Matches the codec the v0 default
44/// CapabilityDescriptor advertises first.
45pub fn default_audio_codec() -> CodecInfo {
46    CodecInfo {
47        name: "opus".into(),
48        clock_rate_hz: 48_000,
49        channels: 1,
50        fmtp: None,
51    }
52}
53
54impl CodecInfo {
55    /// Build a `CodecInfo` from just the codec name, using
56    /// standards-defined defaults for `clock_rate_hz` / `channels`.
57    /// Used by the multi-party fanout path (plan B1) where the wire
58    /// catalog only records the chosen codec name; richer params would
59    /// require carrying the full negotiation result through more layers.
60    /// Falls back to the `name`/48k/mono shape for codecs not in the
61    /// table — fanout still works, the client just sees an audio stream
62    /// it may or may not be able to decode (B2 codec-mismatch refusal
63    /// is the right place to surface that).
64    pub fn from_name_with_defaults(name: &str) -> Self {
65        let (clock_rate_hz, channels) = match name {
66            "opus" => (48_000, 1),
67            "g.711-mu" | "PCMU" | "pcmu" => (8_000, 1),
68            "g.711-a" | "PCMA" | "pcma" => (8_000, 1),
69            "g.722" => (16_000, 1),
70            "g.729" => (8_000, 1),
71            "pcm_s16le" | "PCM_S16LE" => (16_000, 1),
72            _ => (48_000, 1),
73        };
74        Self {
75            name: name.to_string(),
76            clock_rate_hz,
77            channels,
78            fmtp: None,
79        }
80    }
81}
82
83/// One codec entry on the wire, matching CONVERSATION_PROTOCOL.md §8's
84/// `{"name": "opus", "params": {"sample_rate": 48000, ...}}` shape.
85/// Distinct from [`CodecInfo`] — the flat-fields shape can't represent
86/// the spec wire format losslessly. Conversion helpers below.
87#[derive(Clone, Serialize, Deserialize)]
88pub struct Codec {
89    pub name: String,
90    #[serde(default)]
91    pub params: BTreeMap<String, serde_json::Value>,
92}
93
94impl fmt::Debug for Codec {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter
97            .debug_struct("Codec")
98            .field("name_present", &!self.name.is_empty())
99            .field("name_bytes", &self.name.len())
100            .field("parameter_count", &self.params.len())
101            .finish()
102    }
103}
104
105impl Codec {
106    pub fn new(name: impl Into<String>) -> Self {
107        Self {
108            name: name.into(),
109            params: BTreeMap::new(),
110        }
111    }
112}
113
114impl From<CodecInfo> for Codec {
115    fn from(c: CodecInfo) -> Self {
116        let mut params = BTreeMap::new();
117        params.insert("sample_rate".into(), serde_json::json!(c.clock_rate_hz));
118        params.insert("channels".into(), serde_json::json!(c.channels));
119        if let Some(fmtp) = c.fmtp {
120            params.insert("fmtp".into(), serde_json::Value::String(fmtp));
121        }
122        Self {
123            name: c.name,
124            params,
125        }
126    }
127}
128
129impl TryFrom<Codec> for CodecInfo {
130    type Error = &'static str;
131    fn try_from(c: Codec) -> Result<Self, Self::Error> {
132        let clock_rate_hz = c
133            .params
134            .get("sample_rate")
135            .and_then(|v| v.as_u64())
136            .ok_or("missing or invalid sample_rate")? as u32;
137        let channels = c
138            .params
139            .get("channels")
140            .and_then(|v| v.as_u64())
141            .unwrap_or(1) as u8;
142        let fmtp = c
143            .params
144            .get("fmtp")
145            .and_then(|v| v.as_str())
146            .map(String::from);
147        Ok(Self {
148            name: c.name,
149            clock_rate_hz,
150            channels,
151            fmtp,
152        })
153    }
154}
155
156// =====================================================================
157// CapabilityDescriptor (expanded per CONVERSATION_PROTOCOL.md §8 +
158// INTERFACE_DESIGN.md §9)
159// =====================================================================
160
161/// Capability descriptor that round-trips through CONVERSATION_PROTOCOL.md
162/// §8's JSON shape. Field order matches the spec for readability.
163///
164/// `supports_dtmf_rfc4733` is a **method** (derived from `dtmf_modes`),
165/// not a field — `dtmf_modes` is the single source of truth on the wire
166/// and the boolean would silently desync from a custom serde round-trip.
167#[derive(Clone, Default, Serialize, Deserialize)]
168pub struct CapabilityDescriptor {
169    #[serde(default)]
170    pub audio_codecs: Vec<CodecInfo>,
171
172    #[serde(default)]
173    pub video_codecs: Vec<CodecInfo>,
174
175    #[serde(default)]
176    pub data_protocols: Vec<DataProtocol>,
177
178    #[serde(default)]
179    pub dtmf_modes: Vec<DtmfMode>,
180
181    #[serde(default)]
182    pub max_streams_per_connection: u16,
183
184    #[serde(default)]
185    pub transport_features: Vec<TransportFeature>,
186
187    /// Gatewayable interop targets (`["sip", "webrtc"]`). Empty when the
188    /// endpoint is UCTP-only.
189    #[serde(default)]
190    pub interop: Vec<InteropTarget>,
191
192    /// IdentityAssurance the peer is offering. Defaults to
193    /// `Anonymous` when not declared.
194    #[serde(default = "default_assurance_offered")]
195    pub identity_assurance_offered: AssuranceLevel,
196
197    /// Minimum IdentityAssurance the peer requires from its counterpart.
198    /// `None` means no constraint.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub identity_assurance_required: Option<IdentityAssuranceRequirement>,
201
202    /// Legacy boolean retained from the original narrow `CapabilityDescriptor`
203    /// for back-compat with consumers that check messaging support
204    /// directly. Independent of `dtmf_modes` / `data_protocols`.
205    #[serde(default)]
206    pub supports_message_text: bool,
207
208    /// Legacy boolean retained from the original narrow `CapabilityDescriptor`.
209    /// Independent of `transport_features`.
210    #[serde(default)]
211    pub supports_srtp: bool,
212}
213
214impl fmt::Debug for CapabilityDescriptor {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        formatter
217            .debug_struct("CapabilityDescriptor")
218            .field("audio_codec_count", &self.audio_codecs.len())
219            .field("video_codec_count", &self.video_codecs.len())
220            .field("data_protocols", &self.data_protocols)
221            .field("dtmf_modes", &self.dtmf_modes)
222            .field(
223                "max_streams_per_connection",
224                &self.max_streams_per_connection,
225            )
226            .field("transport_features", &self.transport_features)
227            .field("interop", &self.interop)
228            .field(
229                "identity_assurance_offered",
230                &self.identity_assurance_offered,
231            )
232            .field(
233                "identity_assurance_required",
234                &self.identity_assurance_required,
235            )
236            .field("supports_message_text", &self.supports_message_text)
237            .field("supports_srtp", &self.supports_srtp)
238            .finish()
239    }
240}
241
242fn default_assurance_offered() -> AssuranceLevel {
243    AssuranceLevel::Anonymous
244}
245
246impl CapabilityDescriptor {
247    /// True when `dtmf_modes` includes `Rfc4733`. Defined as a method
248    /// (not a field) so `dtmf_modes` is the single source of truth.
249    pub fn supports_dtmf_rfc4733(&self) -> bool {
250        self.dtmf_modes.contains(&DtmfMode::Rfc4733)
251    }
252}
253
254// =====================================================================
255// Capability catalog enums
256// =====================================================================
257
258/// `data_protocols` catalog per CONVERSATION_PROTOCOL.md §8.
259#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum DataProtocol {
262    Text,
263    Json,
264    Binary,
265}
266
267/// `dtmf_modes` catalog per CONVERSATION_PROTOCOL.md §8.
268#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
269pub enum DtmfMode {
270    #[serde(rename = "rfc4733")]
271    Rfc4733,
272    #[serde(rename = "info")]
273    Info,
274}
275
276/// `transport_features` catalog per CONVERSATION_PROTOCOL.md §8.
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "kebab-case")]
279pub enum TransportFeature {
280    MediaDatagrams,
281    ConnectionMigration,
282    SessionResumption,
283    #[serde(rename = "0rtt")]
284    ZeroRtt,
285    #[serde(rename = "transcode-g711-opus")]
286    TranscodeG711Opus,
287    /// Catch-all for future entries so the wire format stays forward-compat.
288    #[serde(other)]
289    Unknown,
290}
291
292/// `identity_assurance_required` levels per CONVERSATION_PROTOCOL.md §5.6 / §8.
293#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum IdentityAssuranceRequirement {
296    None,
297    Pseudonymous,
298    Identified,
299    TaskScoped,
300    UserAuthorized,
301}
302
303/// Substrate name as it appears on the UCTP wire (CONVERSATION_PROTOCOL.md
304/// §8 `interop`). Lowercase kebab-style. Distinct from
305/// [`crate::connection::Transport`] (PascalCase Rust enum) because the
306/// wire format uses lowercase and is the source of truth for
307/// cross-language peers.
308#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "lowercase")]
310pub enum InteropTarget {
311    Sip,
312    Webrtc,
313    Quic,
314    Webtransport,
315    Websocket,
316}
317
318/// Wire form of `identity_assurance_offered`. Maps to the gradient
319/// in [`IdentityAssurance`] but flattened to a single string because the
320/// wire format does not carry the variant payload.
321#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "kebab-case")]
323pub enum AssuranceLevel {
324    #[default]
325    Anonymous,
326    Pseudonymous,
327    Identified,
328    TaskScoped,
329    UserAuthorized,
330}
331
332impl AssuranceLevel {
333    /// Map the wire-form level to its kebab-case label.
334    pub fn to_core(self) -> Option<&'static str> {
335        Some(match self {
336            AssuranceLevel::Anonymous => "anonymous",
337            AssuranceLevel::Pseudonymous => "pseudonymous",
338            AssuranceLevel::Identified => "identified",
339            AssuranceLevel::TaskScoped => "task-scoped",
340            AssuranceLevel::UserAuthorized => "user-authorized",
341        })
342    }
343
344    /// Derive the wire level from a full [`IdentityAssurance`].
345    pub fn from_core(assurance: &IdentityAssurance) -> Self {
346        match assurance {
347            IdentityAssurance::Anonymous => AssuranceLevel::Anonymous,
348            IdentityAssurance::Pseudonymous { .. } => AssuranceLevel::Pseudonymous,
349            IdentityAssurance::Identified { .. } => AssuranceLevel::Identified,
350            IdentityAssurance::TaskScoped { .. } => AssuranceLevel::TaskScoped,
351            IdentityAssurance::UserAuthorized { .. } => AssuranceLevel::UserAuthorized,
352            // D2 — DTLS fingerprint is key-binding without a real-world
353            // identity, so the closest wire level is Pseudonymous.
354            IdentityAssurance::DtlsFingerprint { .. } => AssuranceLevel::Pseudonymous,
355        }
356    }
357}
358
359// =====================================================================
360// Existing intersection / negotiation types (retained from the narrow
361// CapabilityDescriptor era — used by rvoip-sip and other adapters)
362// =====================================================================
363
364#[derive(Clone, Default, Serialize, Deserialize)]
365pub struct CapabilityIntersection {
366    pub audio: Option<CodecInfo>,
367    pub video: Option<CodecInfo>,
368    pub dtmf_method: Option<DtmfMethod>,
369    pub messaging_enabled: bool,
370}
371
372impl fmt::Debug for CapabilityIntersection {
373    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
374        formatter
375            .debug_struct("CapabilityIntersection")
376            .field("audio_present", &self.audio.is_some())
377            .field("video_present", &self.video.is_some())
378            .field("dtmf_method", &self.dtmf_method)
379            .field("messaging_enabled", &self.messaging_enabled)
380            .finish()
381    }
382}
383
384#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
385pub enum DtmfMethod {
386    Rfc4733,
387    SipInfo,
388}
389
390#[derive(Clone, Default, Serialize, Deserialize)]
391pub struct NegotiatedCodecs {
392    pub audio: Option<CodecInfo>,
393    pub video: Option<CodecInfo>,
394}
395
396impl fmt::Debug for NegotiatedCodecs {
397    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
398        formatter
399            .debug_struct("NegotiatedCodecs")
400            .field("audio_present", &self.audio.is_some())
401            .field("video_present", &self.video.is_some())
402            .finish()
403    }
404}
405
406// =====================================================================
407// §8.1 negotiation algorithm (relocated from rvoip-uctp)
408// =====================================================================
409
410/// Outcome of running [`negotiate_streams`] over an offer/answer pair.
411#[derive(Clone)]
412pub enum NegotiationOutcome {
413    /// Per-Stream chosen codecs. Order matches the input `streams_offered`.
414    Ok(Vec<NegotiatedStream>),
415    /// Spec §11.2 488: no codecs overlapped on any stream.
416    NotAcceptable488,
417}
418
419impl fmt::Debug for NegotiationOutcome {
420    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
421        match self {
422            Self::Ok(streams) => formatter
423                .debug_struct("Ok")
424                .field("stream_count", &streams.len())
425                .finish(),
426            Self::NotAcceptable488 => formatter.write_str("NotAcceptable488"),
427        }
428    }
429}
430
431/// One stream's negotiation result.
432#[derive(Clone)]
433pub struct NegotiatedStream {
434    pub stream_id: String,
435    pub kind: String,
436    pub direction: String,
437    /// `Some(codec_name)` when at least one of the offerer's preferences
438    /// matched the answerer's capability; `None` when this individual
439    /// stream had no overlap.
440    pub chosen_codec: Option<String>,
441}
442
443impl fmt::Debug for NegotiatedStream {
444    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
445        formatter
446            .debug_struct("NegotiatedStream")
447            .field("stream_id_present", &!self.stream_id.is_empty())
448            .field("stream_id_bytes", &self.stream_id.len())
449            .field("kind_present", &!self.kind.is_empty())
450            .field("kind_bytes", &self.kind.len())
451            .field("direction_present", &!self.direction.is_empty())
452            .field("direction_bytes", &self.direction.len())
453            .field("chosen_codec_present", &self.chosen_codec.is_some())
454            .finish()
455    }
456}
457
458/// Input shape mirroring `connection.offer.streams_offered`.
459#[derive(Clone)]
460pub struct StreamOffer<'a> {
461    pub id: &'a str,
462    pub kind: &'a str,
463    pub direction: &'a str,
464    pub codec_preferences: &'a [String],
465}
466
467impl fmt::Debug for StreamOffer<'_> {
468    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
469        formatter
470            .debug_struct("StreamOffer")
471            .field("id_present", &!self.id.is_empty())
472            .field("id_bytes", &self.id.len())
473            .field("kind_present", &!self.kind.is_empty())
474            .field("kind_bytes", &self.kind.len())
475            .field("direction_present", &!self.direction.is_empty())
476            .field("direction_bytes", &self.direction.len())
477            .field("codec_preference_count", &self.codec_preferences.len())
478            .finish()
479    }
480}
481
482#[cfg(test)]
483mod diagnostic_tests {
484    use super::*;
485
486    #[test]
487    fn internal_pcm_codec_uses_wideband_mono_defaults() {
488        let codec = CodecInfo::from_name_with_defaults("pcm_s16le");
489        assert_eq!(codec.clock_rate_hz, 16_000);
490        assert_eq!(codec.channels, 1);
491        assert!(codec.fmtp.is_none());
492    }
493
494    #[test]
495    fn capability_diagnostics_never_render_peer_strings() {
496        const CANARY: &str = "capability-canary\r\nAuthorization: exposed";
497        let codec = CodecInfo {
498            name: CANARY.into(),
499            clock_rate_hz: 48_000,
500            channels: 1,
501            fmtp: Some(CANARY.into()),
502        };
503        let descriptor = CapabilityDescriptor {
504            audio_codecs: vec![codec.clone()],
505            ..CapabilityDescriptor::default()
506        };
507        let negotiated = NegotiatedStream {
508            stream_id: CANARY.into(),
509            kind: CANARY.into(),
510            direction: CANARY.into(),
511            chosen_codec: Some(CANARY.into()),
512        };
513        for debug in [
514            format!("{codec:?}"),
515            format!("{:?}", Codec::new(CANARY)),
516            format!("{descriptor:?}"),
517            format!("{negotiated:?}"),
518            format!("{:?}", NegotiationOutcome::Ok(vec![negotiated])),
519        ] {
520            assert!(!debug.contains(CANARY));
521        }
522    }
523}
524
525/// Run the §8.1 negotiation algorithm on a single offer/answer pair.
526///
527/// 1. Walks the offerer's `codec_preferences` in order.
528/// 2. Picks the first codec the answerer advertises (audio or video).
529/// 3. If **no** stream gets a codec, returns
530///    [`NegotiationOutcome::NotAcceptable488`].
531pub fn negotiate_streams<'a, I>(
532    streams_offered: I,
533    answerer: &CapabilityDescriptor,
534) -> NegotiationOutcome
535where
536    I: IntoIterator<Item = StreamOffer<'a>>,
537{
538    let answerer_codecs: std::collections::HashSet<&str> = answerer
539        .audio_codecs
540        .iter()
541        .chain(answerer.video_codecs.iter())
542        .map(|c| c.name.as_str())
543        .collect();
544
545    let mut results = Vec::new();
546    let mut any_match = false;
547
548    for offer in streams_offered {
549        let chosen = offer
550            .codec_preferences
551            .iter()
552            .find(|c| answerer_codecs.contains(c.as_str()))
553            .cloned();
554        if chosen.is_some() {
555            any_match = true;
556        }
557        results.push(NegotiatedStream {
558            stream_id: offer.id.to_string(),
559            kind: offer.kind.to_string(),
560            direction: offer.direction.to_string(),
561            chosen_codec: chosen,
562        });
563    }
564
565    if any_match {
566        NegotiationOutcome::Ok(results)
567    } else {
568        NegotiationOutcome::NotAcceptable488
569    }
570}