deepslate-protocol 0.1.0

Minecraft protocol primitives for the Deepslate proxy.
Documentation
//! Common types used across the Minecraft protocol.

use bytes::{Buf, BufMut};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::varint;

/// Maximum length of a Minecraft protocol string (32767 UTF-16 code units).
const MAX_STRING_LENGTH: usize = 32767;

/// Errors that can occur during protocol operations.
#[derive(Debug, thiserror::Error)]
pub enum ProtocolError {
    /// `VarInt` exceeded the maximum of 5 bytes.
    #[error("VarInt is too long (exceeded 5 bytes)")]
    VarIntTooLong,

    /// Buffer ran out of data unexpectedly.
    #[error("unexpected end of data")]
    UnexpectedEof,

    /// String exceeded the maximum allowed length.
    #[error("string too long: {length} > {max}")]
    StringTooLong {
        /// Actual length of the string.
        length: usize,
        /// Maximum allowed length.
        max: usize,
    },

    /// String contained invalid UTF-8.
    #[error("invalid UTF-8 in string")]
    InvalidUtf8,

    /// Invalid packet ID for the current state.
    #[error("unknown packet ID {id:#04x} in state {state}")]
    UnknownPacket {
        /// The packet ID that was not recognized.
        id: i32,
        /// The current protocol state.
        state: String,
    },

    /// A packet field had an invalid value.
    #[error("invalid packet data: {0}")]
    InvalidData(String),

    /// Compression error.
    #[error("compression error: {0}")]
    CompressionError(String),

    /// Frame too large.
    #[error("frame too large: {size} bytes (max {max})")]
    FrameTooLarge {
        /// Actual frame size.
        size: usize,
        /// Maximum allowed size.
        max: usize,
    },
}

/// A player's game profile, as returned by the Mojang session server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameProfile {
    /// The player's UUID.
    pub id: Uuid,
    /// The player's username.
    pub name: String,
    /// Profile properties (e.g., skin textures).
    pub properties: Vec<ProfileProperty>,
}

/// A single property in a game profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileProperty {
    /// Property name (e.g., "textures").
    pub name: String,
    /// Base64-encoded property value.
    pub value: String,
    /// Optional base64-encoded signature.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
}

/// Read a Minecraft protocol string (VarInt-prefixed UTF-8).
///
/// # Errors
///
/// Returns an error if the string length is invalid or UTF-8 decoding fails.
pub fn read_string(buf: &mut impl Buf) -> Result<String, ProtocolError> {
    read_string_max(buf, MAX_STRING_LENGTH)
}

/// Read a Minecraft protocol string with a custom maximum length.
///
/// # Errors
///
/// Returns an error if the string length exceeds `max_len` or UTF-8 decoding fails.
#[allow(clippy::cast_sign_loss)]
pub fn read_string_max(buf: &mut impl Buf, max_len: usize) -> Result<String, ProtocolError> {
    let length = varint::read_var_int(buf)? as usize;
    if length > max_len * 4 {
        return Err(ProtocolError::StringTooLong {
            length,
            max: max_len * 4,
        });
    }
    if buf.remaining() < length {
        return Err(ProtocolError::UnexpectedEof);
    }
    let mut data = vec![0u8; length];
    buf.copy_to_slice(&mut data);
    String::from_utf8(data).map_err(|_| ProtocolError::InvalidUtf8)
}

/// Write a Minecraft protocol string (VarInt-prefixed UTF-8).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
pub fn write_string(buf: &mut impl BufMut, value: &str) {
    let bytes = value.as_bytes();
    varint::write_var_int(buf, bytes.len() as i32);
    buf.put_slice(bytes);
}

/// Read a UUID as two big-endian i64 values (Minecraft format).
///
/// # Errors
///
/// Returns `ProtocolError::UnexpectedEof` if not enough data.
pub fn read_uuid(buf: &mut impl Buf) -> Result<Uuid, ProtocolError> {
    if buf.remaining() < 16 {
        return Err(ProtocolError::UnexpectedEof);
    }
    let most = buf.get_u64();
    let least = buf.get_u64();
    Ok(Uuid::from_u64_pair(most, least))
}

/// Write a UUID as two big-endian i64 values (Minecraft format).
pub fn write_uuid(buf: &mut impl BufMut, uuid: Uuid) {
    let (most, least) = uuid.as_u64_pair();
    buf.put_u64(most);
    buf.put_u64(least);
}

/// Write a game profile's properties array in the Minecraft protocol format.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
pub fn write_properties(buf: &mut impl BufMut, properties: &[ProfileProperty]) {
    varint::write_var_int(buf, properties.len() as i32);
    for prop in properties {
        write_string(buf, &prop.name);
        write_string(buf, &prop.value);
        if let Some(sig) = &prop.signature {
            buf.put_u8(1); // has signature
            write_string(buf, sig);
        } else {
            buf.put_u8(0); // no signature
        }
    }
}

/// Read a game profile's properties array from the Minecraft protocol format.
///
/// # Errors
///
/// Returns an error if the data is malformed.
#[allow(clippy::cast_sign_loss)]
pub fn read_properties(buf: &mut impl Buf) -> Result<Vec<ProfileProperty>, ProtocolError> {
    let count = varint::read_var_int(buf)? as usize;
    let mut properties = Vec::with_capacity(count);
    for _ in 0..count {
        let name = read_string(buf)?;
        let value = read_string(buf)?;
        let has_signature = if buf.remaining() < 1 {
            return Err(ProtocolError::UnexpectedEof);
        } else {
            buf.get_u8() != 0
        };
        let signature = if has_signature {
            Some(read_string(buf)?)
        } else {
            None
        };
        properties.push(ProfileProperty {
            name,
            value,
            signature,
        });
    }
    Ok(properties)
}

