bloop-protocol 1.1.0

Core implementation of the Bloop wire protocol
//! Variable-length data hashes.

use std::fmt::{self, Display, Formatter};

use thiserror::Error;

use crate::codec::{Decode, DecodeError, Encode, EncodeError, decode_bytes_u8, encode_bytes_u8};

/// Errors that can occur while constructing a [`DataHash`].
#[derive(Debug, Error)]
pub enum Error {
    /// The hash is empty or exceeds 255 bytes.
    #[error("invalid hash length {length}, must be between 1 and 255 bytes")]
    InvalidLength { length: usize },
}

/// An opaque hash over associated data, such as an audio file.
///
/// The protocol makes no assumption about the hash algorithm. On the wire a
/// hash is a byte string prefixed with its length as `u8`, where a length of
/// zero signals absence; `DataHash` itself is therefore never empty, and
/// absence is expressed as `Option<DataHash>`, which encodes `None` as a
/// single zero byte.
///
/// In practice servers use MD5 (16 bytes); enable the `md5` feature for a
/// direct conversion from [`md5::Digest`].
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct DataHash(Vec<u8>);

impl DataHash {
    /// Returns the raw hash bytes.
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }
}

impl TryFrom<Vec<u8>> for DataHash {
    type Error = Error;

    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
        if value.is_empty() || value.len() > 255 {
            return Err(Error::InvalidLength {
                length: value.len(),
            });
        }

        Ok(Self(value))
    }
}

impl TryFrom<&[u8]> for DataHash {
    type Error = Error;

    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
        Self::try_from(value.to_vec())
    }
}

impl Display for DataHash {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        for byte in &self.0 {
            write!(f, "{byte:02x}")?;
        }

        Ok(())
    }
}

impl Encode for DataHash {
    fn encode(&self, out: &mut Vec<u8>) -> Result<(), EncodeError> {
        encode_bytes_u8(&self.0, out)
    }
}

impl Decode for DataHash {
    fn decode(input: &mut &[u8]) -> Result<Self, DecodeError> {
        let bytes = decode_bytes_u8(input)?;

        if bytes.is_empty() {
            return Err(DecodeError::EmptyHash);
        }

        Ok(Self(bytes))
    }
}

impl Encode for Option<DataHash> {
    fn encode(&self, out: &mut Vec<u8>) -> Result<(), EncodeError> {
        match self {
            Some(hash) => hash.encode(out),
            None => {
                out.push(0);
                Ok(())
            }
        }
    }
}

impl Decode for Option<DataHash> {
    fn decode(input: &mut &[u8]) -> Result<Self, DecodeError> {
        let bytes = decode_bytes_u8(input)?;

        if bytes.is_empty() {
            return Ok(None);
        }

        Ok(Some(DataHash(bytes)))
    }
}

#[cfg(feature = "md5")]
impl From<md5::Digest> for DataHash {
    fn from(digest: md5::Digest) -> Self {
        Self(digest.0.to_vec())
    }
}

#[cfg(feature = "hex")]
impl hex::FromHex for DataHash {
    type Error = hex::FromHexError;

    fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
        let bytes = Vec::from_hex(hex)?;
        Self::try_from(bytes).map_err(|_| hex::FromHexError::InvalidStringLength)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for DataHash {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        use hex::FromHex;

        let hex = String::deserialize(deserializer)?;
        DataHash::from_hex(&hex).map_err(serde::de::Error::custom)
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for DataHash {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(&hex::encode(&self.0))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::codec::{decode_payload, encode_payload};

    #[test]
    fn rejects_empty_and_oversized_hashes() {
        assert!(matches!(
            DataHash::try_from(vec![]).unwrap_err(),
            Error::InvalidLength { length: 0 }
        ));
        assert!(matches!(
            DataHash::try_from(vec![0; 256]).unwrap_err(),
            Error::InvalidLength { length: 256 }
        ));
    }

    #[test]
    fn hash_is_u8_length_prefixed() {
        let hash = DataHash::try_from(vec![1u8; 16]).unwrap();
        let mut expected = vec![16];
        expected.extend_from_slice(&[1; 16]);

        assert_eq!(encode_payload(&hash).unwrap(), expected);
        assert_eq!(decode_payload::<DataHash>(&expected).unwrap(), hash);
    }

    #[test]
    fn non_optional_hash_rejects_zero_length() {
        let error = decode_payload::<DataHash>(&[0]).unwrap_err();
        assert!(matches!(error, DecodeError::EmptyHash));
    }

    #[test]
    fn optional_hash_encodes_none_as_zero_byte() {
        assert_eq!(encode_payload(&None::<DataHash>).unwrap(), [0]);
        assert_eq!(decode_payload::<Option<DataHash>>(&[0]).unwrap(), None);

        let hash = DataHash::try_from(vec![7u8; 4]).unwrap();
        let encoded = encode_payload(&Some(hash.clone())).unwrap();

        assert_eq!(encoded, [4, 7, 7, 7, 7]);
        assert_eq!(
            decode_payload::<Option<DataHash>>(&encoded).unwrap(),
            Some(hash)
        );
    }

    #[test]
    fn displays_as_hex() {
        let hash = DataHash::try_from(vec![0xde, 0xad, 0xbe, 0xef]).unwrap();
        assert_eq!(hash.to_string(), "deadbeef");
    }

    #[cfg(feature = "md5")]
    #[test]
    fn converts_from_md5_digest() {
        let hash = DataHash::from(md5::compute(b"bloop"));
        assert_eq!(hash.as_bytes().len(), 16);
    }

    #[cfg(feature = "hex")]
    #[test]
    fn parses_from_hex() {
        use hex::FromHex;

        let hash = DataHash::from_hex("deadbeef").unwrap();
        assert_eq!(hash.as_bytes(), [0xde, 0xad, 0xbe, 0xef]);
        assert!(DataHash::from_hex("").is_err());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_round_trips_as_hex_string() {
        let hash = DataHash::try_from(vec![0xde, 0xad, 0xbe, 0xef]).unwrap();

        let json = serde_json::to_string(&hash).unwrap();
        assert_eq!(json, "\"deadbeef\"");

        let parsed: DataHash = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, hash);
    }
}