velesdb-cli 1.7.1

Interactive CLI and REPL for VelesDB with VelesQL support
//! License management module for VelesDB CLI
//!
//! Provides commands to activate, verify, and display license information.
//! Uses Ed25519 cryptographic signatures for validation.
//!
//! This module uses the SAME cryptographic algorithms as velesdb-premium
//! to ensure compatibility with licenses generated by GetAppSuite.

use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use colored::Colorize;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// License tier enumeration (must match velesdb-premium)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseTier {
    Professional,
    Team,
    Enterprise,
}

impl std::fmt::Display for LicenseTier {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LicenseTier::Professional => write!(f, "Professional"),
            LicenseTier::Team => write!(f, "Team"),
            LicenseTier::Enterprise => write!(f, "Enterprise"),
        }
    }
}

/// Premium feature flags (must match velesdb-premium)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum PremiumFeature {
    HybridSearch,
    AdvancedFiltering,
    EncryptionAtRest,
    Snapshots,
    MultiTenancy,
    RBAC,
    SSO,
    GpuAcceleration,
    AuditLogging,
}

impl std::fmt::Display for PremiumFeature {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PremiumFeature::HybridSearch => write!(f, "Hybrid Search"),
            PremiumFeature::AdvancedFiltering => write!(f, "Advanced Filtering"),
            PremiumFeature::EncryptionAtRest => write!(f, "Encryption at Rest"),
            PremiumFeature::Snapshots => write!(f, "Snapshots & Backups"),
            PremiumFeature::MultiTenancy => write!(f, "Multi-Tenancy"),
            PremiumFeature::RBAC => write!(f, "RBAC"),
            PremiumFeature::SSO => write!(f, "SSO"),
            PremiumFeature::GpuAcceleration => write!(f, "GPU Acceleration"),
            PremiumFeature::AuditLogging => write!(f, "Audit Logging"),
        }
    }
}

/// Decoded license information (must match velesdb-premium)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
    /// License key
    pub key: String,
    /// License tier
    pub tier: LicenseTier,
    /// Organization name
    pub organization: String,
    /// Expiration timestamp (Unix epoch)
    pub expires_at: u64,
    /// Maximum number of instances (-1 = unlimited)
    pub max_instances: i32,
    /// Enabled features
    pub features: Vec<PremiumFeature>,
}

impl LicenseInfo {
    /// Checks if the license has expired
    pub fn is_expired(&self) -> bool {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        now > self.expires_at
    }

    /// Checks if a feature is enabled
    #[allow(dead_code)] // Reason: public API for license validation (used in tests, consumed by velesdb-premium)
    pub fn has_feature(&self, feature: PremiumFeature) -> bool {
        self.features.contains(&feature)
    }

    /// Format expiration date as human-readable string
    pub fn expires_at_formatted(&self) -> String {
        use chrono::{DateTime, Utc};
        #[allow(clippy::cast_possible_wrap)]
        let timestamp = self.expires_at as i64;
        DateTime::<Utc>::from_timestamp(timestamp, 0).map_or_else(
            || "Unknown".to_string(),
            |dt| dt.format("%Y-%m-%d").to_string(),
        )
    }
}

/// Signed license format: `<base64_payload>.<base64_signature>`
#[derive(Debug, Clone)]
pub struct SignedLicense {
    /// The license payload (JSON encoded LicenseInfo)
    pub payload: Vec<u8>,
    /// The Ed25519 signature of the payload
    pub signature: Signature,
}

impl SignedLicense {
    /// Parses a signed license from the standard format
    /// Format: base64(payload).base64(signature)
    pub fn parse(license_string: &str) -> Result<Self> {
        let parts: Vec<&str> = license_string.split('.').collect();
        if parts.len() != 2 {
            anyhow::bail!("Invalid license format. Expected: <payload>.<signature>");
        }

        let payload = BASE64
            .decode(parts[0])
            .context("Failed to decode license payload (invalid base64)")?;

        let sig_bytes = BASE64
            .decode(parts[1])
            .context("Failed to decode license signature (invalid base64)")?;

        let sig_array: [u8; 64] = sig_bytes
            .try_into()
            .map_err(|_| anyhow::anyhow!("Invalid signature length (expected 64 bytes)"))?;

        let signature = Signature::from_bytes(&sig_array);

        Ok(Self { payload, signature })
    }

    /// Verifies the signature using a public key
    pub fn verify_with_key(&self, public_key_b64: &str) -> Result<()> {
        let key_bytes = BASE64
            .decode(public_key_b64)
            .context("Failed to decode public key (invalid base64)")?;

        // Handle both raw 32-byte keys and DER-encoded keys
        let raw_key = if key_bytes.len() == 32 {
            key_bytes
        } else if key_bytes.len() == 44 && key_bytes.starts_with(&[0x30, 0x2a]) {
            key_bytes[12..].to_vec()
        } else {
            anyhow::bail!("Invalid public key length (expected 32 or 44 bytes)");
        };

        let key_array: [u8; 32] = raw_key
            .try_into()
            .map_err(|_| anyhow::anyhow!("Invalid public key format"))?;

        let verifying_key =
            VerifyingKey::from_bytes(&key_array).context("Failed to create verifying key")?;

        verifying_key
            .verify(&self.payload, &self.signature)
            .map_err(|_| anyhow::anyhow!("Invalid signature - license may have been tampered with"))
    }

