cts-common 0.34.0-alpha.6

Common types and traits used across the CipherStash ecosystem
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::Url;

/// The type of service endpoint included in a CTS-issued JWT.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceType {
    ZeroKms,
    Secrets,
}

impl ServiceType {
    /// Return the lowercase string representation (matching the serde serialization).
    pub fn as_str(&self) -> &'static str {
        match self {
            ServiceType::ZeroKms => "zerokms",
            ServiceType::Secrets => "secrets",
        }
    }
}

/// A map of service types to their endpoint URLs.
///
/// Included in CTS-issued JWTs so clients can discover service endpoints
/// without guessing URL schemes from the `aud` claim.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Services(BTreeMap<ServiceType, Url>);

impl Services {
    pub fn new() -> Self {
        Self(BTreeMap::new())
    }

    pub fn insert(&mut self, service_type: ServiceType, url: Url) {
        self.0.insert(service_type, url);
    }

    pub fn get(&self, service_type: ServiceType) -> Option<&Url> {
        self.0.get(&service_type)
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn iter(&self) -> impl Iterator<Item = (&ServiceType, &Url)> {
        self.0.iter()
    }
}

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

    #[test]
    fn test_services_roundtrip() {
        let mut services = Services::new();
        services.insert(
            ServiceType::ZeroKms,
            Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
        );

        let json = serde_json::to_string(&services).unwrap();
        assert_eq!(
            json,
            r#"{"zerokms":"https://us-east-1.aws.viturhosted.net/"}"#
        );

        let deserialized: Services = serde_json::from_str(&json).unwrap();
        assert_eq!(
            deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
            Some("https://us-east-1.aws.viturhosted.net/")
        );
    }

    #[test]
    fn test_empty_services_not_serialized_as_null() {
        let services = Services::new();
        let json = serde_json::to_string(&services).unwrap();
        assert_eq!(json, "{}");
    }

    #[test]
    fn test_default_is_empty() {
        let services = Services::default();
        assert!(services.is_empty());
    }

    #[test]
    fn test_missing_key_returns_none() {
        let services = Services::new();
        assert_eq!(services.get(ServiceType::ZeroKms), None);
    }

    #[test]
    fn test_service_type_as_str() {
        assert_eq!(ServiceType::ZeroKms.as_str(), "zerokms");
        assert_eq!(ServiceType::Secrets.as_str(), "secrets");
    }

    #[test]
    fn test_services_roundtrip_with_multiple_services() {
        let mut services = Services::new();
        services.insert(
            ServiceType::Secrets,
            Url::parse("https://dashboard.cipherstash.com/").unwrap(),
        );
        services.insert(
            ServiceType::ZeroKms,
            Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
        );

        let json = serde_json::to_string(&services).unwrap();
        let deserialized: Services = serde_json::from_str(&json).unwrap();

        assert_eq!(
            deserialized.get(ServiceType::Secrets).map(|u| u.as_str()),
            Some("https://dashboard.cipherstash.com/"),
            "secrets endpoint should roundtrip through serde"
        );
        assert_eq!(
            deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
            Some("https://us-east-1.aws.viturhosted.net/"),
            "zerokms endpoint should roundtrip through serde"
        );
    }

    #[test]
    fn test_iter_populated_services() {
        let mut services = Services::new();
        let url = Url::parse("https://zerokms.example.com/").unwrap();
        services.insert(ServiceType::ZeroKms, url.clone());

        let entries: Vec<_> = services.iter().collect();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0], (&ServiceType::ZeroKms, &url));
    }

    #[test]
    fn test_iter_empty_services() {
        let services = Services::new();
        assert_eq!(services.iter().count(), 0);
    }
}