velesdb-cli 3.6.0

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).
///
/// Only features that require a commercial license belong here.
/// Features available in the open-source core (Hybrid Search, Advanced Filtering,
/// GPU Acceleration) were removed in #390 since they ship ungated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum PremiumFeature {
    EncryptionAtRest,
    Snapshots,
    MultiTenancy,
    RBAC,
    SSO,
    AuditLogging,
}

impl std::fmt::Display for PremiumFeature {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            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::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 premium features.
    ///
    /// Uses a lenient deserializer that silently skips unknown variants
    /// (e.g. `"HybridSearch"` from legacy license payloads).
    #[serde(deserialize_with = "deserialize_features_lenient")]
    pub features: Vec<PremiumFeature>,
}

/// Deserializes a `Vec<PremiumFeature>` while silently skipping unknown variants.
///
/// Legacy license payloads signed by GetAppSuite may contain variants that were
/// removed in #390 (`HybridSearch`, `AdvancedFiltering`, `GpuAcceleration`).
/// Rather than rejecting the entire payload, we filter out unrecognized strings.
fn deserialize_features_lenient<'de, D>(
    deserializer: D,
) -> std::result::Result<Vec<PremiumFeature>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
    Ok(raw
        .into_iter()
        .filter_map(|v| serde_json::from_value::<PremiumFeature>(v).ok())
        .collect())
}

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)]
#[path = "license_tests.rs"]
mod license_tests;