Skip to main content

lit/network/
validator.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::net::{IpAddr, Ipv4Addr};
5use std::path::PathBuf;
6
7use super::audit::AuditLog;
8
9/// Network configuration for LAN restrictions
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct NetworkConfig {
12    /// Allowed IP networks in CIDR notation
13    pub allowed_networks: Vec<String>,
14
15    /// Allowed hostnames/domains
16    pub allowed_hosts: Vec<String>,
17
18    /// Enable audit logging
19    pub audit_log: bool,
20
21    /// Audit log path
22    pub audit_log_path: Option<String>,
23}
24
25impl Default for NetworkConfig {
26    fn default() -> Self {
27        NetworkConfig {
28            allowed_networks: vec![
29                "10.0.0.0/8".to_string(),
30                "172.16.0.0/12".to_string(),
31                "192.168.0.0/16".to_string(),
32            ],
33            allowed_hosts: vec![],
34            audit_log: true,
35            audit_log_path: Some("~/.lit/audit.log".to_string()),
36        }
37    }
38}
39
40impl NetworkConfig {
41    /// Load configuration from file
42    pub fn load() -> Result<Self, String> {
43        let config_path = Self::config_path()?;
44
45        if !config_path.exists() {
46            return Ok(NetworkConfig::default());
47        }
48
49        let content = fs::read_to_string(&config_path)
50            .map_err(|e| format!("Failed to read config: {}", e))?;
51
52        toml::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))
53    }
54
55    /// Get the config file path
56    fn config_path() -> Result<PathBuf, String> {
57        let home = dirs::home_dir().ok_or("Could not find home directory")?;
58        Ok(home.join(".litconfig"))
59    }
60
61    /// Save configuration to file
62    pub fn save(&self) -> Result<(), String> {
63        let config_path = Self::config_path()?;
64
65        let content = toml::to_string_pretty(self)
66            .map_err(|e| format!("Failed to serialize config: {}", e))?;
67
68        fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))
69    }
70}
71
72/// URL validator for LAN-only access
73pub struct NetworkValidator {
74    config: NetworkConfig,
75}
76
77impl NetworkValidator {
78    /// Create a new validator
79    pub fn new() -> Result<Self, String> {
80        let config = NetworkConfig::load()?;
81        Ok(NetworkValidator { config })
82    }
83
84    /// Validate a URL for LAN access
85    pub fn validate_url(&self, url: &str) -> Result<(), String> {
86        // Parse the URL
87        let url_parts = self.parse_url(url)?;
88
89        // Check protocol
90        if url_parts.protocol != "lit" {
91            return Err(format!(
92                "Invalid protocol '{}'. Only 'lit://' protocol is allowed for LAN operations",
93                url_parts.protocol
94            ));
95        }
96
97        // Validate host
98        self.validate_host(&url_parts.host)?;
99
100        // Log if enabled
101        if self.config.audit_log {
102            self.log_access(url)?;
103        }
104
105        Ok(())
106    }
107
108    /// Parse a URL into components
109    fn parse_url(&self, url: &str) -> Result<UrlParts, String> {
110        let re =
111            Regex::new(r"^([a-z]+)://([^/]+)(.*)$").map_err(|e| format!("Regex error: {}", e))?;
112
113        let captures = re
114            .captures(url)
115            .ok_or_else(|| format!("Invalid URL format: {}", url))?;
116
117        Ok(UrlParts {
118            protocol: captures.get(1).unwrap().as_str().to_string(),
119            host: captures.get(2).unwrap().as_str().to_string(),
120            path: captures.get(3).unwrap().as_str().to_string(),
121        })
122    }
123
124    /// Validate a host against the whitelist
125    fn validate_host(&self, host: &str) -> Result<(), String> {
126        // Check if it's an IP address
127        if let Ok(ip) = host.parse::<IpAddr>() {
128            return self.validate_ip(&ip);
129        }
130
131        // Check against allowed hosts
132        if self.config.allowed_hosts.iter().any(|h| h == host) {
133            return Ok(());
134        }
135
136        // Try to resolve hostname to IP
137        // For now, we'll just check if it matches the allowed hosts
138        // In a real implementation, you would resolve DNS here
139
140        Err(format!(
141            "Host '{}' is not in the allowed LAN hosts list. \
142             Configure allowed hosts in ~/.litconfig",
143            host
144        ))
145    }
146
147    /// Validate an IP address against allowed networks
148    fn validate_ip(&self, ip: &IpAddr) -> Result<(), String> {
149        match ip {
150            IpAddr::V4(ipv4) => self.validate_ipv4(ipv4),
151            IpAddr::V6(_) => Err("IPv6 not supported yet".to_string()),
152        }
153    }
154
155    /// Validate an IPv4 address against CIDR ranges
156    fn validate_ipv4(&self, ip: &Ipv4Addr) -> Result<(), String> {
157        for network in &self.config.allowed_networks {
158            if self.ip_in_cidr(ip, network)? {
159                return Ok(());
160            }
161        }
162
163        Err(format!(
164            "IP address '{}' is not in any allowed LAN network range. \
165             Configure allowed networks in ~/.litconfig",
166            ip
167        ))
168    }
169
170    /// Check if an IP is in a CIDR range
171    fn ip_in_cidr(&self, ip: &Ipv4Addr, cidr: &str) -> Result<bool, String> {
172        let parts: Vec<&str> = cidr.split('/').collect();
173
174        if parts.len() != 2 {
175            return Err(format!("Invalid CIDR notation: {}", cidr));
176        }
177
178        let network_ip: Ipv4Addr = parts[0]
179            .parse()
180            .map_err(|e| format!("Invalid IP in CIDR: {}", e))?;
181
182        let prefix_len: u8 = parts[1]
183            .parse()
184            .map_err(|e| format!("Invalid prefix length in CIDR: {}", e))?;
185
186        if prefix_len > 32 {
187            return Err("Invalid prefix length: must be 0-32".to_string());
188        }
189
190        let mask = if prefix_len == 0 {
191            0u32
192        } else {
193            !0u32 << (32 - prefix_len)
194        };
195
196        let network_int = u32::from_be_bytes(network_ip.octets());
197        let ip_int = u32::from_be_bytes(ip.octets());
198
199        Ok((network_int & mask) == (ip_int & mask))
200    }
201
202    /// Log a network access attempt
203    fn log_access(&self, url: &str) -> Result<(), String> {
204        if self.config.audit_log {
205            // Use HMAC-signed audit log
206            let audit_path = self.config.audit_log_path.as_deref();
207            let audit = AuditLog::new(audit_path)?;
208            audit.log("NETWORK_ACCESS", url)?;
209        }
210
211        Ok(())
212    }
213}
214
215#[allow(dead_code)]
216struct UrlParts {
217    protocol: String,
218    host: String,
219    path: String,
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_cidr_matching() {
228        let config = NetworkConfig::default();
229        let validator = NetworkValidator { config };
230
231        let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
232        assert!(validator.ip_in_cidr(&ip, "192.168.0.0/16").unwrap());
233        assert!(!validator.ip_in_cidr(&ip, "10.0.0.0/8").unwrap());
234    }
235
236    #[test]
237    fn test_url_parsing() {
238        let config = NetworkConfig::default();
239        let validator = NetworkValidator { config };
240
241        let url = "lit://192.168.1.100/repo.lit";
242        let parts = validator.parse_url(url).unwrap();
243
244        assert_eq!(parts.protocol, "lit");
245        assert_eq!(parts.host, "192.168.1.100");
246        assert_eq!(parts.path, "/repo.lit");
247    }
248}