batata-client 0.0.2

Rust client for Batata/Nacos service discovery and configuration management
Documentation
use base64::Engine;
use hmac::{Hmac, Mac};
use sha1::Sha1;

use crate::common::current_time_millis;

/// Authentication credentials
#[derive(Clone, Debug, Default)]
pub struct Credentials {
    /// Username for basic auth
    pub username: Option<String>,
    /// Password for basic auth
    pub password: Option<String>,
    /// Access key for RAM auth (Alibaba Cloud style)
    pub access_key: Option<String>,
    /// Secret key for RAM auth
    pub secret_key: Option<String>,
    /// ACM endpoint (for Alibaba Cloud ACM)
    pub endpoint: Option<String>,
    /// ACM region ID
    pub region_id: Option<String>,
    /// RAM role name (for ECS instance role auth)
    pub ram_role_name: Option<String>,
}

impl Credentials {
    /// Create empty credentials
    pub fn new() -> Self {
        Self::default()
    }

    /// Create credentials with username and password
    pub fn with_username_password(username: impl Into<String>, password: impl Into<String>) -> Self {
        Self {
            username: Some(username.into()),
            password: Some(password.into()),
            ..Default::default()
        }
    }

    /// Create credentials with access key and secret key
    pub fn with_access_key(access_key: impl Into<String>, secret_key: impl Into<String>) -> Self {
        Self {
            access_key: Some(access_key.into()),
            secret_key: Some(secret_key.into()),
            ..Default::default()
        }
    }

    /// Create credentials for Alibaba Cloud ACM
    pub fn with_acm(
        access_key: impl Into<String>,
        secret_key: impl Into<String>,
        endpoint: impl Into<String>,
        region_id: impl Into<String>,
    ) -> Self {
        Self {
            access_key: Some(access_key.into()),
            secret_key: Some(secret_key.into()),
            endpoint: Some(endpoint.into()),
            region_id: Some(region_id.into()),
            ..Default::default()
        }
    }

    /// Set ACM endpoint
    pub fn set_endpoint(&mut self, endpoint: impl Into<String>) {
        self.endpoint = Some(endpoint.into());
    }

    /// Set ACM region ID
    pub fn set_region_id(&mut self, region_id: impl Into<String>) {
        self.region_id = Some(region_id.into());
    }

    /// Set RAM role name for ECS instance role auth
    pub fn set_ram_role_name(&mut self, role_name: impl Into<String>) {
        self.ram_role_name = Some(role_name.into());
    }

    /// Check if credentials are configured
    pub fn is_configured(&self) -> bool {
        self.has_basic_auth() || self.has_ak_sk_auth()
    }

    /// Check if basic auth is configured
    pub fn has_basic_auth(&self) -> bool {
        self.username.is_some() && self.password.is_some()
    }

    /// Check if AK/SK auth is configured
    pub fn has_ak_sk_auth(&self) -> bool {
        self.access_key.is_some() && self.secret_key.is_some()
    }

    /// Check if ACM auth is configured
    pub fn has_acm_auth(&self) -> bool {
        self.has_ak_sk_auth() && self.endpoint.is_some() && self.region_id.is_some()
    }

    /// Generate signature for AK/SK auth
    pub fn generate_signature(&self, resource: &str) -> Option<SignatureInfo> {
        let access_key = self.access_key.as_ref()?;
        let secret_key = self.secret_key.as_ref()?;

        let timestamp = current_time_millis().to_string();
        let sign_str = format!("{}+{}", resource, timestamp);

        // HMAC-SHA1 signature
        let mut mac = Hmac::<Sha1>::new_from_slice(secret_key.as_bytes()).ok()?;
        mac.update(sign_str.as_bytes());
        let result = mac.finalize();
        let signature = base64::engine::general_purpose::STANDARD.encode(result.into_bytes());

        Some(SignatureInfo {
            access_key: access_key.clone(),
            signature,
            timestamp,
        })
    }

    /// Generate ACM-style signature for Alibaba Cloud ACM
    ///
    /// ACM uses a different signature format: HMAC-SHA1(secretKey, resource + "+" + timestamp)
    /// The timestamp is in ISO 8601 format for ACM.
    pub fn generate_acm_signature(&self, resource: &str) -> Option<AcmSignatureInfo> {
        let access_key = self.access_key.as_ref()?;
        let secret_key = self.secret_key.as_ref()?;

        // ACM uses milliseconds timestamp
        let timestamp = current_time_millis().to_string();
        let sign_str = format!("{}+{}", resource, timestamp);

        // HMAC-SHA1 signature
        let mut mac = Hmac::<Sha1>::new_from_slice(secret_key.as_bytes()).ok()?;
        mac.update(sign_str.as_bytes());
        let result = mac.finalize();
        let signature = base64::engine::general_purpose::STANDARD.encode(result.into_bytes());

        Some(AcmSignatureInfo {
            access_key: access_key.clone(),
            signature,
            timestamp,
            endpoint: self.endpoint.clone(),
            region_id: self.region_id.clone(),
        })
    }
}

/// Signature information for AK/SK auth
#[derive(Clone, Debug)]
pub struct SignatureInfo {
    pub access_key: String,
    pub signature: String,
    pub timestamp: String,
}

/// ACM signature information for Alibaba Cloud ACM
#[derive(Clone, Debug)]
pub struct AcmSignatureInfo {
    pub access_key: String,
    pub signature: String,
    pub timestamp: String,
    pub endpoint: Option<String>,
    pub region_id: Option<String>,
}

/// Access token from login
#[derive(Clone, Debug, Default)]
pub struct AccessToken {
    /// Token value
    pub token: String,
    /// Token expiration time in milliseconds
    pub expire_time: i64,
    /// Whether token is global admin
    pub global_admin: bool,
}

impl AccessToken {
    /// Check if token is expired
    pub fn is_expired(&self) -> bool {
        if self.token.is_empty() {
            return true;
        }
        // Consider expired 30 seconds before actual expiration
        current_time_millis() >= self.expire_time - 30000
    }

    /// Check if token is valid
    pub fn is_valid(&self) -> bool {
        !self.token.is_empty() && !self.is_expired()
    }
}

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

    #[test]
    fn test_credentials_basic_auth() {
        let creds = Credentials::with_username_password("admin", "password");
        assert!(creds.has_basic_auth());
        assert!(!creds.has_ak_sk_auth());
        assert!(creds.is_configured());
    }

    #[test]
    fn test_credentials_ak_sk_auth() {
        let creds = Credentials::with_access_key("ak123", "sk456");
        assert!(!creds.has_basic_auth());
        assert!(creds.has_ak_sk_auth());
        assert!(creds.is_configured());
    }

    #[test]
    fn test_generate_signature() {
        let creds = Credentials::with_access_key("test-ak", "test-sk");
        let sig = creds.generate_signature("test-resource");
        assert!(sig.is_some());
        let sig = sig.unwrap();
        assert_eq!(sig.access_key, "test-ak");
        assert!(!sig.signature.is_empty());
        assert!(!sig.timestamp.is_empty());
    }

    #[test]
    fn test_access_token_expired() {
        let token = AccessToken {
            token: "test-token".to_string(),
            expire_time: 0,
            global_admin: false,
        };
        assert!(token.is_expired());
        assert!(!token.is_valid());
    }

    #[test]
    fn test_access_token_valid() {
        let token = AccessToken {
            token: "test-token".to_string(),
            expire_time: current_time_millis() + 60000,
            global_admin: false,
        };
        assert!(!token.is_expired());
        assert!(token.is_valid());
    }
}