bloop_server_framework/
message.rs

1//! Module handling the protocol messages exchanged between client and server.
2//!
3//! It defines message structures, serialization/deserialization logic, and
4//! error handling used in the communication protocol.
5//!
6//! This module supports parsing raw messages into typed enums for client and
7//! server messages, managing data hashes, server feature flags, and achievement
8//! records.
9
10use crate::nfc_uid::NfcUid;
11use bitmask_enum::bitmask;
12use byteorder::ReadBytesExt;
13use md5::Digest;
14use std::io::{self, Cursor, Read};
15use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
16use uuid::Uuid;
17
18/// Represents a raw protocol message with a type byte and a payload.
19#[derive(Debug, Clone)]
20pub struct Message {
21    /// Message type identifier (opcode).
22    message_type: u8,
23    /// Payload bytes associated with this message.
24    payload: Vec<u8>,
25}
26
27impl Message {
28    /// Constructs a new [`Message`] with the specified type and payload.
29    pub fn new(message_type: u8, payload: Vec<u8>) -> Self {
30        Self {
31            message_type,
32            payload,
33        }
34    }
35
36    /// Serializes the [`Message`] into bytes:
37    ///
38    /// - 1 byte for message_type
39    /// - 4 bytes little-endian length of payload
40    /// - payload bytes
41    pub fn into_bytes(self) -> Vec<u8> {
42        let mut bytes = Vec::with_capacity(1 + 4 + self.payload.len());
43        bytes.push(self.message_type);
44        bytes.extend_from_slice(&(self.payload.len() as u32).to_le_bytes());
45        bytes.extend(self.payload);
46
47        bytes
48    }
49}
50
51/// Wrapper around an MD5 `Digest` representing a data hash.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct DataHash(Digest);
54
55impl From<Digest> for DataHash {
56    fn from(value: Digest) -> Self {
57        Self(value)
58    }
59}
60
61impl DataHash {
62    /// Returns the hash bytes as a slice.
63    pub fn as_bytes(&self) -> &[u8] {
64        self.0.as_ref()
65    }
66
67    /// Converts the [`DataHash`] into a 17-byte array tagged with
68    /// length prefix (16).
69    fn into_tagged_bytes(self) -> [u8; 17] {
70        let mut bytes = [0u8; 17];
71        bytes[0] = 16;
72        bytes[1..].copy_from_slice(self.0.as_slice());
73
74        bytes
75    }
76
77    /// Attempts to read an optional [`DataHash`] from a byte cursor.
78    fn from_cursor_opt(cursor: &mut Cursor<Vec<u8>>) -> Result<Option<Self>, io::Error> {
79        let length = cursor.read_u8()?;
80
81        if length == 0 {
82            return Ok(None);
83        }
84
85        if length != 16 {
86            return Err(io::Error::new(
87                io::ErrorKind::InvalidInput,
88                format!("invalid data hash length: {length}"),
89            ));
90        }
91
92        let mut bytes = [0u8; 16];
93        cursor.read_exact(&mut bytes)?;
94
95        Ok(Some(Self(Digest(bytes))))
96    }
97}
98
99/// Bitmask enum representing features supported by the server.
100#[bitmask(u64)]
101#[bitmask_config(vec_debug)]
102pub enum ServerFeatures {
103    /// Indicates support for preload checks.
104    PreloadCheck = 0x1,
105}
106
107/// Enum of messages sent from the client to the server.
108#[derive(Debug)]
109pub enum ClientMessage {
110    /// Handshake message specifying supported protocol version range.
111    ClientHandshake { min_version: u8, max_version: u8 },
112
113    /// Authentication message including client ID, secret, and IP address.
114    Authentication {
115        client_id: String,
116        client_secret: String,
117        ip_addr: IpAddr,
118    },
119
120    /// Ping message (keep-alive).
121    Ping,
122
123    /// Quit message (disconnect).
124    Quit,
125
126    /// "Bloop" message containing an NFC UID.
127    Bloop { nfc_uid: NfcUid },
128
129    /// Request to retrieve audio data associated with an achievement ID.
130    RetrieveAudio { achievement_id: Uuid },
131
132    /// Preload check optionally including a hash of the audio manifest.
133    PreloadCheck {
134        audio_manifest_hash: Option<DataHash>,
135    },
136
137    /// Unknown or unsupported message variant, carrying raw message data.
138    Unknown(Message),
139}
140
141impl TryFrom<Message> for ClientMessage {
142    type Error = io::Error;
143
144    /// Tries to parse a raw [`Message`] into a typed [`ClientMessage`] variant.
145    ///
146    /// Returns an error if the message payload is malformed or incomplete.
147    fn try_from(message: Message) -> Result<Self, Self::Error> {
148        let mut cursor = Cursor::new(message.payload);
149
150        match message.message_type {
151            0x01 => {
152                let min_version = cursor.read_u8()?;
153                let max_version = cursor.read_u8()?;
154
155                Ok(Self::ClientHandshake {
156                    min_version,
157                    max_version,
158                })
159            }
160            0x03 => {
161                let client_id = read_string(&mut cursor)?;
162                let client_secret = read_string(&mut cursor)?;
163                let ip_addr = read_ip_addr(&mut cursor)?;
164
165                Ok(Self::Authentication {
166                    client_id,
167                    client_secret,
168                    ip_addr,
169                })
170            }
171            0x05 => Ok(Self::Ping),
172            0x07 => Ok(Self::Quit),
173            0x08 => {
174                let length = cursor.read_u8()? as usize;
175                let mut buffer = vec![0u8; length];
176                cursor.read_exact(&mut buffer)?;
177
178                let nfc_uid = NfcUid::try_from(buffer.as_slice()).map_err(|_| {
179                    io::Error::new(io::ErrorKind::InvalidData, "Invalid NFC UID length or data")
180                })?;
181
182                Ok(Self::Bloop { nfc_uid })
183            }
184            0x0a => {
185                let mut uuid = [0u8; 16];
186                cursor.read_exact(&mut uuid)?;
187
188                Ok(Self::RetrieveAudio {
189                    achievement_id: Uuid::from_bytes(uuid),
190                })
191            }
192            0x0c => {
193                let hash = DataHash::from_cursor_opt(&mut cursor)?;
194
195                Ok(Self::PreloadCheck {
196                    audio_manifest_hash: hash,
197                })
198            }
199            code => Ok(Self::Unknown(Message::new(code, cursor.into_inner()))),
200        }
201    }
202}
203
204/// Reads a length-prefixed UTF-8 string from the cursor.
205///
206/// Returns an IO error if the string is invalid UTF-8 or reading fails.
207fn read_string(cursor: &mut Cursor<Vec<u8>>) -> Result<String, io::Error> {
208    let length = cursor.read_u8()? as usize;
209    let mut buffer = vec![0; length];
210    cursor.read_exact(&mut buffer)?;
211
212    String::from_utf8(buffer)
213        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8 string"))
214}
215
216/// Reads an IP address from the cursor.
217///
218/// Format: 1 byte kind (4 for IPv4, 6 for IPv6), followed by bytes of the
219/// address. Returns an IO error if the kind is invalid or reading fails.
220fn read_ip_addr(cursor: &mut Cursor<Vec<u8>>) -> Result<IpAddr, io::Error> {
221    let kind = cursor.read_u8()?;
222
223    match kind {
224        4 => {
225            let mut bytes = [0u8; 4];
226            cursor.read_exact(&mut bytes)?;
227            Ok(IpAddr::V4(Ipv4Addr::from(bytes)))
228        }
229        6 => {
230            let mut bytes = [0u8; 16];
231            cursor.read_exact(&mut bytes)?;
232            Ok(IpAddr::V6(Ipv6Addr::from(bytes)))
233        }
234        _ => Err(io::Error::new(
235            io::ErrorKind::InvalidData,
236            format!("invalid IP address type: {kind}"),
237        )),
238    }
239}
240
241/// Record representing an achievement, including its UUID and optional audio
242/// file hash.
243#[derive(Debug)]
244pub struct AchievementRecord {
245    /// UUID of the achievement.
246    pub id: Uuid,
247    /// Optional hash of the associated audio file.
248    pub audio_file_hash: Option<DataHash>,
249}
250
251impl AchievementRecord {
252    /// Serializes the `AchievementRecord` into bytes:
253    ///
254    /// - 16 bytes for UUID
255    /// - 17 bytes tagged DataHash if present, or a zero byte if absent.
256    fn into_bytes(self) -> Vec<u8> {
257        let mut bytes = Vec::with_capacity(16 + 17);
258        bytes.extend_from_slice(&self.id.into_bytes());
259
260        match self.audio_file_hash {
261            Some(hash) => bytes.extend_from_slice(&hash.into_tagged_bytes()),
262            None => bytes.push(0),
263        }
264
265        bytes
266    }
267}
268
269/// Enum representing error responses from the server.
270#[derive(Debug)]
271pub enum ErrorResponse {
272    UnexpectedMessage,
273    MalformedMessage,
274    UnsupportedVersionRange,
275    InvalidCredentials,
276    UnknownNfcUid,
277    NfcUidThrottled,
278    AudioUnavailable,
279    /// Custom error code with arbitrary value.
280    Custom(u8),
281}
282
283impl From<ErrorResponse> for u8 {
284    fn from(error: ErrorResponse) -> u8 {
285        error.into_error_code()
286    }
287}
288
289impl ErrorResponse {
290    /// Converts the `ErrorResponse` into the corresponding numeric error code.
291    fn into_error_code(self) -> u8 {
292        match self {
293            Self::UnexpectedMessage => 0,
294            Self::MalformedMessage => 1,
295            Self::UnsupportedVersionRange => 2,
296            Self::InvalidCredentials => 3,
297            Self::UnknownNfcUid => 4,
298            Self::NfcUidThrottled => 5,
299            Self::AudioUnavailable => 6,
300            Self::Custom(code) => code,
301        }
302    }
303}
304
305/// Enum of messages sent from the server to the client.
306#[derive(Debug)]
307pub enum ServerMessage {
308    /// Error response message.
309    Error(ErrorResponse),
310
311    /// Server handshake response with accepted protocol version and features.
312    ServerHandshake {
313        accepted_version: u8,
314        features: ServerFeatures,
315    },
316
317    /// Authentication was accepted.
318    AuthenticationAccepted,
319
320    /// Pong response to client's ping.
321    Pong,
322
323    /// Response to a Bloop message containing achievement records.
324    BloopAccepted {
325        achievements: Vec<AchievementRecord>,
326    },
327
328    /// Audio data response carrying raw bytes.
329    AudioData { data: Vec<u8> },
330
331    /// Indicates preload data matched on the server.
332    PreloadMatch,
333
334    /// Indicates preload data mismatched, includes hash and achievements.
335    PreloadMismatch {
336        audio_manifest_hash: DataHash,
337        achievements: Vec<AchievementRecord>,
338    },
339
340    /// Custom server message.
341    Custom(Message),
342}
343
344impl From<ServerMessage> for Message {
345    /// Converts a `ServerMessage` enum into a raw `Message` suitable for
346    /// transmission.
347    fn from(server_message: ServerMessage) -> Message {
348        match server_message {
349            ServerMessage::Error(error) => Message::new(0x00, vec![error.into_error_code()]),
350            ServerMessage::ServerHandshake {
351                accepted_version,
352                features,
353            } => {
354                let mut payload = Vec::with_capacity(9);
355                payload.push(accepted_version);
356                payload.extend_from_slice(&features.bits().to_le_bytes());
357                Message::new(0x02, payload)
358            }
359            ServerMessage::AuthenticationAccepted => Message::new(0x04, vec![]),
360            ServerMessage::Pong => Message::new(0x06, vec![]),
361            ServerMessage::BloopAccepted { achievements } => {
362                let mut payload = Vec::with_capacity(1 + achievements.len() * (16 + 17));
363                payload.push(achievements.len() as u8);
364
365                for achievement in achievements {
366                    payload.extend(achievement.into_bytes())
367                }
368
369                Message::new(0x09, payload)
370            }
371            ServerMessage::AudioData { data } => {
372                let mut payload = Vec::with_capacity(4 + data.len());
373                payload.extend_from_slice(&(data.len() as u32).to_le_bytes());
374                payload.extend(data);
375
376                Message::new(0x0b, payload)
377            }
378            ServerMessage::PreloadMatch => Message::new(0x0d, vec![]),
379            ServerMessage::PreloadMismatch {
380                audio_manifest_hash,
381                achievements,
382            } => {
383                let mut payload = Vec::with_capacity(17 + 1 + achievements.len() * (16 + 17));
384                payload.extend_from_slice(&audio_manifest_hash.into_tagged_bytes());
385                payload.extend_from_slice(&(achievements.len() as u32).to_le_bytes());
386
387                for achievement in achievements {
388                    payload.extend(achievement.into_bytes())
389                }
390
391                Message::new(0x0e, payload)
392            }
393            ServerMessage::Custom(message) => message,
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::net::{IpAddr, Ipv4Addr};
402    use uuid::Uuid;
403
404    fn make_message(msg_type: u8, payload: &[u8]) -> Message {
405        Message::new(msg_type, payload.to_vec())
406    }
407
408    #[test]
409    fn client_handshake_parses_correctly_from_message() {
410        let payload = [1u8, 5];
411        let msg = make_message(0x01, &payload);
412        let client_msg = ClientMessage::try_from(msg).unwrap();
413
414        match client_msg {
415            ClientMessage::ClientHandshake {
416                min_version,
417                max_version,
418            } => {
419                assert_eq!(min_version, 1);
420                assert_eq!(max_version, 5);
421            }
422            _ => panic!("Expected ClientHandshake variant"),
423        }
424    }
425
426    #[test]
427    fn authentication_parses_correctly_from_message() {
428        let mut payload = vec![];
429        payload.push(3);
430        payload.extend(b"foo");
431        payload.push(3);
432        payload.extend(b"bar");
433        payload.push(4);
434        payload.extend(&[127, 0, 0, 1]);
435
436        let msg = make_message(0x03, &payload);
437        let client_msg = ClientMessage::try_from(msg).unwrap();
438
439        match client_msg {
440            ClientMessage::Authentication {
441                client_id,
442                client_secret,
443                ip_addr,
444            } => {
445                assert_eq!(client_id, "foo");
446                assert_eq!(client_secret, "bar");
447                assert_eq!(ip_addr, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
448            }
449            _ => panic!("Expected Authentication variant"),
450        }
451    }
452
453    #[test]
454    fn ping_message_parses_as_ping_variant() {
455        let msg = make_message(0x05, &[]);
456        let client_msg = ClientMessage::try_from(msg).unwrap();
457        assert!(matches!(client_msg, ClientMessage::Ping));
458    }
459
460    #[test]
461    fn quit_message_parses_as_quit_variant() {
462        let msg = make_message(0x07, &[]);
463        let client_msg = ClientMessage::try_from(msg).unwrap();
464        assert!(matches!(client_msg, ClientMessage::Quit));
465    }
466
467    #[test]
468    fn bloop_message_parses_single_nfc_uid_correctly() {
469        let payload = [4u8, 1, 2, 3, 4];
470        let msg = make_message(0x08, &payload);
471        let client_msg = ClientMessage::try_from(msg).unwrap();
472
473        match client_msg {
474            ClientMessage::Bloop { nfc_uid } => {
475                let expected = NfcUid::try_from(&[1, 2, 3, 4][..]).unwrap();
476                assert_eq!(nfc_uid, expected);
477            }
478            _ => panic!("Expected Bloop variant"),
479        }
480    }
481
482    #[test]
483    fn retrieve_audio_message_parses_uuid_correctly() {
484        let uuid = Uuid::new_v4();
485        let payload = uuid.as_bytes();
486        let msg = make_message(0x0a, payload);
487        let client_msg = ClientMessage::try_from(msg).unwrap();
488
489        match client_msg {
490            ClientMessage::RetrieveAudio { achievement_id } => {
491                assert_eq!(achievement_id, uuid);
492            }
493            _ => panic!("Expected RetrieveAudio variant"),
494        }
495    }
496
497    #[test]
498    fn preload_check_message_parses_with_some_hash() {
499        let mut payload = vec![16];
500        payload.extend_from_slice(&[0u8; 16]);
501        let msg = make_message(0x0c, &payload);
502        let client_msg = ClientMessage::try_from(msg).unwrap();
503
504        match client_msg {
505            ClientMessage::PreloadCheck {
506                audio_manifest_hash,
507            } => {
508                assert!(audio_manifest_hash.is_some());
509                let hash = audio_manifest_hash.unwrap();
510                assert_eq!(hash.0.as_slice(), &[0u8; 16]);
511            }
512            _ => panic!("Expected PreloadCheck variant"),
513        }
514    }
515
516    #[test]
517    fn preload_check_message_parses_with_none_hash() {
518        let payload = [0];
519        let msg = make_message(0x0c, &payload);
520        let client_msg = ClientMessage::try_from(msg).unwrap();
521
522        match client_msg {
523            ClientMessage::PreloadCheck {
524                audio_manifest_hash,
525            } => {
526                assert!(audio_manifest_hash.is_none());
527            }
528            _ => panic!("Expected PreloadCheck variant"),
529        }
530    }
531
532    #[test]
533    fn unknown_message_parses_correctly_with_payload_preserved() {
534        let payload = [1, 2, 3];
535        let msg = make_message(0xFF, &payload);
536        let client_msg = ClientMessage::try_from(msg).unwrap();
537
538        match client_msg {
539            ClientMessage::Unknown(m) => {
540                assert_eq!(m.message_type, 0xFF);
541                assert_eq!(m.payload, payload);
542            }
543            _ => panic!("Expected Unknown variant"),
544        }
545    }
546
547    #[test]
548    fn client_handshake_fails_if_payload_too_short() {
549        let msg = make_message(0x01, &[1]);
550        assert!(ClientMessage::try_from(msg).is_err());
551    }
552
553    #[test]
554    fn authentication_fails_on_invalid_utf8_client_id() {
555        let mut payload = vec![2];
556        payload.extend(&[0xff, 0xff]);
557        payload.push(3);
558        payload.extend(b"bar");
559        payload.push(4);
560        payload.extend(&[127, 0, 0, 1]);
561
562        let msg = make_message(0x03, &payload);
563        assert!(ClientMessage::try_from(msg).is_err());
564    }
565
566    #[test]
567    fn authentication_fails_on_invalid_utf8_client_secret() {
568        let mut payload = vec![3];
569        payload.extend(b"foo");
570        payload.push(2);
571        payload.extend(&[0xff, 0xff]);
572        payload.push(4);
573        payload.extend(&[127, 0, 0, 1]);
574
575        let msg = make_message(0x03, &payload);
576        assert!(ClientMessage::try_from(msg).is_err());
577    }
578
579    #[test]
580    fn authentication_fails_on_invalid_ip_kind() {
581        let mut payload = vec![3];
582        payload.extend(b"foo");
583        payload.push(3);
584        payload.extend(b"bar");
585        payload.push(0xff);
586        payload.extend(&[1, 2, 3, 4]);
587
588        let msg = make_message(0x03, &payload);
589        assert!(ClientMessage::try_from(msg).is_err());
590    }
591
592    #[test]
593    fn bloop_fails_if_nfc_uid_length_mismatch() {
594        let payload = [5u8, 1, 2, 3, 4];
595        let msg = make_message(0x08, &payload);
596        assert!(ClientMessage::try_from(msg).is_err());
597    }
598
599    #[test]
600    fn retrieve_audio_fails_if_uuid_too_short() {
601        let payload = [0u8; 15];
602        let msg = make_message(0x0a, &payload);
603        assert!(ClientMessage::try_from(msg).is_err());
604    }
605
606    #[test]
607    fn preload_check_fails_on_invalid_length() {
608        let payload = [1, 0];
609        let msg = make_message(0x0c, &payload);
610        assert!(ClientMessage::try_from(msg).is_err());
611    }
612
613    #[test]
614    fn server_message_error_serializes_correctly() {
615        let server_msg = ServerMessage::Error(ErrorResponse::InvalidCredentials);
616        let message: Message = server_msg.into();
617        assert_eq!(message.message_type, 0x00);
618        assert_eq!(message.payload, vec![3]);
619    }
620
621    #[test]
622    fn server_handshake_serializes_correctly() {
623        let features = ServerFeatures::none();
624        let server_msg = ServerMessage::ServerHandshake {
625            accepted_version: 7,
626            features,
627        };
628        let message: Message = server_msg.into();
629        assert_eq!(message.message_type, 0x02);
630        assert_eq!(message.payload.len(), 9);
631        assert_eq!(message.payload[0], 7);
632        assert_eq!(&message.payload[1..], &features.bits().to_le_bytes());
633    }
634
635    #[test]
636    fn authentication_accepted_serializes_to_empty_payload() {
637        let server_msg = ServerMessage::AuthenticationAccepted;
638        let message: Message = server_msg.into();
639        assert_eq!(message.message_type, 0x04);
640        assert!(message.payload.is_empty());
641    }
642
643    #[test]
644    fn pong_serializes_to_empty_payload() {
645        let server_msg = ServerMessage::Pong;
646        let message: Message = server_msg.into();
647        assert_eq!(message.message_type, 0x06);
648        assert!(message.payload.is_empty());
649    }
650
651    #[test]
652    fn bloop_accepted_serializes_with_achievements() {
653        let uuid = Uuid::new_v4();
654        let record = AchievementRecord {
655            id: uuid,
656            audio_file_hash: None,
657        };
658
659        let server_msg = ServerMessage::BloopAccepted {
660            achievements: vec![record],
661        };
662
663        let message: Message = server_msg.into();
664        assert_eq!(message.message_type, 0x09);
665        assert_eq!(message.payload[0], 1);
666
667        assert_eq!(&message.payload[1..17], uuid.as_bytes());
668        assert_eq!(message.payload.len(), 1 + 16 + 1);
669    }
670
671    #[test]
672    fn audio_data_serializes_correctly() {
673        let data = vec![1, 2, 3, 4, 5];
674        let server_msg = ServerMessage::AudioData { data: data.clone() };
675        let message: Message = server_msg.into();
676
677        assert_eq!(message.message_type, 0x0b);
678        assert_eq!(&message.payload[4..], &data[..]);
679
680        let length = u32::from_le_bytes(message.payload[0..4].try_into().unwrap());
681        assert_eq!(length as usize, data.len());
682    }
683
684    #[test]
685    fn preload_match_serializes_to_empty_payload() {
686        let server_msg = ServerMessage::PreloadMatch;
687        let message: Message = server_msg.into();
688
689        assert_eq!(message.message_type, 0x0d);
690        assert!(message.payload.is_empty());
691    }
692
693    #[test]
694    fn preload_mismatch_serializes_with_hash_and_achievements() {
695        let hash = DataHash(Digest([1u8; 16]));
696        let uuid = Uuid::new_v4();
697        let record = AchievementRecord {
698            id: uuid,
699            audio_file_hash: None,
700        };
701
702        let server_msg = ServerMessage::PreloadMismatch {
703            audio_manifest_hash: hash,
704            achievements: vec![record],
705        };
706
707        let message: Message = server_msg.into();
708        assert_eq!(message.message_type, 0x0e);
709
710        // Check the first byte is length 16 for DataHash (per into_bytes)
711        assert_eq!(message.payload[0], 16);
712        assert_eq!(&message.payload[1..17], &hash.0.as_slice()[..]);
713
714        // Number of achievements (4 bytes little-endian)
715        let count = u32::from_le_bytes(message.payload[17..21].try_into().unwrap());
716        assert_eq!(count, 1);
717
718        // UUID bytes start at 21
719        assert_eq!(&message.payload[21..37], uuid.as_bytes());
720    }
721
722    #[test]
723    fn custom_server_message_passes_through_as_is() {
724        let original = Message::new(0xAB, vec![9, 8, 7]);
725        let server_msg = ServerMessage::Custom(original.clone());
726        let message: Message = server_msg.into();
727
728        assert_eq!(message.message_type, 0xAB);
729        assert_eq!(message.payload, vec![9, 8, 7]);
730    }
731}