mumble_server_runtime_protocol/
udp.rs

1//! UDP voice-plane envelope decoding (protobuf format, Mumble 1.5+).
2//!
3//! A decrypted UDP packet is a one-byte type header followed by a protobuf
4//! message:
5//!
6//! ```text
7//! [ type: u8 ][ protobuf message ]
8//! ```
9//!
10//! REF: references/vendored/protocol/MumbleProtocol.h : `MUMBLE_ALL_UDP_MESSAGES`
11//!      (Audio = 0, Ping = 1).
12//! REF: MumbleProtocol.cpp : `m_byteBuffer[0] = UDPMessageType::Audio/Ping`, protobuf
13//!      encoded from offset 1; `UDPDecoder::decode` rejects `data.size() <= 1`.
14//!
15//! Only the protobuf format is implemented. The legacy UDP format is out of scope
16//! per ADR-0001; a packet whose header is neither Audio nor Ping is rejected
17//! rather than guessed (fail closed, R6). This decodes the envelope only: the
18//! `opus_data` payload stays as raw bytes — no Opus decoding happens here.
19//!
20//! Input is assumed already decrypted; OCB2 lives in `mumble-server-runtime-crypto`.
21
22use prost::Message;
23use thiserror::Error;
24
25use crate::messages::udp;
26
27/// UDP message type register (protobuf format).
28/// REF: MumbleProtocol.h : `enum class UDPMessageType : byte { Audio = 0, Ping = 1 }`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[repr(u8)]
31pub enum UdpMessageType {
32    Audio = 0,
33    Ping = 1,
34}
35
36impl From<UdpMessageType> for u8 {
37    fn from(value: UdpMessageType) -> Self {
38        value as u8
39    }
40}
41
42/// A decoded UDP voice-plane message.
43#[derive(Debug, Clone, PartialEq)]
44pub enum UdpMessage {
45    Audio(udp::Audio),
46    Ping(udp::Ping),
47}
48
49/// Errors from decoding a UDP envelope.
50#[derive(Debug, Error)]
51pub enum UdpDecodeError {
52    /// Empty, or only the header byte with no payload.
53    /// REF: MumbleProtocol.cpp : `if (data.size() <= 1) return false;`.
54    #[error("UDP packet too short: {len} byte(s), need a header plus payload")]
55    TooShort { len: usize },
56
57    /// The header byte is neither Audio (0) nor Ping (1). This includes every
58    /// legacy-format packet, which is deliberately unsupported (ADR-0001).
59    #[error(
60        "unsupported UDP packet type {0}; only protobuf Audio (0) and Ping (1) \
61         are implemented (legacy is out per ADR-0001)"
62    )]
63    UnsupportedType(u8),
64
65    /// The payload was not a valid protobuf encoding of its declared message.
66    #[error("protobuf decode failed for UDP {message_type:?} ({payload_len} bytes): {source}")]
67    Protobuf {
68        message_type: UdpMessageType,
69        payload_len: usize,
70        source: prost::DecodeError,
71    },
72}
73
74/// Decode a decrypted UDP packet into a typed [`UdpMessage`].
75pub fn decode_udp(packet: &[u8]) -> Result<UdpMessage, UdpDecodeError> {
76    // A valid packet is a header byte plus a non-empty payload.
77    let (&header, payload) = match packet.split_first() {
78        Some(split) if !split.1.is_empty() => split,
79        _ => return Err(UdpDecodeError::TooShort { len: packet.len() }),
80    };
81
82    let message_type = match header {
83        0 => UdpMessageType::Audio,
84        1 => UdpMessageType::Ping,
85        other => return Err(UdpDecodeError::UnsupportedType(other)),
86    };
87
88    Ok(match message_type {
89        UdpMessageType::Audio => UdpMessage::Audio(decode_pb(message_type, payload)?),
90        UdpMessageType::Ping => UdpMessage::Ping(decode_pb(message_type, payload)?),
91    })
92}
93
94/// Encode a UDP voice-plane message to a decrypted packet (`[type][protobuf]`) —
95/// the inverse of [`decode_udp`], so `decode_udp(&encode_udp(m)) == Ok(m)`.
96///
97/// The result is plaintext; OCB2 encryption is the caller's job (`mumble-server-runtime-crypto`).
98pub fn encode_udp(message: &UdpMessage) -> Vec<u8> {
99    let (message_type, body) = match message {
100        UdpMessage::Audio(audio) => (UdpMessageType::Audio, audio.encode_to_vec()),
101        UdpMessage::Ping(ping) => (UdpMessageType::Ping, ping.encode_to_vec()),
102    };
103    // body is our own freshly-encoded output, not a wire-derived length.
104    let mut packet = Vec::with_capacity(1 + body.len());
105    packet.push(u8::from(message_type));
106    packet.extend_from_slice(&body);
107    packet
108}
109
110/// Prost-decode a payload, tagging failures with the message type and length.
111fn decode_pb<M: Message + Default>(
112    message_type: UdpMessageType,
113    payload: &[u8],
114) -> Result<M, UdpDecodeError> {
115    M::decode(payload).map_err(|source| UdpDecodeError::Protobuf {
116        message_type,
117        payload_len: payload.len(),
118        source,
119    })
120}
121
122#[cfg(test)]
123mod tests {
124    // See framing.rs for why the test module allows expect_used under -D warnings.
125    #![allow(clippy::expect_used)]
126
127    use super::*;
128
129    fn envelope(message_type: UdpMessageType, body: &[u8]) -> Vec<u8> {
130        let mut packet = vec![u8::from(message_type)];
131        packet.extend_from_slice(body);
132        packet
133    }
134
135    #[test]
136    fn decodes_audio_envelope_and_keeps_opus_raw() {
137        let audio = udp::Audio {
138            header: Some(udp::audio::Header::Target(0)),
139            sender_session: 5,
140            frame_number: 100,
141            opus_data: vec![0xAA, 0xBB, 0xCC], // opaque Opus bytes, never decoded
142            positional_data: vec![],
143            volume_adjustment: 0.0,
144            is_terminator: false,
145        };
146        let packet = envelope(UdpMessageType::Audio, &audio.encode_to_vec());
147        match decode_udp(&packet).expect("decode audio") {
148            UdpMessage::Audio(decoded) => {
149                assert_eq!(decoded, audio);
150                // The envelope decoder must not touch the Opus payload.
151                assert_eq!(decoded.opus_data, vec![0xAA, 0xBB, 0xCC]);
152            }
153            other => panic!("expected Audio, got {other:?}"),
154        }
155    }
156
157    #[test]
158    fn decodes_ping_envelope() {
159        let ping = udp::Ping {
160            timestamp: 987_654,
161            ..Default::default()
162        };
163        let packet = envelope(UdpMessageType::Ping, &ping.encode_to_vec());
164        match decode_udp(&packet).expect("decode ping") {
165            UdpMessage::Ping(decoded) => assert_eq!(decoded, ping),
166            other => panic!("expected Ping, got {other:?}"),
167        }
168    }
169
170    #[test]
171    fn encode_then_decode_roundtrips_audio_and_ping() {
172        let audio = UdpMessage::Audio(udp::Audio {
173            header: Some(udp::audio::Header::Context(0)),
174            sender_session: 7,
175            frame_number: 690,
176            opus_data: vec![0xD8, 0xED, 0x5C],
177            positional_data: vec![],
178            volume_adjustment: 0.0,
179            is_terminator: false,
180        });
181        assert_eq!(
182            decode_udp(&encode_udp(&audio)).expect("re-decode audio"),
183            audio
184        );
185
186        let ping = UdpMessage::Ping(udp::Ping {
187            timestamp: 68_900,
188            ..Default::default()
189        });
190        assert_eq!(
191            decode_udp(&encode_udp(&ping)).expect("re-decode ping"),
192            ping
193        );
194    }
195
196    #[test]
197    fn encoded_header_byte_matches_message_type() {
198        let ping = UdpMessage::Ping(udp::Ping {
199            timestamp: 1,
200            ..Default::default()
201        });
202        assert_eq!(encode_udp(&ping)[0], u8::from(UdpMessageType::Ping));
203    }
204
205    #[test]
206    fn empty_and_header_only_packets_are_too_short() {
207        match decode_udp(&[]) {
208            Err(UdpDecodeError::TooShort { len: 0 }) => {}
209            other => panic!("expected TooShort(0), got {other:?}"),
210        }
211        // A lone header byte with no payload is invalid.
212        match decode_udp(&[0x00]) {
213            Err(UdpDecodeError::TooShort { len: 1 }) => {}
214            other => panic!("expected TooShort(1), got {other:?}"),
215        }
216    }
217
218    #[test]
219    fn legacy_or_unknown_header_fails_closed() {
220        // A legacy Opus voice packet has its codec type in the high bits, e.g.
221        // header 0x80. We do not support legacy: reject rather than misparse.
222        match decode_udp(&[0x80, 0x01, 0x02]) {
223            Err(UdpDecodeError::UnsupportedType(0x80)) => {}
224            other => panic!("expected UnsupportedType(0x80), got {other:?}"),
225        }
226        match decode_udp(&[0x05, 0xFF]) {
227            Err(UdpDecodeError::UnsupportedType(5)) => {}
228            other => panic!("expected UnsupportedType(5), got {other:?}"),
229        }
230    }
231
232    #[test]
233    fn malformed_audio_payload_is_an_error() {
234        // Header says Audio, but the payload is a truncated varint.
235        match decode_udp(&[0x00, 0x08]) {
236            Err(UdpDecodeError::Protobuf {
237                message_type: UdpMessageType::Audio,
238                ..
239            }) => {}
240            other => panic!("expected protobuf error, got {other:?}"),
241        }
242    }
243}