1use crate::crypto::encryption::restrict_to_owner;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7static AIRGAP_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(default)]
13pub struct AirgapConfig {
14 pub enabled: bool,
16
17 pub allowed_transports: Vec<TransportType>,
19
20 pub allowed_media: Vec<String>,
22
23 pub allowed_shares: Vec<String>,
25
26 pub strict_mode: bool,
28
29 pub audit_log: bool,
31
32 pub audit_log_path: Option<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum TransportType {
39 LocalFilesystem,
41
42 RemovableMedia,
44
45 NetworkShare,
47
48 FileProtocol,
50
51 Http,
53
54 Ssh,
56
57 LitProtocol,
59
60 Ftp,
62
63 Other,
65}
66
67impl Default for AirgapConfig {
68 fn default() -> Self {
69 AirgapConfig {
70 enabled: false,
71 allowed_transports: vec![
72 TransportType::LocalFilesystem,
73 TransportType::RemovableMedia,
74 TransportType::NetworkShare,
75 TransportType::FileProtocol,
76 ],
77 allowed_media: vec![],
78 allowed_shares: vec![],
79 strict_mode: false,
80 audit_log: true,
81 audit_log_path: Some("~/.lit/airgap_audit.log".to_string()),
82 }
83 }
84}
85
86impl AirgapConfig {
87 pub fn load() -> Result<Self, String> {
89 let config_path = Self::config_path()?;
90
91 if !config_path.exists() {
92 return Ok(AirgapConfig::default());
93 }
94
95 let content = fs::read_to_string(&config_path)
96 .map_err(|e| format!("Failed to read airgap config: {}", e))?;
97
98 toml::from_str(&content).map_err(|e| format!("Failed to parse airgap config: {}", e))
99 }
100
101 fn config_path() -> Result<PathBuf, String> {
103 let home = dirs::home_dir().ok_or("Could not find home directory")?;
104 Ok(home.join(".lit").join("airgap.toml"))
105 }
106
107 pub fn save(&self) -> Result<(), String> {
109 let config_path = Self::config_path()?;
110
111 if let Some(parent) = config_path.parent() {
113 fs::create_dir_all(parent)
114 .map_err(|e| format!("Failed to create config directory: {}", e))?;
115 }
116
117 let content = toml::to_string_pretty(self)
118 .map_err(|e| format!("Failed to serialize config: {}", e))?;
119
120 fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))
121 }
122
123 pub fn enable_airgap_mode() {
125 AIRGAP_MODE_ENABLED.store(true, Ordering::SeqCst);
126 }
127
128 pub fn disable_airgap_mode() {
130 AIRGAP_MODE_ENABLED.store(false, Ordering::SeqCst);
131 }
132
133 pub fn is_airgap_mode() -> bool {
135 AIRGAP_MODE_ENABLED.load(Ordering::SeqCst)
136 }
137}
138
139pub struct AirgapValidator {
141 config: AirgapConfig,
142}
143
144impl AirgapValidator {
145 pub fn new() -> Result<Self, String> {
147 let config = AirgapConfig::load()?;
148
149 if config.enabled {
151 AirgapConfig::enable_airgap_mode();
152 }
153
154 Ok(AirgapValidator { config })
155 }
156
157 pub fn validate_transport(&self, path: &str) -> Result<TransportInfo, String> {
159 if !self.config.enabled && !AirgapConfig::is_airgap_mode() {
161 return Ok(TransportInfo {
162 transport_type: self.detect_transport_type(path)?,
163 normalized_path: path.to_string(),
164 is_allowed: true,
165 });
166 }
167
168 let transport_type = self.detect_transport_type(path)?;
170
171 if !self.config.allowed_transports.contains(&transport_type) {
173 return Err(format!(
174 "🚫 AIRGAP MODE: Transport type {:?} is blocked. \
175 Only physical transports allowed (USB, network shares, local filesystem). \
176 Use --airgapped=false to disable airgap mode.",
177 transport_type
178 ));
179 }
180
181 match &transport_type {
183 TransportType::RemovableMedia => {
184 self.validate_removable_media(path)?;
185 }
186 TransportType::NetworkShare => {
187 if self.config.strict_mode {
188 return Err(
189 "🚫 AIRGAP STRICT MODE: Network shares are blocked in strict mode. \
190 Use USB/removable media only."
191 .to_string(),
192 );
193 }
194 self.validate_network_share(path)?;
195 }
196 TransportType::Http
197 | TransportType::Ssh
198 | TransportType::LitProtocol
199 | TransportType::Ftp
200 | TransportType::Other => {
201 return Err(format!(
202 "🚫 AIRGAP MODE: Network protocol {:?} is blocked. \
203 Use file://, USB drives, or network shares only.",
204 transport_type
205 ));
206 }
207 _ => {}
208 }
209
210 if self.config.audit_log {
212 self.log_transport_access(path, &transport_type)?;
213 }
214
215 Ok(TransportInfo {
216 transport_type: transport_type.clone(),
217 normalized_path: self.normalize_path(path)?,
218 is_allowed: true,
219 })
220 }
221
222 fn detect_transport_type(&self, path: &str) -> Result<TransportType, String> {
224 if path.starts_with("http://") || path.starts_with("https://") {
226 return Ok(TransportType::Http);
227 }
228 if path.starts_with("ssh://") || path.starts_with("scp://") {
229 return Ok(TransportType::Ssh);
230 }
231 if path.starts_with("lit://") {
232 return Ok(TransportType::LitProtocol);
233 }
234 if path.starts_with("ftp://") || path.starts_with("ftps://") {
235 return Ok(TransportType::Ftp);
236 }
237 if path.starts_with("file://") {
238 return Ok(TransportType::FileProtocol);
239 }
240
241 if path.starts_with(r"\\") || path.starts_with("//") {
243 return Ok(TransportType::NetworkShare);
244 }
245
246 #[cfg(target_os = "windows")]
250 {
251 let path_obj = std::path::Path::new(path);
252 if let Some(first_component) = path_obj.components().next() {
253 use std::path::Component;
254 if let Component::Prefix(prefix) = first_component {
255 use std::path::Prefix;
256 match prefix.kind() {
257 Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
258 if self.is_removable_drive(path)? {
260 return Ok(TransportType::RemovableMedia);
261 }
262 return Ok(TransportType::LocalFilesystem);
263 }
264 Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
265 return Ok(TransportType::NetworkShare);
266 }
267 _ => {}
268 }
269 }
270 }
271 }
272
273 if path.starts_with('/') {
275 if self.is_removable_mount(path)? {
277 return Ok(TransportType::RemovableMedia);
278 }
279 return Ok(TransportType::LocalFilesystem);
280 }
281
282 Ok(TransportType::LocalFilesystem)
284 }
285
286 #[cfg(target_os = "windows")]
288 fn is_removable_drive(&self, path: &str) -> Result<bool, String> {
289 use std::ffi::OsStr;
290 use std::os::windows::ffi::OsStrExt;
291
292 let path_obj = std::path::Path::new(path);
294 let drive = if let Some(first_component) = path_obj.components().next() {
295 first_component.as_os_str().to_string_lossy().to_string()
296 } else {
297 return Ok(false);
298 };
299
300 let drive_root = if drive.ends_with('\\') {
302 drive
303 } else {
304 format!("{}\\", drive)
305 };
306
307 let wide: Vec<u16> = OsStr::new(&drive_root)
309 .encode_wide()
310 .chain(std::iter::once(0))
311 .collect();
312
313 #[cfg(target_os = "windows")]
315 unsafe {
316 use windows::core::PCWSTR;
317 use windows::Win32::Storage::FileSystem::GetDriveTypeW;
318
319 let drive_type = GetDriveTypeW(PCWSTR::from_raw(wide.as_ptr()));
320 Ok(drive_type == 2)
322 }
323
324 #[cfg(not(target_os = "windows"))]
325 Ok(false)
326 }
327
328 fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
334 let removable_paths = vec![
336 "/media/",
337 "/mnt/",
338 "/Volumes/", "/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 fn validate_removable_media(&self, path: &str) -> Result<(), String> {
353 if self.config.allowed_media.is_empty() {
355 return Ok(());
356 }
357
358 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 fn validate_network_share(&self, path: &str) -> Result<(), String> {
374 if self.config.allowed_shares.is_empty() {
376 return Ok(());
377 }
378
379 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 fn normalize_path(&self, path: &str) -> Result<String, String> {
395 let path = if let Some(stripped) = path.strip_prefix("file://") {
397 stripped
398 } else {
399 path
400 };
401
402 let expanded = shellexpand::tilde(path);
405
406 Ok(expanded.to_string())
407 }
408
409 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 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 drop(file);
441
442 restrict_to_owner(&log_path)?;
449 }
450
451 Ok(())
452 }
453
454 pub fn config(&self) -> &AirgapConfig {
456 &self.config
457 }
458}
459
460#[derive(Debug, Clone)]
462pub struct TransportInfo {
463 pub transport_type: TransportType,
465
466 pub normalized_path: String,
468
469 pub is_allowed: bool,
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_transport_detection_http() {
479 let validator = AirgapValidator {
480 config: AirgapConfig::default(),
481 };
482
483 assert_eq!(
484 validator
485 .detect_transport_type("http://example.com")
486 .unwrap(),
487 TransportType::Http
488 );
489 assert_eq!(
490 validator
491 .detect_transport_type("https://example.com")
492 .unwrap(),
493 TransportType::Http
494 );
495 }
496
497 #[test]
498 fn test_transport_detection_ssh() {
499 let validator = AirgapValidator {
500 config: AirgapConfig::default(),
501 };
502
503 assert_eq!(
504 validator
505 .detect_transport_type("ssh://server/repo")
506 .unwrap(),
507 TransportType::Ssh
508 );
509 assert_eq!(
510 validator
511 .detect_transport_type("scp://server/repo")
512 .unwrap(),
513 TransportType::Ssh
514 );
515 }
516
517 #[test]
518 fn test_transport_detection_lit() {
519 let validator = AirgapValidator {
520 config: AirgapConfig::default(),
521 };
522
523 assert_eq!(
524 validator
525 .detect_transport_type("lit://192.168.1.100/repo")
526 .unwrap(),
527 TransportType::LitProtocol
528 );
529 }
530
531 #[test]
532 fn test_transport_detection_network_share() {
533 let validator = AirgapValidator {
534 config: AirgapConfig::default(),
535 };
536
537 assert_eq!(
538 validator
539 .detect_transport_type(r"\\server\share\repo")
540 .unwrap(),
541 TransportType::NetworkShare
542 );
543 assert_eq!(
544 validator
545 .detect_transport_type("//server/share/repo")
546 .unwrap(),
547 TransportType::NetworkShare
548 );
549 }
550
551 #[test]
552 fn test_transport_detection_file_protocol() {
553 let validator = AirgapValidator {
554 config: AirgapConfig::default(),
555 };
556
557 assert_eq!(
558 validator
559 .detect_transport_type("file:///path/to/repo")
560 .unwrap(),
561 TransportType::FileProtocol
562 );
563 }
564
565 #[test]
566 fn test_airgap_blocks_network_protocols() {
567 let config = AirgapConfig {
568 enabled: true,
569 ..Default::default()
570 };
571 let validator = AirgapValidator { config };
572
573 assert!(validator.validate_transport("http://example.com").is_err());
575
576 assert!(validator.validate_transport("ssh://server/repo").is_err());
578
579 assert!(validator
581 .validate_transport("lit://192.168.1.100/repo")
582 .is_err());
583 }
584
585 #[test]
586 fn test_airgap_allows_local_filesystem() {
587 let config = AirgapConfig {
588 enabled: true,
589 ..Default::default()
590 };
591 let validator = AirgapValidator { config };
592
593 assert!(validator.validate_transport("/path/to/repo").is_ok());
595 assert!(validator.validate_transport("./relative/path").is_ok());
596 assert!(validator.validate_transport("file:///path/to/repo").is_ok());
597 }
598
599 #[test]
600 fn test_airgap_strict_mode_blocks_shares() {
601 let config = AirgapConfig {
602 enabled: true,
603 strict_mode: true,
604 ..Default::default()
605 };
606 let validator = AirgapValidator { config };
607
608 assert!(validator.validate_transport(r"\\server\share").is_err());
610 }
611
612 #[test]
613 fn test_path_normalization() {
614 let validator = AirgapValidator {
615 config: AirgapConfig::default(),
616 };
617
618 assert_eq!(
619 validator.normalize_path("file:///tmp/test").unwrap(),
620 "/tmp/test"
621 );
622 }
623}