kcode-k1-persons-wire 0.1.0

Exact wire-v1 encoding for K1 person actions
Documentation
pub use kcode_k1_person_types::PersonId;
use kcode_k1_persons_projection::PersonAction;
pub use kcode_k1_txn_ordering::TxId;

const INVALID_PAYLOAD: &str = "invalid persons wire payload";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PersonsWire {
    bytes: Vec<u8>,
}

impl PersonsWire {
    pub fn create(name: String) -> Result<Self, String> {
        PersonAction::create(name.clone())?;
        let mut bytes = Vec::with_capacity(2 + name.len());
        bytes.extend_from_slice(&[1, 1]);
        bytes.extend_from_slice(name.as_bytes());
        Ok(Self { bytes })
    }

    pub fn update(person: PersonId, name: String) -> Result<Self, String> {
        PersonAction::update(person, name.clone())?;
        let mut bytes = Vec::with_capacity(14 + name.len());
        bytes.extend_from_slice(&[1, 2]);
        bytes.extend_from_slice(person.as_tx_id().as_bytes());
        bytes.extend_from_slice(name.as_bytes());
        Ok(Self { bytes })
    }

    pub fn resolve(canonical: PersonId, alias: PersonId) -> Self {
        let mut bytes = Vec::with_capacity(26);
        bytes.extend_from_slice(&[1, 3]);
        bytes.extend_from_slice(canonical.as_tx_id().as_bytes());
        bytes.extend_from_slice(alias.as_tx_id().as_bytes());
        Self { bytes }
    }

    pub fn as_bytes(&self) -> &[u8] {
        &self.bytes
    }
}

fn invalid_payload() -> String {
    INVALID_PAYLOAD.to_owned()
}

fn decode_person_id(bytes: &[u8]) -> Result<PersonId, String> {
    let bytes = <[u8; 12]>::try_from(bytes).map_err(|_| invalid_payload())?;
    Ok(PersonId::from_tx_id(TxId::from_bytes(bytes)))
}

fn decode_name(bytes: &[u8]) -> Result<String, String> {
    std::str::from_utf8(bytes)
        .map(str::to_owned)
        .map_err(|_| invalid_payload())
}

