kcode-k1-access-profile-codec 0.1.0

Canonical byte codec for K1 authorization profiles
Documentation
//! Canonical version 1 binary encoding for K1 authorization profiles.
//!
//! This crate is a stateless value codec. See `Documentation.md` for the byte
//! layout and a usage example.

pub use kcode_k1_access_profile_values::{
    AuthorizationProfile, GroupId, ModelId, ProfileOwner, ProfileViewer, TxId, UserId,
};

/// Encodes an authorization profile in the canonical version 1 format.
///
/// Owners and viewers are emitted in the normalized order exposed by the
/// profile. An error is returned if either count cannot be represented by the
/// format's big-endian `u32` count.
pub fn encode_profile(profile: &AuthorizationProfile) -> Result<Vec<u8>, String> {
    let owner_count = u32::try_from(profile.owners().len())
        .map_err(|_| "owner count exceeds canonical encoding".to_owned())?;
    let viewer_count = u32::try_from(profile.viewers().len())
        .map_err(|_| "viewer count exceeds canonical encoding".to_owned())?;
    let mut bytes = Vec::new();
    bytes.push(1);
    bytes.extend_from_slice(&owner_count.to_be_bytes());
    for owner in profile.owners() {
        match owner {
            ProfileOwner::RequestUser => bytes.push(0),
            ProfileOwner::User(user) => {
                bytes.push(1);
                bytes.extend_from_slice(user.as_tx_id().as_bytes());
            }
            ProfileOwner::Group(group) => {
                bytes.push(2);
                bytes.extend_from_slice(group.txid().as_bytes());
            }
        }
    }
    bytes.extend_from_slice(&viewer_count.to_be_bytes());
    for viewer in profile.viewers() {
        match viewer {
            ProfileViewer::RequestUser => bytes.push(0),
            ProfileViewer::RequestModel => bytes.push(1),
            ProfileViewer::User(user) => {
                bytes.push(2);
                bytes.extend_from_slice(user.as_tx_id().as_bytes());
            }
            ProfileViewer::Group(group) => {
                bytes.push(3);
                bytes.extend_from_slice(group.txid().as_bytes());
            }
            ProfileViewer::Model(model) => {
                bytes.push(4);
                bytes.extend_from_slice(model.as_bytes());
            }
        }
    }
    Ok(bytes)
}

/// Decodes a complete canonical version 1 authorization profile.
///
/// Unknown versions or tags, truncation, trailing bytes, invalid profiles, and
/// encodings that are not in normalized canonical order are rejected.
pub fn decode_profile(bytes: &[u8]) -> Result<AuthorizationProfile, String> {
    let mut reader = Reader::new(bytes);
    if reader.u8()? != 1 {
        return Err("unknown profile encoding version".to_owned());
    }
    let owner_count = usize::try_from(reader.u32()?)
        .map_err(|_| "owner count does not fit this platform".to_owned())?;
    let mut owners = Vec::new();
    for _ in 0..owner_count {
        owners.push(match reader.u8()? {
            0 => ProfileOwner::RequestUser,
            1 => ProfileOwner::User(UserId::from_tx_id(TxId::from_bytes(reader.take()?))),
            2 => ProfileOwner::Group(GroupId::new(TxId::from_bytes(reader.take()?))),
            _ => return Err("unknown profile owner tag".to_owned()),
        });
    }
    let viewer_count = usize::try_from(reader.u32()?)
        .map_err(|_| "viewer count does not fit this platform".to_owned())?;
    let mut viewers = Vec::new();
    for _ in 0..viewer_count {
        viewers.push(match reader.u8()? {
            0 => ProfileViewer::RequestUser,
            1 => ProfileViewer::RequestModel,
            2 => ProfileViewer::User(UserId::from_tx_id(TxId::from_bytes(reader.take()?))),
            3 => ProfileViewer::Group(GroupId::new(TxId::from_bytes(reader.take()?))),
            4 => ProfileViewer::Model(ModelId::from_bytes(reader.take()?)),
            _ => return Err("unknown profile viewer tag".to_owned()),
        });
    }
    if !reader.finished() {
        return Err("trailing bytes in profile encoding".to_owned());
    }
    let profile = AuthorizationProfile::new(owners, viewers)?;
    if encode_profile(&profile)?.as_slice() != bytes {
        return Err("profile encoding is not canonical".to_owned());
    }
    Ok(profile)
}

