1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{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 let path_obj = Path::new(path);
242
243 if path.starts_with(r"\\") || path.starts_with("//") {
245 return Ok(TransportType::NetworkShare);
246 }
247
248 #[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 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 = 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 #[cfg(not(target_os = "windows"))]
328 fn is_removable_drive(&self, _path: &str) -> Result<bool, String> {
329 Ok(false)
330 }
331
332 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 }
441
442 Ok(())
443 }
444
445 pub fn config(&self) -> &AirgapConfig {
447 &self.config
448 }
449}
450
451#[derive(Debug, Clone)]
453pub struct TransportInfo {
454 pub transport_type: TransportType,
456
457 pub normalized_path: String,
459
460 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 assert!(validator.validate_transport("http://example.com").is_err());
566
567 assert!(validator.validate_transport("ssh://server/repo").is_err());
569
570 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 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 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}