lingxia-lxapp 0.7.0

LxApp (lightweight application) container and runtime for LingXia framework
use crate::error::LxAppError;
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;

#[derive(Debug, Clone, Default)]
pub struct NetworkSecurity {
    /// Normalized domains that are trusted for network requests.
    ///
    /// Empty means deny all. Use `"*"` to explicitly allow all domains.
    trusted_domains: HashSet<String>,
}

impl NetworkSecurity {
    /// Creates a new empty NetworkSecurity configuration
    pub fn new() -> Self {
        Self {
            trusted_domains: HashSet::new(),
        }
    }

    /// Checks if a domain is allowed for network access.
    ///
    /// Empty means deny all. Use `"*"` to explicitly allow all domains.
    pub fn is_domain_allowed(&self, domain: &str) -> bool {
        if self.trusted_domains.contains("*") {
            return true;
        }
        let Some(domain) = normalize_trusted_domain(domain) else {
            return false;
        };
        self.trusted_domains.contains(&domain)
            || self.trusted_domains.iter().any(|trusted| {
                trusted
                    .strip_prefix("*.")
                    .is_some_and(|suffix| domain.ends_with(&format!(".{suffix}")))
            })
    }

    /// Set trusted domains from a list, replacing the current policy.
    pub(crate) fn set_domains(&mut self, domains: &[String]) {
        self.trusted_domains.clear();
        for domain in domains
            .iter()
            .filter_map(|domain| normalize_trusted_domain(domain))
        {
            self.trusted_domains.insert(domain);
        }
    }

    pub(crate) fn domains_snapshot(&self) -> Vec<String> {
        let mut domains: Vec<String> = self.trusted_domains.iter().cloned().collect();
        domains.sort();
        domains
    }
}

/// Security privilege handle.
///
/// Producers of privileged APIs create a typed handle for their privilege id
/// and pass it to [`crate::LxApp::has_security_privilege`]. Core runtime does
/// not define built-in privilege names.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LxAppSecurityPrivilege {
    id: Arc<str>,
}

impl LxAppSecurityPrivilege {
    /// Create a typed handle for a producer-defined security privilege id.
    ///
    /// This only normalizes and validates the id. It does not grant any
    /// capability; each privileged API must still call
    /// [`crate::LxApp::has_security_privilege`] before doing sensitive work.
    pub fn new(privilege: impl AsRef<str>) -> Result<Self, LxAppError> {
        let normalized = normalize_security_privilege_id(privilege.as_ref()).ok_or_else(|| {
            LxAppError::InvalidParameter(format!(
                "security privilege id must be a lowercase identifier: {:?}",
                privilege.as_ref()
            ))
        })?;

        Ok(Self::registered(normalized))
    }

    pub(crate) fn registered(id: String) -> Self {
        Self {
            id: Arc::from(id.into_boxed_str()),
        }
    }

    pub fn as_str(&self) -> &str {
        self.id.as_ref()
    }
}