struct Reader<'a> {
    bytes: &'a [u8],
    offset: usize,
}

impl<'a> Reader<'a> {
    const fn new(bytes: &'a [u8]) -> Self {
        Self { bytes, offset: 0 }
    }

    fn u8(&mut self) -> Result<u8, String> {
        Ok(self.take::<1>()?[0])
    }

    fn u32(&mut self) -> Result<u32, String> {
        Ok(u32::from_be_bytes(self.take()?))
    }

    fn take<const N: usize>(&mut self) -> Result<[u8; N], String> {
        let end = self
            .offset
            .checked_add(N)
            .ok_or_else(|| "truncated profile encoding".to_owned())?;
        let source = self
            .bytes
            .get(self.offset..end)
            .ok_or_else(|| "truncated profile encoding".to_owned())?;
        let mut output = [0; N];
        output.copy_from_slice(source);
        self.offset = end;
        Ok(output)
    }

    fn finished(&self) -> bool {
        self.offset == self.bytes.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use super::{ProfileOwner as O, ProfileViewer as V};

    fn canonical_case() -> (AuthorizationProfile, Vec<u8>) {
        let tx = |byte| TxId::from_bytes([byte; 12]);
        let profile = AuthorizationProfile::new(
            vec![
                O::Group(GroupId::new(tx(3))),
                O::RequestUser,
                O::User(UserId::from_tx_id(tx(2))),
            ],
            vec![
                V::Model(ModelId::from_bytes([5; 32])),
                V::Group(GroupId::new(tx(4))),
                V::RequestUser,
                V::User(UserId::from_tx_id(tx(3))),
                V::RequestModel,
            ],
        )
        .expect("profile");
        let bytes = [
            &[1, 0, 0, 0, 3, 0, 1][..],
            &[2; 12],
            &[2],
            &[3; 12],
            &[0, 0, 0, 5, 0, 1, 2],
            &[3; 12],
            &[3],
            &[4; 12],
            &[4],
            &[5; 32],
        ]
        .concat();
        (profile, bytes)
    }

    #[test]
    fn emits_the_exact_canonical_vector() {
        let (profile, expected) = canonical_case();
        assert_eq!(encode_profile(&profile).expect("encoding"), expected);
    }

    #[test]
    fn canonical_vector_round_trips() {
        let (profile, bytes) = canonical_case();
        assert_eq!(decode_profile(&bytes).expect("decoding"), profile);
        assert_eq!(
            encode_profile(&decode_profile(&bytes).unwrap()).unwrap(),
            bytes
        );
    }

    #[test]
    fn rejects_truncated_input() {
        assert_eq!(
            decode_profile(&[]).unwrap_err(),
            "truncated profile encoding"
        );
        assert_eq!(
            decode_profile(&[1, 0, 0, 0, 1, 1]).unwrap_err(),
            "truncated profile encoding"
        );
    }

    #[test]
    fn rejects_unknown_version_and_tags() {
        for (input, expected) in [
            (vec![2], "unknown profile encoding version"),
            (vec![1, 0, 0, 0, 1, 9], "unknown profile owner tag"),
            (
                vec![1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 9],
                "unknown profile viewer tag",
            ),
        ] {
            assert_eq!(decode_profile(&input).unwrap_err(), expected);
        }
    }

    #[test]
    fn rejects_empty_owner_set() {
        assert_eq!(
            decode_profile(&[1, 0, 0, 0, 0, 0, 0, 0, 0]).unwrap_err(),
            "authorization profile requires at least one owner"
        );
    }

    #[test]
    fn rejects_noncanonical_order_and_duplicates() {
        let out_of_order = [&[1, 0, 0, 0, 2, 2][..], &[1; 12], &[0, 0, 0, 0, 0]].concat();
        let duplicate = vec![1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0];
        for input in [out_of_order, duplicate] {
            assert_eq!(
                decode_profile(&input).unwrap_err(),
                "profile encoding is not canonical"
            );
        }
    }

    #[test]
    fn rejects_trailing_bytes() {
        assert_eq!(
            decode_profile(&[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap_err(),
            "trailing bytes in profile encoding"
        );
    }
}