    /// Extracts the license info from the verified payload
    pub fn extract_info(&self) -> Result<LicenseInfo> {
        serde_json::from_slice(&self.payload)
            .context("Failed to parse license payload (invalid JSON)")
    }
}

/// Validates a signed license and returns the license info if valid
pub fn validate_license(license_string: &str, public_key_b64: &str) -> Result<LicenseInfo> {
    let signed = SignedLicense::parse(license_string)?;
    signed.verify_with_key(public_key_b64)?;
    let info = signed.extract_info()?;

    if info.is_expired() {
        anyhow::bail!("License has expired on {}", info.expires_at_formatted());
    }

    Ok(info)
}

/// Get the license config file path (~/.velesdb/license)
pub fn get_license_config_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("Could not determine home directory")?;
    let config_dir = home.join(".velesdb");
    fs::create_dir_all(&config_dir).context("Failed to create .velesdb config directory")?;
    Ok(config_dir.join("license"))
}

/// Save license key to config file
pub fn save_license_key(license_key: &str) -> Result<()> {
    let path = get_license_config_path()?;
    fs::write(&path, license_key)
        .with_context(|| format!("Failed to write license to {}", path.display()))?;
    Ok(())
}

/// Load license key from config file
pub fn load_license_key() -> Result<String> {
    let path = get_license_config_path()?;
    fs::read_to_string(&path)
        .with_context(|| format!("Failed to read license from {}", path.display()))
}

/// Display license information in a formatted way
pub fn display_license_info(info: &LicenseInfo) {
    println!("\n{}", "License Information".green().bold());
    println!("{}", "=".repeat(60).green());
    println!("  {} {}", "Key:".cyan(), info.key);
    println!("  {} {}", "Organization:".cyan(), info.organization.bold());
    println!("  {} {}", "Tier:".cyan(), info.tier.to_string().yellow());
    println!(
        "  {} {}",
        "Max Instances:".cyan(),
        if info.max_instances == -1 {
            "Unlimited".to_string()
        } else {
            info.max_instances.to_string()
        }
    );
    println!("  {} {}", "Expires:".cyan(), info.expires_at_formatted());

    if info.is_expired() {
        println!("  {} {}", "Status:".cyan(), "EXPIRED".red().bold());
    } else {
        println!("  {} {}", "Status:".cyan(), "VALID".green().bold());
    }

    println!("\n{}", "Enabled Features:".cyan().bold());
    for feature in &info.features {
        println!("  {} {}", "".green(), feature);
    }
    println!();
}

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

    #[test]
    fn test_license_tier_display() {
        assert_eq!(LicenseTier::Professional.to_string(), "Professional");
        assert_eq!(LicenseTier::Team.to_string(), "Team");
        assert_eq!(LicenseTier::Enterprise.to_string(), "Enterprise");
    }

    #[test]
    fn test_license_info_has_feature() {
        let info = LicenseInfo {
            key: "TEST-KEY".to_string(),
            tier: LicenseTier::Professional,
            organization: "Test Corp".to_string(),
            expires_at: u64::MAX,
            max_instances: 1,
            features: vec![PremiumFeature::HybridSearch, PremiumFeature::Snapshots],
        };

        assert!(info.has_feature(PremiumFeature::HybridSearch));
        assert!(info.has_feature(PremiumFeature::Snapshots));
        assert!(!info.has_feature(PremiumFeature::MultiTenancy));
    }

    #[test]
    fn test_license_info_is_expired() {
        let expired = LicenseInfo {
            key: "TEST-KEY".to_string(),
            tier: LicenseTier::Professional,
            organization: "Test Corp".to_string(),
            expires_at: 1_000_000, // Very old timestamp
            max_instances: 1,
            features: vec![],
        };

        assert!(expired.is_expired());

        let valid = LicenseInfo {
            key: "TEST-KEY".to_string(),
            tier: LicenseTier::Professional,
            organization: "Test Corp".to_string(),
            expires_at: u64::MAX,
            max_instances: 1,
            features: vec![],
        };

        assert!(!valid.is_expired());
    }

    #[test]
    fn test_signed_license_parse_invalid_format() {
        let result = SignedLicense::parse("invalid-format");
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("Invalid license format"));
    }

    #[test]
    fn test_signed_license_parse_invalid_base64() {
        let result = SignedLicense::parse("not-base64.also-not-base64");
        assert!(result.is_err());
    }
}