impl AsRef<str> for LxAppSecurityPrivilege {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for LxAppSecurityPrivilege {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

pub(crate) fn normalize_trusted_domain(domain: &str) -> Option<String> {
    let trimmed = domain.trim().trim_end_matches('.');
    if trimmed == "*" {
        return Some("*".to_string());
    }
    if trimmed.is_empty()
        || trimmed.contains("://")
        || trimmed.contains('/')
        || trimmed.contains('\\')
        || trimmed.contains(':')
        || trimmed.chars().any(char::is_whitespace)
    {
        return None;
    }

    if let Some(suffix) = trimmed.strip_prefix("*.") {
        if suffix.contains('*') || !suffix.contains('.') {
            return None;
        }
        return is_valid_trusted_host(suffix).then(|| format!("*.{}", suffix.to_ascii_lowercase()));
    }

    if trimmed.contains('*') {
        return None;
    }

    if is_valid_trusted_host(trimmed) {
        Some(trimmed.to_ascii_lowercase())
    } else {
        None
    }
}

pub(crate) fn is_valid_trusted_host(host: &str) -> bool {
    if host.is_empty() || host.len() > 253 {
        return false;
    }
    if host.parse::<std::net::Ipv4Addr>().is_ok() {
        return true;
    }

    host.split('.').all(|label| {
        !label.is_empty()
            && label.len() <= 63
            && !label.starts_with('-')
            && !label.ends_with('-')
            && label
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
    })
}

pub(crate) fn normalize_security_privilege_id(privilege: &str) -> Option<String> {
    let trimmed = privilege.trim();
    if trimmed.is_empty()
        || trimmed.contains('/')
        || trimmed.contains('\\')
        || trimmed.contains(':')
        || trimmed.chars().any(char::is_whitespace)
    {
        return None;
    }

    if trimmed
        .bytes()
        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_'))
    {
        Some(trimmed.to_string())
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::{
        LxAppSecurityPrivilege, NetworkSecurity, is_valid_trusted_host,
        normalize_security_privilege_id, normalize_trusted_domain,
    };

    #[test]
    fn creates_producer_defined_security_privilege() {
        let privilege = LxAppSecurityPrivilege::new("agent.automation").unwrap();
        assert_eq!(privilege.as_str(), "agent.automation");
        assert_eq!(privilege.to_string(), "agent.automation");
        assert_eq!(privilege.as_ref(), "agent.automation");
    }

    #[test]
    fn rejects_invalid_security_privilege_id() {
        assert!(normalize_security_privilege_id("Agent Automation").is_none());
        assert!(LxAppSecurityPrivilege::new("Agent Automation").is_err());
    }

    #[test]
    fn empty_trusted_domains_denies_all() {
        let security = NetworkSecurity::new();
        assert!(!security.is_domain_allowed("example.com"));
    }

    #[test]
    fn wildcard_trusted_domain_allows_all() {
        let mut security = NetworkSecurity::new();
        security.set_domains(&["*".to_string()]);
        assert!(security.is_domain_allowed("example.com"));
        assert!(security.is_domain_allowed("api.lingxia.app"));
    }

    #[test]
    fn trusted_domain_matching_normalizes_runtime_host() {
        let mut security = NetworkSecurity::new();
        security.set_domains(&[" API.Example.COM. ".to_string()]);

        assert!(security.is_domain_allowed("api.example.com"));
        assert!(security.is_domain_allowed("API.EXAMPLE.COM."));
        assert!(!security.is_domain_allowed("cdn.example.com"));
    }

    #[test]
    fn trusted_domain_matching_supports_subdomain_wildcard() {
        let mut security = NetworkSecurity::new();
        security.set_domains(&["*.example.com".to_string()]);

        assert!(security.is_domain_allowed("cdn.example.com"));
        assert!(security.is_domain_allowed("img.cdn.example.com"));
        assert!(!security.is_domain_allowed("example.com"));
        assert_eq!(
            normalize_trusted_domain("*.Example.COM."),
            Some("*.example.com".to_string())
        );
    }

    #[test]
    fn rejects_invalid_trusted_domain_shape() {
        assert!(normalize_trusted_domain("https://api.example.com").is_none());
        assert!(normalize_trusted_domain("api.example.com/path").is_none());
        assert!(normalize_trusted_domain("api.example.com:443").is_none());
        assert!(normalize_trusted_domain("*example.com").is_none());
        assert!(normalize_trusted_domain("api.*.example.com").is_none());
        assert!(normalize_trusted_domain("*.com").is_none());
        assert!(normalize_trusted_domain("api_internal.example.com").is_none());
        assert!(normalize_trusted_domain("-api.example.com").is_none());
        assert!(normalize_trusted_domain("api-.example.com").is_none());
        assert!(normalize_trusted_domain("api..example.com").is_none());
        assert!(normalize_trusted_domain(".").is_none());
    }

    #[test]
    fn accepts_localhost_and_ipv4_hosts() {
        assert!(is_valid_trusted_host("localhost"));
        assert!(is_valid_trusted_host("127.0.0.1"));
        assert_eq!(
            normalize_trusted_domain("LOCALHOST"),
            Some("localhost".to_string())
        );
    }
}