use crate::error::LxAppError;
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct NetworkSecurity {
trusted_domains: HashSet<String>,
}
impl NetworkSecurity {
pub fn new() -> Self {
Self {
trusted_domains: HashSet::new(),
}
}
pub fn is_domain_allowed(&self, domain: &str) -> bool {
self.trusted_domains.contains("*")
|| normalize_trusted_domain(domain)
.is_some_and(|domain| self.trusted_domains.contains(&domain))
}
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);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LxAppSecurityPrivilege {
id: Arc<str>,
}
impl LxAppSecurityPrivilege {
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 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 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("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())
);
}
}