/// Write an NBT string tag entry (tag type `0x08`) with the given name and value.
///
/// Wire format: `0x08` (`TAG_String`) + name (u16-prefixed) + value (u16-prefixed).
#[allow(clippy::cast_possible_truncation)]
fn write_nbt_string_tag(buf: &mut impl BufMut, name: &str, value: &str) {
    buf.put_u8(0x08); // TAG_String
    buf.put_u16(name.len() as u16);
    buf.put_slice(name.as_bytes());
    buf.put_u16(value.len() as u16);
    buf.put_slice(value.as_bytes());
}

/// Write a Minecraft text component encoded as network NBT.
///
/// Since 1.20.2 (protocol 764), text components in PLAY and CONFIG state
/// packets use NBT encoding instead of JSON strings. The network NBT format
/// omits the root tag name.
///
/// This writes a minimal compound tag: `{"text": "...", "color": "..."}`.
/// The `color` field is only included if `color` is `Some`.
pub fn write_nbt_text_component(buf: &mut impl BufMut, text: &str, color: Option<&str>) {
    buf.put_u8(0x0A); // TAG_Compound (root, no name in network NBT)
    write_nbt_string_tag(buf, "text", text);
    if let Some(color) = color {
        write_nbt_string_tag(buf, "color", color);
    }
    buf.put_u8(0x00); // TAG_End
}

/// Format a UUID without dashes (Minecraft's "undashed" format).
#[must_use]
pub fn undashed_uuid(uuid: Uuid) -> String {
    uuid.as_simple().to_string()
}

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

    #[test]
    fn test_string_roundtrip() {
        let mut buf = Vec::new();
        write_string(&mut buf, "Hello, Minecraft!");
        let result = read_string(&mut &buf[..]).unwrap();
        assert_eq!(result, "Hello, Minecraft!");
    }

    #[test]
    fn test_string_empty() {
        let mut buf = Vec::new();
        write_string(&mut buf, "");
        let result = read_string(&mut &buf[..]).unwrap();
        assert_eq!(result, "");
    }

    #[test]
    fn test_uuid_roundtrip() {
        let uuid = Uuid::parse_str("069a79f4-44e9-4726-a5be-fca90e38aaf5").unwrap();
        let mut buf = Vec::new();
        write_uuid(&mut buf, uuid);
        assert_eq!(buf.len(), 16);
        let result = read_uuid(&mut &buf[..]).unwrap();
        assert_eq!(result, uuid);
    }

    #[test]
    fn test_properties_roundtrip() {
        let props = vec![
            ProfileProperty {
                name: "textures".to_string(),
                value: "base64data".to_string(),
                signature: Some("sig".to_string()),
            },
            ProfileProperty {
                name: "other".to_string(),
                value: "val".to_string(),
                signature: None,
            },
        ];
        let mut buf = Vec::new();
        write_properties(&mut buf, &props);
        let result = read_properties(&mut &buf[..]).unwrap();
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].name, "textures");
        assert_eq!(result[0].signature.as_deref(), Some("sig"));
        assert_eq!(result[1].signature, None);
    }

    #[test]
    fn test_nbt_text_component_simple() {
        let mut buf = Vec::new();
        write_nbt_text_component(&mut buf, "hello", None);
        // TAG_Compound (0x0A), TAG_String (0x08), name "text" (4 bytes), value "hello" (5 bytes), TAG_End (0x00)
        assert_eq!(buf[0], 0x0A); // compound
        assert_eq!(buf[1], 0x08); // string tag
        assert_eq!(&buf[2..4], &[0x00, 0x04]); // name length = 4
        assert_eq!(&buf[4..8], b"text");
        assert_eq!(&buf[8..10], &[0x00, 0x05]); // value length = 5
        assert_eq!(&buf[10..15], b"hello");
        assert_eq!(buf[15], 0x00); // end tag
        assert_eq!(buf.len(), 16);
    }

    #[test]
    fn test_nbt_text_component_with_color() {
        let mut buf = Vec::new();
        write_nbt_text_component(&mut buf, "hi", Some("yellow"));
        assert_eq!(buf[0], 0x0A); // compound
        // First entry: "text" = "hi"
        assert_eq!(buf[1], 0x08);
        assert_eq!(&buf[2..4], &[0x00, 0x04]);
        assert_eq!(&buf[4..8], b"text");
        assert_eq!(&buf[8..10], &[0x00, 0x02]);
        assert_eq!(&buf[10..12], b"hi");
        // Second entry: "color" = "yellow"
        assert_eq!(buf[12], 0x08);
        assert_eq!(&buf[13..15], &[0x00, 0x05]);
        assert_eq!(&buf[15..20], b"color");
        assert_eq!(&buf[20..22], &[0x00, 0x06]);
        assert_eq!(&buf[22..28], b"yellow");
        // End tag
        assert_eq!(buf[28], 0x00);
        assert_eq!(buf.len(), 29);
    }
}