pub fn decode(payload: &[u8]) -> Result<PersonAction, String> {
    if payload.first() != Some(&1) {
        return Err(invalid_payload());
    }
    let kind = payload.get(1).ok_or_else(invalid_payload)?;
    match kind {
        1 if (3..=130).contains(&payload.len()) => {
            let name = decode_name(&payload[2..])?;
            PersonAction::create(name).map_err(|_| invalid_payload())
        }
        2 if (15..=142).contains(&payload.len()) => {
            let person = decode_person_id(&payload[2..14])?;
            let name = decode_name(&payload[14..])?;
            PersonAction::update(person, name).map_err(|_| invalid_payload())
        }
        3 if payload.len() == 26 => {
            let canonical = decode_person_id(&payload[2..14])?;
            let alias = decode_person_id(&payload[14..26])?;
            Ok(PersonAction::resolve(canonical, alias))
        }
        _ => Err(invalid_payload()),
    }
}

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

    fn person(bytes: [u8; 12]) -> PersonId {
        PersonId::from_tx_id(TxId::from_bytes(bytes))
    }

    fn assert_invalid(payload: &[u8]) {
        assert_eq!(decode(payload), Err(INVALID_PAYLOAD.to_owned()));
    }

    #[test]
    fn exact_bytes_and_roundtrips() {
        let create = PersonsWire::create("Ada".to_owned()).unwrap();
        assert_eq!(create.as_bytes(), b"\x01\x01Ada");
        assert_eq!(
            decode(create.as_bytes()).unwrap(),
            PersonAction::create("Ada".to_owned()).unwrap()
        );

        let update_person = person([7; 12]);
        let update = PersonsWire::update(update_person, "Grace".to_owned()).unwrap();
        let mut update_bytes = vec![1, 2];
        update_bytes.extend_from_slice(&[7; 12]);
        update_bytes.extend_from_slice(b"Grace");
        assert_eq!(update.as_bytes(), update_bytes);
        assert_eq!(
            decode(update.as_bytes()).unwrap(),
            PersonAction::update(update_person, "Grace".to_owned()).unwrap()
        );

        let canonical = person([3; 12]);
        let alias = person([9; 12]);
        let resolve = PersonsWire::resolve(canonical, alias);
        let mut resolve_bytes = vec![1, 3];
        resolve_bytes.extend_from_slice(&[3; 12]);
        resolve_bytes.extend_from_slice(&[9; 12]);
        assert_eq!(resolve.as_bytes(), resolve_bytes);
        assert_eq!(
            decode(resolve.as_bytes()).unwrap(),
            PersonAction::resolve(canonical, alias)
        );
    }

    #[test]
    fn unicode_and_name_boundaries_roundtrip() {
        let unicode = "Zoë🙂".to_owned();
        let wire = PersonsWire::create(unicode.clone()).unwrap();
        let mut expected = vec![1, 1];
        expected.extend_from_slice(unicode.as_bytes());
        assert_eq!(wire.as_bytes(), expected);
        assert_eq!(
            decode(wire.as_bytes()).unwrap(),
            PersonAction::create(unicode).unwrap()
        );

        for size in [1, 128] {
            let name = "x".repeat(size);
            let create = PersonsWire::create(name.clone()).unwrap();
            assert_eq!(create.as_bytes().len(), size + 2);
            assert_eq!(
                decode(create.as_bytes()).unwrap(),
                PersonAction::create(name.clone()).unwrap()
            );
            let update = PersonsWire::update(person([5; 12]), name.clone()).unwrap();
            assert_eq!(update.as_bytes().len(), size + 14);
            assert_eq!(
                decode(update.as_bytes()).unwrap(),
                PersonAction::update(person([5; 12]), name).unwrap()
            );
        }
    }

    #[test]
    fn constructor_name_errors_are_preserved() {
        let cases = [
            ("", "person name must be 1 through 128 UTF-8 bytes"),
            ("a\n", "person name must not contain control characters"),
            (" ", "person name must contain a non-whitespace character"),
        ];
        for (name, error) in cases {
            assert_eq!(PersonsWire::create(name.to_owned()).unwrap_err(), error);
            assert_eq!(
                PersonsWire::update(person([1; 12]), name.to_owned()).unwrap_err(),
                error
            );
        }
    }

    #[test]
    fn malformed_payloads_collapse_to_one_error() {
        assert_invalid(&[]);
        assert_invalid(&[1]);
        assert_invalid(&[2, 1, b'a']);
        assert_invalid(&[1, 4, b'a']);
        assert_invalid(&[1, 1]);
        assert_invalid(&[1, 2]);
        assert_invalid(&[1, 1, 0xff]);

        let mut invalid_update_utf8 = vec![1, 2];
        invalid_update_utf8.extend_from_slice(&[2; 12]);
        invalid_update_utf8.push(0xff);
        assert_invalid(&invalid_update_utf8);

        for name in [vec![b' '], vec![b'\n'], vec![b'a'; 129]] {
            let mut create = vec![1, 1];
            create.extend_from_slice(&name);
            assert_invalid(&create);
            let mut update = vec![1, 2];
            update.extend_from_slice(&[2; 12]);
            update.extend_from_slice(&name);
            assert_invalid(&update);
        }
    }

    #[test]
    fn resolve_rejects_short_and_trailing_payloads() {
        let wire = PersonsWire::resolve(person([4; 12]), person([6; 12]));
        assert_invalid(&wire.as_bytes()[..25]);
        let mut trailing = wire.as_bytes().to_vec();
        trailing.push(0);
        assert_invalid(&trailing);
    }

    #[test]
    fn wire_traits_include_thread_safety() {
        fn assert_traits<T: Clone + Debug + Eq + PartialEq + Send + Sync>() {}
        assert_traits::<PersonsWire>();
    }
}