bloop-protocol 1.1.0

Core implementation of the Bloop wire protocol
//! Payload encoding and decoding.
//!
//! [`Encode`] and [`Decode`] describe how a value maps onto the packed byte
//! layout the protocol uses for message payloads. Implementations exist for
//! the spec's primitive data types; message structs typically derive both.
//!
//! Length-prefixed collections come in two prefix widths (`u8` and `u32`),
//! selected per field via the `#[bloop(count = ...)]` derive attribute, which
//! is backed by the free functions in this module.

mod counted;
mod primitives;

use thiserror::Error;

pub use counted::{
    decode_bytes_u8, decode_bytes_u32, decode_counted_u8, decode_counted_u32, encode_bytes_u8,
    encode_bytes_u32, encode_counted_u8, encode_counted_u32,
};

/// Errors that can occur while encoding a payload.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EncodeError {
    /// A string exceeds the 255 bytes its `u8` length prefix can express.
    #[error("string of {length} bytes exceeds the u8 length prefix limit of 255")]
    StringTooLong { length: usize },

    /// A collection exceeds what its count prefix can express.
    #[error("collection of {length} elements exceeds its count prefix limit of {limit}")]
    CountOverflow { length: usize, limit: u64 },

    /// A byte string exceeds what its length prefix can express.
    #[error("byte string of {length} bytes exceeds its length prefix limit of {limit}")]
    BytesOverflow { length: usize, limit: u64 },
}

/// Errors that can occur while decoding a payload.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DecodeError {
    /// The payload ended before the value was complete.
    #[error("payload ended before the value was complete")]
    UnexpectedEof,

    /// A string is not valid UTF-8.
    #[error("string is not valid UTF-8")]
    InvalidUtf8(#[from] std::string::FromUtf8Error),

    /// An IP address carries a version tag other than 4 or 6.
    #[error("invalid IP version tag {0}, expected 4 or 6")]
    InvalidIpVersion(u8),

    /// An NFC UID declares a length other than 4, 7 or 10.
    #[error("invalid NFC UID length {0}, must be 4, 7 or 10")]
    InvalidNfcUidLength(u8),

    /// A non-optional hash declares a length of zero.
    #[error("hash must not be empty")]
    EmptyHash,

    /// The payload contains bytes past the end of the decoded value.
    #[error("{remaining} trailing bytes after the payload was decoded")]
    TrailingBytes { remaining: usize },
}

/// A value that can be written in its wire representation.
pub trait Encode {
    /// Appends the wire representation of the value to `out`.
    ///
    /// # Errors
    ///
    /// Returns an [`EncodeError`] if a length or count does not fit its wire
    /// prefix.
    fn encode(&self, out: &mut Vec<u8>) -> Result<(), EncodeError>;
}

/// A value that can be read from its wire representation.
pub trait Decode: Sized {
    /// Reads a value from the front of `input`, advancing it past the
    /// consumed bytes.
    ///
    /// # Errors
    ///
    /// Returns a [`DecodeError`] if the input is exhausted or contains an
    /// invalid value.
    fn decode(input: &mut &[u8]) -> Result<Self, DecodeError>;
}

/// Encodes a value into a standalone payload byte vector.
///
/// # Errors
///
/// Returns an [`EncodeError`] if a length or count does not fit its wire
/// prefix.
pub fn encode_payload<T: Encode>(value: &T) -> Result<Vec<u8>, EncodeError> {
    let mut out = Vec::new();
    value.encode(&mut out)?;
    Ok(out)
}

/// Decodes a complete payload, rejecting trailing bytes.
///
/// Full consumption is enforced here at the message boundary rather than in
/// [`Decode`] itself: nested records inside a payload legitimately leave
/// bytes behind for the fields that follow them.
///
/// # Errors
///
/// Returns a [`DecodeError`] if decoding fails or bytes remain after the
/// value was decoded.
pub fn decode_payload<T: Decode>(payload: &[u8]) -> Result<T, DecodeError> {
    let mut input = payload;
    let value = T::decode(&mut input)?;

    if !input.is_empty() {
        return Err(DecodeError::TrailingBytes {
            remaining: input.len(),
        });
    }

    Ok(value)
}

/// Splits `length` bytes off the front of `input`.
pub(crate) fn take<'a>(input: &mut &'a [u8], length: usize) -> Result<&'a [u8], DecodeError> {
    if input.len() < length {
        return Err(DecodeError::UnexpectedEof);
    }

    let (taken, rest) = input.split_at(length);
    *input = rest;

    Ok(taken)
}

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

    #[test]
    fn decode_payload_rejects_trailing_bytes() {
        let payload = [7u8, 0xff];
        let error = decode_payload::<u8>(&payload).unwrap_err();

        assert!(matches!(error, DecodeError::TrailingBytes { remaining: 1 }));
    }

    #[test]
    fn decode_payload_accepts_exact_payload() {
        let payload = [7u8];
        assert_eq!(decode_payload::<u8>(&payload).unwrap(), 7);
    }
}