1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6static AIRGAP_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct AirgapConfig {
13 pub enabled: bool,
15
16 pub allowed_transports: Vec<TransportType>,
18
19 pub allowed_media: Vec<String>,
21
22 pub allowed_shares: Vec<String>,
24
25 pub strict_mode: bool,
27
28 pub audit_log: bool,
30
31 pub audit_log_path: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum TransportType {
38 LocalFilesystem,
40
41 RemovableMedia,
43
44 NetworkShare,
46
47 FileProtocol,
49
50 Http,
52
53 Ssh,
55
56 LitProtocol,
58
59 Ftp,
61
62 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 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 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 pub fn save(&self) -> Result<(), String> {
108 let config_path = Self::config_path()?;
109
110 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 pub fn enable_airgap_mode() {
124 AIRGAP_MODE_ENABLED.store(true, Ordering::SeqCst);
125 }
126
127 pub fn disable_airgap_mode() {
129 AIRGAP_MODE_ENABLED.store(false, Ordering::SeqCst);
130 }
131
132 pub fn is_airgap_mode() -> bool {
134 AIRGAP_MODE_ENABLED.load(Ordering::SeqCst)
135 }
136}
137
138pub struct AirgapValidator {
140 config: AirgapConfig,
141}
142
143impl AirgapValidator {
144 pub fn new() -> Result<Self, String> {
146 let config = AirgapConfig::load()?;
147
148 if config.enabled {
150 AirgapConfig::enable_airgap_mode();
151 }
152
153 Ok(AirgapValidator { config })
154 }
155
156 pub fn validate_transport(&self, path: &str) -> Result<TransportInfo, String> {
158 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 let transport_type = self.detect_transport_type(path)?;
169
170 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 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 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 fn detect_transport_type(&self, path: &str) -> Result<TransportType, String> {
223 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 if path.starts_with(r"\\") || path.starts_with("//") {
242 return Ok(TransportType::NetworkShare);
243 }
244
245 #[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 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 if path.starts_with('/') {
274 if self.is_removable_mount(path)? {
276 return Ok(TransportType::RemovableMedia);
277 }
278 return Ok(TransportType::LocalFilesystem);
279 }
280
281 Ok(TransportType::LocalFilesystem)
283 }
284
285 #[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 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 let drive_root = if drive.ends_with('\\') {
301 drive
302 } else {
303 format!("{}\\", drive)
304 };
305
306 let wide: Vec<u16> = OsStr::new(&drive_root)
308 .encode_wide()
309 .chain(std::iter::once(0))
310 .collect();
311
312 #[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 Ok(drive_type == 2)
321 }
322
323 #[cfg(not(target_os = "windows"))]
324 Ok(false)
325 }
326
327 fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
333 let removable_paths = vec![
335 "/media/",
336 "/mnt/",
337 "/Volumes/", "/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 fn validate_removable_media(&self, path: &str) -> Result<(), String> {
352 if self.config.allowed_media.is_empty() {
354 return Ok(());
355 }
356
357 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 fn validate_network_share(&self, path: &str) -> Result<(), String> {
373 if self.config.allowed_shares.is_empty() {
375 return Ok(());
376 }
377
378 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 fn normalize_path(&self, path: &str) -> Result<String, String> {
394 let path = if let Some(stripped) = path.strip_prefix("file://") {
396 stripped
397 } else {
398 path
399 };
400
401 let expanded = shellexpand::tilde(path);
404
405 Ok(expanded.to_string())
406 }
407
408 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 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 pub fn config(&self) -> &AirgapConfig {
446 &self.config
447 }
448}
449
450#[derive(Debug, Clone)]
452pub struct TransportInfo {
453 pub transport_type: TransportType,
455
456 pub normalized_path: String,
458
459 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 assert!(validator.validate_transport("http://example.com").is_err());
565
566 assert!(validator.validate_transport("ssh://server/repo").is_err());
568
569 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 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 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}