warg-protocol 0.10.0

An implementation of the operator and package log protocols for Warg registries.
Documentation
use anyhow::Error;
use prost::Message;
use thiserror::Error;
use warg_crypto::{hash::AnyHash, Decode, Encode, Signable};
use warg_protobuf::protocol as protobuf;

use crate::{pbjson_to_prost_timestamp, prost_to_pbjson_timestamp, registry::RecordId};

mod model;
mod state;

pub use model::{PackageEntry, PackageRecord, Permission};
pub use state::{LogState, Release, ReleaseState, ValidationError};

/// The currently supported package protocol version.
pub const PACKAGE_RECORD_VERSION: u32 = 0;

impl Decode for model::PackageRecord {
    fn decode(bytes: &[u8]) -> Result<Self, Error> {
        protobuf::PackageRecord::decode(bytes)?.try_into()
    }
}

impl TryFrom<protobuf::PackageRecord> for model::PackageRecord {
    type Error = Error;

    fn try_from(record: protobuf::PackageRecord) -> Result<Self, Self::Error> {
        let prev: Option<RecordId> = match record.prev {
            Some(hash_string) => {
                let hash: AnyHash = hash_string.parse()?;
                Some(hash.into())
            }
            None => None,
        };
        let version = record.version;
        let pbjson_timestamp = record.time.ok_or(InvalidTimestampError)?;
        let prost_timestamp = pbjson_to_prost_timestamp(pbjson_timestamp);
        let timestamp = prost_timestamp.try_into()?;

        let entries: Result<Vec<model::PackageEntry>, Error> = record
            .entries
            .into_iter()
            .map(|proto_entry| proto_entry.try_into())
            .collect();
        let entries = entries?;

        Ok(model::PackageRecord {
            prev,
            version,
            timestamp,
            entries,
        })
    }
}

#[derive(Error, Debug)]
#[error("empty or invalid timestamp in record")]
struct InvalidTimestampError;

impl TryFrom<protobuf::PackageEntry> for model::PackageEntry {
    type Error = Error;

    fn try_from(entry: protobuf::PackageEntry) -> Result<Self, Self::Error> {
        use protobuf::package_entry::Contents;
        let output = match entry.contents.ok_or(EmptyContentError)? {
            Contents::Init(init) => model::PackageEntry::Init {
                hash_algorithm: init.hash_algorithm.parse()?,
                key: init.key.parse()?,
            },
            Contents::GrantFlat(grant_flat) => model::PackageEntry::GrantFlat {
                key: grant_flat.key.parse()?,
                permissions: grant_flat
                    .permissions
                    .into_iter()
                    .map(TryInto::try_into)
                    .collect::<Result<_, _>>()?,
            },
            Contents::RevokeFlat(revoke_flat) => model::PackageEntry::RevokeFlat {
                key_id: revoke_flat.key_id.into(),
                permissions: revoke_flat
                    .permissions
                    .into_iter()
                    .map(TryInto::try_into)
                    .collect::<Result<_, _>>()?,
            },
            Contents::Release(release) => model::PackageEntry::Release {
                version: release
                    .version
                    .parse()
                    .map_err(|error| Error::new(error) as Error)?,
                content: release.content_hash.parse()?,
            },
            Contents::Yank(yank) => model::PackageEntry::Yank {
                version: yank.version.parse()?,
            },
        };
        Ok(output)
    }
}

#[derive(Error, Debug)]
#[error("no content in entry")]
struct EmptyContentError;

impl TryFrom<i32> for model::Permission {
    type Error = Error;

    fn try_from(permission: i32) -> Result<Self, Self::Error> {
        let proto_perm = protobuf::PackagePermission::try_from(permission)
            .map_err(|_| PermissionParseError { value: permission })?;
        match proto_perm {
            protobuf::PackagePermission::Unspecified => {
                Err(Error::new(PermissionParseError { value: permission }))
            }
            protobuf::PackagePermission::Release => Ok(model::Permission::Release),
            protobuf::PackagePermission::Yank => Ok(model::Permission::Yank),
        }
    }
}

#[derive(Error, Debug)]
#[error("the value {value} could not be parsed as a permission")]
struct PermissionParseError {
    value: i32,
}

// Serialization

