Skip to main content

lit/network/
airgap.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::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        // Windows network share detection (\\server\share or //server/share)
241        if path.starts_with(r"\\") || path.starts_with("//") {
242            return Ok(TransportType::NetworkShare);
243        }
244
245        // Windows drive letter detection. The parsed path is only consulted
246        // here, so it is bound inside the block rather than above it — on any
247        // other platform there is nothing to parse it for.
248        #[cfg(target_os = "windows")]
249        {
250            let path_obj = std::path::Path::new(path);
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 = std::path::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    // There is deliberately no non-Windows `is_removable_drive`: drive letters
328    // only exist on Windows, and its sole caller sits inside a Windows-gated
329    // block. Other platforms detect removable media by mount point instead.
330
331    /// Check if a Unix path is a mount point for removable media
332    fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
333        // Common removable media mount points
334        let removable_paths = vec![
335            "/media/",
336            "/mnt/",
337            "/Volumes/", // macOS
338            "/run/media/",
339        ];
340
341        for mount_prefix in removable_paths {
342            if path.starts_with(mount_prefix) {
343                return Ok(true);
344            }
345        }
346
347        Ok(false)
348    }
349
350    /// Validate removable media access
351    fn validate_removable_media(&self, path: &str) -> Result<(), String> {
352        // If no specific media paths are configured, allow all removable media
353        if self.config.allowed_media.is_empty() {
354            return Ok(());
355        }
356
357        // Check if path starts with any allowed media path
358        for allowed in &self.config.allowed_media {
359            if path.starts_with(allowed) {
360                return Ok(());
361            }
362        }
363
364        Err(format!(
365            "🚫 AIRGAP MODE: Removable media path '{}' is not in the allowed list. \
366             Configure allowed media in ~/.lit/airgap.toml",
367            path
368        ))
369    }
370
371    /// Validate network share access
372    fn validate_network_share(&self, path: &str) -> Result<(), String> {
373        // If no specific shares are configured, allow all network shares
374        if self.config.allowed_shares.is_empty() {
375            return Ok(());
376        }
377
378        // Check if path starts with any allowed share path
379        for allowed in &self.config.allowed_shares {
380            if path.starts_with(allowed) {
381                return Ok(());
382            }
383        }
384
385        Err(format!(
386            "🚫 AIRGAP MODE: Network share '{}' is not in the allowed list. \
387             Configure allowed shares in ~/.lit/airgap.toml",
388            path
389        ))
390    }
391
392    /// Normalize a path for consistent handling
393    fn normalize_path(&self, path: &str) -> Result<String, String> {
394        // Remove file:// prefix if present
395        let path = if let Some(stripped) = path.strip_prefix("file://") {
396            stripped
397        } else {
398            path
399        };
400
401        // SECURITY: Only expand tilde (~), not arbitrary environment variables,
402        // to prevent injection via crafted paths containing e.g. ${MALICIOUS}
403        let expanded = shellexpand::tilde(path);
404
405        Ok(expanded.to_string())
406    }
407
408    /// Log a transport access attempt
409    fn log_transport_access(
410        &self,
411        path: &str,
412        transport_type: &TransportType,
413    ) -> Result<(), String> {
414        if let Some(log_path) = &self.config.audit_log_path {
415            let expanded_path = shellexpand::tilde(log_path);
416            let log_path = PathBuf::from(expanded_path.as_ref());
417
418            // Create parent directory if needed
419            if let Some(parent) = log_path.parent() {
420                fs::create_dir_all(parent)
421                    .map_err(|e| format!("Failed to create log directory: {}", e))?;
422            }
423
424            let timestamp = chrono::Utc::now().to_rfc3339();
425            let log_entry = format!(
426                "{} | AIRGAP TRANSPORT | {:?} | {}\n",
427                timestamp, transport_type, path
428            );
429
430            use std::io::Write;
431            let mut file = fs::OpenOptions::new()
432                .create(true)
433                .append(true)
434                .open(&log_path)
435                .map_err(|e| format!("Failed to open log file: {}", e))?;
436
437            file.write_all(log_entry.as_bytes())
438                .map_err(|e| format!("Failed to write to log: {}", e))?;
439        }
440
441        Ok(())
442    }
443
444    /// Get current configuration
445    pub fn config(&self) -> &AirgapConfig {
446        &self.config
447    }
448}
449
450/// Information about a validated transport
451#[derive(Debug, Clone)]
452pub struct TransportInfo {
453    /// The detected transport type
454    pub transport_type: TransportType,
455
456    /// Normalized path (expanded variables, etc.)
457    pub normalized_path: String,
458
459    /// Whether this transport is allowed
460    pub is_allowed: bool,
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_transport_detection_http() {
469        let validator = AirgapValidator {
470            config: AirgapConfig::default(),
471        };
472
473        assert_eq!(
474            validator
475                .detect_transport_type("http://example.com")
476                .unwrap(),
477            TransportType::Http
478        );
479        assert_eq!(
480            validator
481                .detect_transport_type("https://example.com")
482                .unwrap(),
483            TransportType::Http
484        );
485    }
486
487    #[test]
488    fn test_transport_detection_ssh() {
489        let validator = AirgapValidator {
490            config: AirgapConfig::default(),
491        };
492
493        assert_eq!(
494            validator
495                .detect_transport_type("ssh://server/repo")
496                .unwrap(),
497            TransportType::Ssh
498        );
499        assert_eq!(
500            validator
501                .detect_transport_type("scp://server/repo")
502                .unwrap(),
503            TransportType::Ssh
504        );
505    }
506
507    #[test]
508    fn test_transport_detection_lit() {
509        let validator = AirgapValidator {
510            config: AirgapConfig::default(),
511        };
512
513        assert_eq!(
514            validator
515                .detect_transport_type("lit://192.168.1.100/repo")
516                .unwrap(),
517            TransportType::LitProtocol
518        );
519    }
520
521    #[test]
522    fn test_transport_detection_network_share() {
523        let validator = AirgapValidator {
524            config: AirgapConfig::default(),
525        };
526
527        assert_eq!(
528            validator
529                .detect_transport_type(r"\\server\share\repo")
530                .unwrap(),
531            TransportType::NetworkShare
532        );
533        assert_eq!(
534            validator
535                .detect_transport_type("//server/share/repo")
536                .unwrap(),
537            TransportType::NetworkShare
538        );
539    }
540
541    #[test]
542    fn test_transport_detection_file_protocol() {
543        let validator = AirgapValidator {
544            config: AirgapConfig::default(),
545        };
546
547        assert_eq!(
548            validator
549                .detect_transport_type("file:///path/to/repo")
550                .unwrap(),
551            TransportType::FileProtocol
552        );
553    }
554
555    #[test]
556    fn test_airgap_blocks_network_protocols() {
557        let config = AirgapConfig {
558            enabled: true,
559            ..Default::default()
560        };
561        let validator = AirgapValidator { config };
562
563        // Should block HTTP
564        assert!(validator.validate_transport("http://example.com").is_err());
565
566        // Should block SSH
567        assert!(validator.validate_transport("ssh://server/repo").is_err());
568
569        // Should block lit:// protocol
570        assert!(validator
571            .validate_transport("lit://192.168.1.100/repo")
572            .is_err());
573    }
574
575    #[test]
576    fn test_airgap_allows_local_filesystem() {
577        let config = AirgapConfig {
578            enabled: true,
579            ..Default::default()
580        };
581        let validator = AirgapValidator { config };
582
583        // Should allow local paths
584        assert!(validator.validate_transport("/path/to/repo").is_ok());
585        assert!(validator.validate_transport("./relative/path").is_ok());
586        assert!(validator.validate_transport("file:///path/to/repo").is_ok());
587    }
588
589    #[test]
590    fn test_airgap_strict_mode_blocks_shares() {
591        let config = AirgapConfig {
592            enabled: true,
593            strict_mode: true,
594            ..Default::default()
595        };
596        let validator = AirgapValidator { config };
597
598        // Should block network shares in strict mode
599        assert!(validator.validate_transport(r"\\server\share").is_err());
600    }
601
602    #[test]
603    fn test_path_normalization() {
604        let validator = AirgapValidator {
605            config: AirgapConfig::default(),
606        };
607
608        assert_eq!(
609            validator.normalize_path("file:///tmp/test").unwrap(),
610            "/tmp/test"
611        );
612    }
613}