Skip to main content

lit/network/
airgap.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5
6/// Global airgap mode flag
7static AIRGAP_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
8
9/// Airgap configuration for isolated network environments
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct AirgapConfig {
13    /// Enable airgap mode (blocks all network protocols)
14    pub enabled: bool,
15
16    /// Allowed transport types
17    pub allowed_transports: Vec<TransportType>,
18
19    /// Allowed removable media paths (USB drives, etc.)
20    pub allowed_media: Vec<String>,
21
22    /// Allowed network shares (SMB/CIFS paths)
23    pub allowed_shares: Vec<String>,
24
25    /// Enable strict mode (blocks even LAN protocols)
26    pub strict_mode: bool,
27
28    /// Audit logging for transport access
29    pub audit_log: bool,
30
31    /// Audit log path
32    pub audit_log_path: Option<String>,
33}
34
35/// Transport types for airgapped environments
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum TransportType {
38    /// Local filesystem (always allowed)
39    LocalFilesystem,
40
41    /// USB/removable media drives
42    RemovableMedia,
43
44    /// Network file shares (SMB/CIFS)
45    NetworkShare,
46
47    /// Direct file:// protocol
48    FileProtocol,
49
50    /// Blocked: HTTP/HTTPS
51    Http,
52
53    /// Blocked: SSH/SCP
54    Ssh,
55
56    /// Blocked: Custom lit:// network protocol
57    LitProtocol,
58
59    /// Blocked: FTP/FTPS
60    Ftp,
61
62    /// Blocked: Any other network protocol
63    Other,
64}
65
66impl Default for AirgapConfig {
67    fn default() -> Self {
68        AirgapConfig {
69            enabled: false,
70            allowed_transports: vec![
71                TransportType::LocalFilesystem,
72                TransportType::RemovableMedia,
73                TransportType::NetworkShare,
74                TransportType::FileProtocol,
75            ],
76            allowed_media: vec![],
77            allowed_shares: vec![],
78            strict_mode: false,
79            audit_log: true,
80            audit_log_path: Some("~/.lit/airgap_audit.log".to_string()),
81        }
82    }
83}
84
85impl AirgapConfig {
86    /// Load configuration from file
87    pub fn load() -> Result<Self, String> {
88        let config_path = Self::config_path()?;
89
90        if !config_path.exists() {
91            return Ok(AirgapConfig::default());
92        }
93
94        let content = fs::read_to_string(&config_path)
95            .map_err(|e| format!("Failed to read airgap config: {}", e))?;
96
97        toml::from_str(&content).map_err(|e| format!("Failed to parse airgap config: {}", e))
98    }
99
100    /// Get the config file path
101    fn config_path() -> Result<PathBuf, String> {
102        let home = dirs::home_dir().ok_or("Could not find home directory")?;
103        Ok(home.join(".lit").join("airgap.toml"))
104    }
105
106    /// Save configuration to file
107    pub fn save(&self) -> Result<(), String> {
108        let config_path = Self::config_path()?;
109
110        // Create parent directory if needed
111        if let Some(parent) = config_path.parent() {
112            fs::create_dir_all(parent)
113                .map_err(|e| format!("Failed to create config directory: {}", e))?;
114        }
115
116        let content = toml::to_string_pretty(self)
117            .map_err(|e| format!("Failed to serialize config: {}", e))?;
118
119        fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))
120    }
121
122    /// Enable airgap mode globally
123    pub fn enable_airgap_mode() {
124        AIRGAP_MODE_ENABLED.store(true, Ordering::SeqCst);
125    }
126
127    /// Disable airgap mode globally
128    pub fn disable_airgap_mode() {
129        AIRGAP_MODE_ENABLED.store(false, Ordering::SeqCst);
130    }
131
132    /// Check if airgap mode is enabled
133    pub fn is_airgap_mode() -> bool {
134        AIRGAP_MODE_ENABLED.load(Ordering::SeqCst)
135    }
136}
137
138/// Airgap validator for transport restrictions
139pub struct AirgapValidator {
140    config: AirgapConfig,
141}
142
143impl AirgapValidator {
144    /// Create a new validator
145    pub fn new() -> Result<Self, String> {
146        let config = AirgapConfig::load()?;
147
148        // Apply global airgap mode if configured
149        if config.enabled {
150            AirgapConfig::enable_airgap_mode();
151        }
152
153        Ok(AirgapValidator { config })
154    }
155
156    /// Validate a path/URL for airgapped access
157    pub fn validate_transport(&self, path: &str) -> Result<TransportInfo, String> {
158        // If airgap mode is not enabled, allow everything
159        if !self.config.enabled && !AirgapConfig::is_airgap_mode() {
160            return Ok(TransportInfo {
161                transport_type: self.detect_transport_type(path)?,
162                normalized_path: path.to_string(),
163                is_allowed: true,
164            });
165        }
166
167        // Detect transport type
168        let transport_type = self.detect_transport_type(path)?;
169
170        // Check if transport type is allowed
171        if !self.config.allowed_transports.contains(&transport_type) {
172            return Err(format!(
173                "🚫 AIRGAP MODE: Transport type {:?} is blocked. \
174                 Only physical transports allowed (USB, network shares, local filesystem). \
175                 Use --airgapped=false to disable airgap mode.",
176                transport_type
177            ));
178        }
179
180        // Additional validation based on transport type
181        match &transport_type {
182            TransportType::RemovableMedia => {
183                self.validate_removable_media(path)?;
184            }
185            TransportType::NetworkShare => {
186                if self.config.strict_mode {
187                    return Err(
188                        "🚫 AIRGAP STRICT MODE: Network shares are blocked in strict mode. \
189                         Use USB/removable media only."
190                            .to_string(),
191                    );
192                }
193                self.validate_network_share(path)?;
194            }
195            TransportType::Http
196            | TransportType::Ssh
197            | TransportType::LitProtocol
198            | TransportType::Ftp
199            | TransportType::Other => {
200                return Err(format!(
201                    "🚫 AIRGAP MODE: Network protocol {:?} is blocked. \
202                     Use file://, USB drives, or network shares only.",
203                    transport_type
204                ));
205            }
206            _ => {}
207        }
208
209        // Log if enabled
210        if self.config.audit_log {
211            self.log_transport_access(path, &transport_type)?;
212        }
213
214        Ok(TransportInfo {
215            transport_type: transport_type.clone(),
216            normalized_path: self.normalize_path(path)?,
217            is_allowed: true,
218        })
219    }
220
221    /// Detect the transport type from a path/URL
222    fn detect_transport_type(&self, path: &str) -> Result<TransportType, String> {
223        // Protocol-based detection
224        if path.starts_with("http://") || path.starts_with("https://") {
225            return Ok(TransportType::Http);
226        }
227        if path.starts_with("ssh://") || path.starts_with("scp://") {
228            return Ok(TransportType::Ssh);
229        }
230        if path.starts_with("lit://") {
231            return Ok(TransportType::LitProtocol);
232        }
233        if path.starts_with("ftp://") || path.starts_with("ftps://") {
234            return Ok(TransportType::Ftp);
235        }
236        if path.starts_with("file://") {
237            return Ok(TransportType::FileProtocol);
238        }
239
240        // Path-based detection
241        let path_obj = Path::new(path);
242
243        // Windows network share detection (\\server\share or //server/share)
244        if path.starts_with(r"\\") || path.starts_with("//") {
245            return Ok(TransportType::NetworkShare);
246        }
247
248        // Windows drive letter detection
249        #[cfg(target_os = "windows")]
250        {
251            if let Some(first_component) = path_obj.components().next() {
252                use std::path::Component;
253                if let Component::Prefix(prefix) = first_component {
254                    use std::path::Prefix;
255                    match prefix.kind() {
256                        Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
257                            // Check if it's a removable drive
258                            if self.is_removable_drive(path)? {
259                                return Ok(TransportType::RemovableMedia);
260                            }
261                            return Ok(TransportType::LocalFilesystem);
262                        }
263                        Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
264                            return Ok(TransportType::NetworkShare);
265                        }
266                        _ => {}
267                    }
268                }
269            }
270        }
271
272        // Unix absolute path
273        if path.starts_with('/') {
274            // Check if it's a mount point for removable media
275            if self.is_removable_mount(path)? {
276                return Ok(TransportType::RemovableMedia);
277            }
278            return Ok(TransportType::LocalFilesystem);
279        }
280
281        // Relative path - treat as local filesystem
282        Ok(TransportType::LocalFilesystem)
283    }
284
285    /// Check if a Windows drive is removable (USB, etc.)
286    #[cfg(target_os = "windows")]
287    fn is_removable_drive(&self, path: &str) -> Result<bool, String> {
288        use std::ffi::OsStr;
289        use std::os::windows::ffi::OsStrExt;
290
291        // Extract drive letter
292        let path_obj = Path::new(path);
293        let drive = if let Some(first_component) = path_obj.components().next() {
294            first_component.as_os_str().to_string_lossy().to_string()
295        } else {
296            return Ok(false);
297        };
298
299        // Add backslash if not present
300        let drive_root = if drive.ends_with('\\') {
301            drive
302        } else {
303            format!("{}\\", drive)
304        };
305
306        // Convert to wide string for Windows API
307        let wide: Vec<u16> = OsStr::new(&drive_root)
308            .encode_wide()
309            .chain(std::iter::once(0))
310            .collect();
311
312        // SAFETY: `wide` is a valid null-terminated UTF-16 string from OsStr conversion.
313        #[cfg(target_os = "windows")]
314        unsafe {
315            use windows::core::PCWSTR;
316            use windows::Win32::Storage::FileSystem::GetDriveTypeW;
317
318            let drive_type = GetDriveTypeW(PCWSTR::from_raw(wide.as_ptr()));
319            // DRIVE_REMOVABLE = 2
320            Ok(drive_type == 2)
321        }
322
323        #[cfg(not(target_os = "windows"))]
324        Ok(false)
325    }
326
327    #[cfg(not(target_os = "windows"))]
328    fn is_removable_drive(&self, _path: &str) -> Result<bool, String> {
329        Ok(false)
330    }
331
332    /// Check if a Unix path is a mount point for removable media
333    fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
334        // Common removable media mount points
335        let removable_paths = vec![
336            "/media/",
337            "/mnt/",
338            "/Volumes/", // macOS
339            "/run/media/",
340        ];
341
342        for mount_prefix in removable_paths {
343            if path.starts_with(mount_prefix) {
344                return Ok(true);
345            }
346        }
347
348        Ok(false)
349    }
350
351    /// Validate removable media access
352    fn validate_removable_media(&self, path: &str) -> Result<(), String> {
353        // If no specific media paths are configured, allow all removable media
354        if self.config.allowed_media.is_empty() {
355            return Ok(());
356        }
357
358        // Check if path starts with any allowed media path
359        for allowed in &self.config.allowed_media {
360            if path.starts_with(allowed) {
361                return Ok(());
362            }
363        }
364
365        Err(format!(
366            "🚫 AIRGAP MODE: Removable media path '{}' is not in the allowed list. \
367             Configure allowed media in ~/.lit/airgap.toml",
368            path
369        ))
370    }
371
372    /// Validate network share access
373    fn validate_network_share(&self, path: &str) -> Result<(), String> {
374        // If no specific shares are configured, allow all network shares
375        if self.config.allowed_shares.is_empty() {
376            return Ok(());
377        }
378
379        // Check if path starts with any allowed share path
380        for allowed in &self.config.allowed_shares {
381            if path.starts_with(allowed) {
382                return Ok(());
383            }
384        }
385
386        Err(format!(
387            "🚫 AIRGAP MODE: Network share '{}' is not in the allowed list. \
388             Configure allowed shares in ~/.lit/airgap.toml",
389            path
390        ))
391    }
392
393    /// Normalize a path for consistent handling
394    fn normalize_path(&self, path: &str) -> Result<String, String> {
395        // Remove file:// prefix if present
396        let path = if let Some(stripped) = path.strip_prefix("file://") {
397            stripped
398        } else {
399            path
400        };
401
402        // SECURITY: Only expand tilde (~), not arbitrary environment variables,
403        // to prevent injection via crafted paths containing e.g. ${MALICIOUS}
404        let expanded = shellexpand::tilde(path);
405
406        Ok(expanded.to_string())
407    }
408
409    /// Log a transport access attempt
410    fn log_transport_access(
411        &self,
412        path: &str,
413        transport_type: &TransportType,
414    ) -> Result<(), String> {
415        if let Some(log_path) = &self.config.audit_log_path {
416            let expanded_path = shellexpand::tilde(log_path);
417            let log_path = PathBuf::from(expanded_path.as_ref());
418
419            // Create parent directory if needed
420            if let Some(parent) = log_path.parent() {
421                fs::create_dir_all(parent)
422                    .map_err(|e| format!("Failed to create log directory: {}", e))?;
423            }
424
425            let timestamp = chrono::Utc::now().to_rfc3339();
426            let log_entry = format!(
427                "{} | AIRGAP TRANSPORT | {:?} | {}\n",
428                timestamp, transport_type, path
429            );
430
431            use std::io::Write;
432            let mut file = fs::OpenOptions::new()
433                .create(true)
434                .append(true)
435                .open(&log_path)
436                .map_err(|e| format!("Failed to open log file: {}", e))?;
437
438            file.write_all(log_entry.as_bytes())
439                .map_err(|e| format!("Failed to write to log: {}", e))?;
440        }
441
442        Ok(())
443    }
444
445    /// Get current configuration
446    pub fn config(&self) -> &AirgapConfig {
447        &self.config
448    }
449}
450
451/// Information about a validated transport
452#[derive(Debug, Clone)]
453pub struct TransportInfo {
454    /// The detected transport type
455    pub transport_type: TransportType,
456
457    /// Normalized path (expanded variables, etc.)
458    pub normalized_path: String,
459
460    /// Whether this transport is allowed
461    pub is_allowed: bool,
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_transport_detection_http() {
470        let validator = AirgapValidator {
471            config: AirgapConfig::default(),
472        };
473
474        assert_eq!(
475            validator
476                .detect_transport_type("http://example.com")
477                .unwrap(),
478            TransportType::Http
479        );
480        assert_eq!(
481            validator
482                .detect_transport_type("https://example.com")
483                .unwrap(),
484            TransportType::Http
485        );
486    }
487
488    #[test]
489    fn test_transport_detection_ssh() {
490        let validator = AirgapValidator {
491            config: AirgapConfig::default(),
492        };
493
494        assert_eq!(
495            validator
496                .detect_transport_type("ssh://server/repo")
497                .unwrap(),
498            TransportType::Ssh
499        );
500        assert_eq!(
501            validator
502                .detect_transport_type("scp://server/repo")
503                .unwrap(),
504            TransportType::Ssh
505        );
506    }
507
508    #[test]
509    fn test_transport_detection_lit() {
510        let validator = AirgapValidator {
511            config: AirgapConfig::default(),
512        };
513
514        assert_eq!(
515            validator
516                .detect_transport_type("lit://192.168.1.100/repo")
517                .unwrap(),
518            TransportType::LitProtocol
519        );
520    }
521
522    #[test]
523    fn test_transport_detection_network_share() {
524        let validator = AirgapValidator {
525            config: AirgapConfig::default(),
526        };
527
528        assert_eq!(
529            validator
530                .detect_transport_type(r"\\server\share\repo")
531                .unwrap(),
532            TransportType::NetworkShare
533        );
534        assert_eq!(
535            validator
536                .detect_transport_type("//server/share/repo")
537                .unwrap(),
538            TransportType::NetworkShare
539        );
540    }
541
542    #[test]
543    fn test_transport_detection_file_protocol() {
544        let validator = AirgapValidator {
545            config: AirgapConfig::default(),
546        };
547
548        assert_eq!(
549            validator
550                .detect_transport_type("file:///path/to/repo")
551                .unwrap(),
552            TransportType::FileProtocol
553        );
554    }
555
556    #[test]
557    fn test_airgap_blocks_network_protocols() {
558        let config = AirgapConfig {
559            enabled: true,
560            ..Default::default()
561        };
562        let validator = AirgapValidator { config };
563
564        // Should block HTTP
565        assert!(validator.validate_transport("http://example.com").is_err());
566
567        // Should block SSH
568        assert!(validator.validate_transport("ssh://server/repo").is_err());
569
570        // Should block lit:// protocol
571        assert!(validator
572            .validate_transport("lit://192.168.1.100/repo")
573            .is_err());
574    }
575
576    #[test]
577    fn test_airgap_allows_local_filesystem() {
578        let config = AirgapConfig {
579            enabled: true,
580            ..Default::default()
581        };
582        let validator = AirgapValidator { config };
583
584        // Should allow local paths
585        assert!(validator.validate_transport("/path/to/repo").is_ok());
586        assert!(validator.validate_transport("./relative/path").is_ok());
587        assert!(validator.validate_transport("file:///path/to/repo").is_ok());
588    }
589
590    #[test]
591    fn test_airgap_strict_mode_blocks_shares() {
592        let config = AirgapConfig {
593            enabled: true,
594            strict_mode: true,
595            ..Default::default()
596        };
597        let validator = AirgapValidator { config };
598
599        // Should block network shares in strict mode
600        assert!(validator.validate_transport(r"\\server\share").is_err());
601    }
602
603    #[test]
604    fn test_path_normalization() {
605        let validator = AirgapValidator {
606            config: AirgapConfig::default(),
607        };
608
609        assert_eq!(
610            validator.normalize_path("file:///tmp/test").unwrap(),
611            "/tmp/test"
612        );
613    }
614}