impl Signable for model::PackageRecord {
    const PREFIX: &'static [u8] = b"WARG-PACKAGE-RECORD-SIGNATURE-V0";
}

impl Encode for model::PackageRecord {
    fn encode(&self) -> Vec<u8> {
        let proto_record: protobuf::PackageRecord = self.into();
        proto_record.encode_to_vec()
    }
}

impl<'a> From<&'a model::PackageRecord> for protobuf::PackageRecord {
    fn from(record: &'a model::PackageRecord) -> Self {
        protobuf::PackageRecord {
            prev: record.prev.as_ref().map(|hash| hash.to_string()),
            version: record.version,
            time: Some(prost_to_pbjson_timestamp(record.timestamp.into())),
            entries: record.entries.iter().map(|entry| entry.into()).collect(),
        }
    }
}

impl<'a> From<&'a model::PackageEntry> for protobuf::PackageEntry {
    fn from(entry: &'a model::PackageEntry) -> Self {
        use protobuf::package_entry::Contents;
        let contents = match entry {
            model::PackageEntry::Init {
                hash_algorithm,
                key,
            } => Contents::Init(protobuf::PackageInit {
                key: key.to_string(),
                hash_algorithm: hash_algorithm.to_string(),
            }),
            model::PackageEntry::GrantFlat { key, permissions } => {
                Contents::GrantFlat(protobuf::PackageGrantFlat {
                    key: key.to_string(),
                    permissions: permissions.iter().map(Into::into).collect(),
                })
            }
            model::PackageEntry::RevokeFlat {
                key_id,
                permissions,
            } => Contents::RevokeFlat(protobuf::PackageRevokeFlat {
                key_id: key_id.to_string(),
                permissions: permissions.iter().map(Into::into).collect(),
            }),
            model::PackageEntry::Release { version, content } => {
                Contents::Release(protobuf::PackageRelease {
                    version: version.to_string(),
                    content_hash: content.to_string(),
                })
            }
            model::PackageEntry::Yank { version } => Contents::Yank(protobuf::PackageYank {
                version: version.to_string(),
            }),
        };
        let contents = Some(contents);
        protobuf::PackageEntry { contents }
    }
}

impl<'a> From<&'a model::Permission> for i32 {
    fn from(permission: &'a model::Permission) -> Self {
        let proto_perm = match permission {
            model::Permission::Release => protobuf::PackagePermission::Release,
            model::Permission::Yank => protobuf::PackagePermission::Yank,
        };
        proto_perm.into()
    }
}

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

    use std::time::SystemTime;

    use semver::Version;

    use warg_crypto::hash::HashAlgorithm;

    use crate::ProtoEnvelope;
    use warg_crypto::signing::generate_p256_pair;

    #[test]
    fn test_envelope_roundtrip() {
        let (alice_pub, alice_priv) = generate_p256_pair();
        let (bob_pub, _bob_priv) = generate_p256_pair();

        let record = model::PackageRecord {
            prev: None,
            version: PACKAGE_RECORD_VERSION,
            timestamp: SystemTime::now(),
            entries: vec![
                model::PackageEntry::Init {
                    hash_algorithm: HashAlgorithm::Sha256,
                    key: alice_pub,
                },
                model::PackageEntry::GrantFlat {
                    key: bob_pub.clone(),
                    permissions: vec![model::Permission::Release, model::Permission::Yank],
                },
                model::PackageEntry::RevokeFlat {
                    key_id: bob_pub.fingerprint(),
                    permissions: vec![model::Permission::Release],
                },
                model::PackageEntry::Release {
                    version: Version::new(1, 0, 0),
                    content: HashAlgorithm::Sha256.digest(&[0, 1, 2, 3]),
                },
            ],
        };

        let first_envelope = match ProtoEnvelope::signed_contents(&alice_priv, record) {
            Ok(value) => value,
            Err(error) => panic!("Failed to sign envelope 1: {:?}", error),
        };

        let bytes = first_envelope.to_protobuf();

        let second_envelope: ProtoEnvelope<model::PackageRecord> =
            match ProtoEnvelope::from_protobuf(&bytes) {
                Ok(value) => value,
                Err(error) => panic!("Failed to create envelope 2: {:?}", error),
            };

        assert_eq!(first_envelope, second_envelope);
    }
}