use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
static AIRGAP_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AirgapConfig {
pub enabled: bool,
pub allowed_transports: Vec<TransportType>,
pub allowed_media: Vec<String>,
pub allowed_shares: Vec<String>,
pub strict_mode: bool,
pub audit_log: bool,
pub audit_log_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportType {
LocalFilesystem,
RemovableMedia,
NetworkShare,
FileProtocol,
Http,
Ssh,
LitProtocol,
Ftp,
Other,
}
impl Default for AirgapConfig {
fn default() -> Self {
AirgapConfig {
enabled: false,
allowed_transports: vec![
TransportType::LocalFilesystem,
TransportType::RemovableMedia,
TransportType::NetworkShare,
TransportType::FileProtocol,
],
allowed_media: vec![],
allowed_shares: vec![],
strict_mode: false,
audit_log: true,
audit_log_path: Some("~/.lit/airgap_audit.log".to_string()),
}
}
}
impl AirgapConfig {
pub fn load() -> Result<Self, String> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(AirgapConfig::default());
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read airgap config: {}", e))?;
toml::from_str(&content).map_err(|e| format!("Failed to parse airgap config: {}", e))
}
fn config_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not find home directory")?;
Ok(home.join(".lit").join("airgap.toml"))
}
pub fn save(&self) -> Result<(), String> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))
}
pub fn enable_airgap_mode() {
AIRGAP_MODE_ENABLED.store(true, Ordering::SeqCst);
}
pub fn disable_airgap_mode() {
AIRGAP_MODE_ENABLED.store(false, Ordering::SeqCst);
}
pub fn is_airgap_mode() -> bool {
AIRGAP_MODE_ENABLED.load(Ordering::SeqCst)
}
}
pub struct AirgapValidator {
config: AirgapConfig,
}
impl AirgapValidator {
pub fn new() -> Result<Self, String> {
let config = AirgapConfig::load()?;
if config.enabled {
AirgapConfig::enable_airgap_mode();
}
Ok(AirgapValidator { config })
}
pub fn validate_transport(&self, path: &str) -> Result<TransportInfo, String> {
if !self.config.enabled && !AirgapConfig::is_airgap_mode() {
return Ok(TransportInfo {
transport_type: self.detect_transport_type(path)?,
normalized_path: path.to_string(),
is_allowed: true,
});
}
let transport_type = self.detect_transport_type(path)?;
if !self.config.allowed_transports.contains(&transport_type) {
return Err(format!(
"🚫 AIRGAP MODE: Transport type {:?} is blocked. \
Only physical transports allowed (USB, network shares, local filesystem). \
Use --airgapped=false to disable airgap mode.",
transport_type
));
}
match &transport_type {
TransportType::RemovableMedia => {
self.validate_removable_media(path)?;
}
TransportType::NetworkShare => {
if self.config.strict_mode {
return Err(
"🚫 AIRGAP STRICT MODE: Network shares are blocked in strict mode. \
Use USB/removable media only."
.to_string(),
);
}
self.validate_network_share(path)?;
}
TransportType::Http
| TransportType::Ssh
| TransportType::LitProtocol
| TransportType::Ftp
| TransportType::Other => {
return Err(format!(
"🚫 AIRGAP MODE: Network protocol {:?} is blocked. \
Use file://, USB drives, or network shares only.",
transport_type
));
}
_ => {}
}
if self.config.audit_log {
self.log_transport_access(path, &transport_type)?;
}
Ok(TransportInfo {
transport_type: transport_type.clone(),
normalized_path: self.normalize_path(path)?,
is_allowed: true,
})
}
fn detect_transport_type(&self, path: &str) -> Result<TransportType, String> {
if path.starts_with("http://") || path.starts_with("https://") {
return Ok(TransportType::Http);
}
if path.starts_with("ssh://") || path.starts_with("scp://") {
return Ok(TransportType::Ssh);
}
if path.starts_with("lit://") {
return Ok(TransportType::LitProtocol);
}
if path.starts_with("ftp://") || path.starts_with("ftps://") {
return Ok(TransportType::Ftp);
}
if path.starts_with("file://") {
return Ok(TransportType::FileProtocol);
}
let path_obj = Path::new(path);
if path.starts_with(r"\\") || path.starts_with("//") {
return Ok(TransportType::NetworkShare);
}
#[cfg(target_os = "windows")]
{
if let Some(first_component) = path_obj.components().next() {
use std::path::Component;
if let Component::Prefix(prefix) = first_component {
use std::path::Prefix;
match prefix.kind() {
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
if self.is_removable_drive(path)? {
return Ok(TransportType::RemovableMedia);
}
return Ok(TransportType::LocalFilesystem);
}
Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
return Ok(TransportType::NetworkShare);
}
_ => {}
}
}
}
}
if path.starts_with('/') {
if self.is_removable_mount(path)? {
return Ok(TransportType::RemovableMedia);
}
return Ok(TransportType::LocalFilesystem);
}
Ok(TransportType::LocalFilesystem)
}
#[cfg(target_os = "windows")]
fn is_removable_drive(&self, path: &str) -> Result<bool, String> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let path_obj = Path::new(path);
let drive = if let Some(first_component) = path_obj.components().next() {
first_component.as_os_str().to_string_lossy().to_string()
} else {
return Ok(false);
};
let drive_root = if drive.ends_with('\\') {
drive
} else {
format!("{}\\", drive)
};
let wide: Vec<u16> = OsStr::new(&drive_root)
.encode_wide()
.chain(std::iter::once(0))
.collect();
#[cfg(target_os = "windows")]
unsafe {
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::GetDriveTypeW;
let drive_type = GetDriveTypeW(PCWSTR::from_raw(wide.as_ptr()));
Ok(drive_type == 2)
}
#[cfg(not(target_os = "windows"))]
Ok(false)
}
#[cfg(not(target_os = "windows"))]
fn is_removable_drive(&self, _path: &str) -> Result<bool, String> {
Ok(false)
}
fn is_removable_mount(&self, path: &str) -> Result<bool, String> {
let removable_paths = vec![
"/media/",
"/mnt/",
"/Volumes/", "/run/media/",
];
for mount_prefix in removable_paths {
if path.starts_with(mount_prefix) {
return Ok(true);
}
}
Ok(false)
}
fn validate_removable_media(&self, path: &str) -> Result<(), String> {
if self.config.allowed_media.is_empty() {
return Ok(());
}
for allowed in &self.config.allowed_media {
if path.starts_with(allowed) {
return Ok(());
}
}
Err(format!(
"🚫 AIRGAP MODE: Removable media path '{}' is not in the allowed list. \
Configure allowed media in ~/.lit/airgap.toml",
path
))
}
fn validate_network_share(&self, path: &str) -> Result<(), String> {
if self.config.allowed_shares.is_empty() {
return Ok(());
}
for allowed in &self.config.allowed_shares {
if path.starts_with(allowed) {
return Ok(());
}
}
Err(format!(
"🚫 AIRGAP MODE: Network share '{}' is not in the allowed list. \
Configure allowed shares in ~/.lit/airgap.toml",
path
))
}
fn normalize_path(&self, path: &str) -> Result<String, String> {
let path = if let Some(stripped) = path.strip_prefix("file://") {
stripped
} else {
path
};
let expanded = shellexpand::tilde(path);
Ok(expanded.to_string())
}
fn log_transport_access(
&self,
path: &str,
transport_type: &TransportType,
) -> Result<(), String> {
if let Some(log_path) = &self.config.audit_log_path {
let expanded_path = shellexpand::tilde(log_path);
let log_path = PathBuf::from(expanded_path.as_ref());
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create log directory: {}", e))?;
}
let timestamp = chrono::Utc::now().to_rfc3339();
let log_entry = format!(
"{} | AIRGAP TRANSPORT | {:?} | {}\n",
timestamp, transport_type, path
);
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|e| format!("Failed to open log file: {}", e))?;
file.write_all(log_entry.as_bytes())
.map_err(|e| format!("Failed to write to log: {}", e))?;
}
Ok(())
}
pub fn config(&self) -> &AirgapConfig {
&self.config
}
}
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub transport_type: TransportType,
pub normalized_path: String,
pub is_allowed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_detection_http() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator
.detect_transport_type("http://example.com")
.unwrap(),
TransportType::Http
);
assert_eq!(
validator
.detect_transport_type("https://example.com")
.unwrap(),
TransportType::Http
);
}
#[test]
fn test_transport_detection_ssh() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator
.detect_transport_type("ssh://server/repo")
.unwrap(),
TransportType::Ssh
);
assert_eq!(
validator
.detect_transport_type("scp://server/repo")
.unwrap(),
TransportType::Ssh
);
}
#[test]
fn test_transport_detection_lit() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator
.detect_transport_type("lit://192.168.1.100/repo")
.unwrap(),
TransportType::LitProtocol
);
}
#[test]
fn test_transport_detection_network_share() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator
.detect_transport_type(r"\\server\share\repo")
.unwrap(),
TransportType::NetworkShare
);
assert_eq!(
validator
.detect_transport_type("//server/share/repo")
.unwrap(),
TransportType::NetworkShare
);
}
#[test]
fn test_transport_detection_file_protocol() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator
.detect_transport_type("file:///path/to/repo")
.unwrap(),
TransportType::FileProtocol
);
}
#[test]
fn test_airgap_blocks_network_protocols() {
let config = AirgapConfig {
enabled: true,
..Default::default()
};
let validator = AirgapValidator { config };
assert!(validator.validate_transport("http://example.com").is_err());
assert!(validator.validate_transport("ssh://server/repo").is_err());
assert!(validator
.validate_transport("lit://192.168.1.100/repo")
.is_err());
}
#[test]
fn test_airgap_allows_local_filesystem() {
let config = AirgapConfig {
enabled: true,
..Default::default()
};
let validator = AirgapValidator { config };
assert!(validator.validate_transport("/path/to/repo").is_ok());
assert!(validator.validate_transport("./relative/path").is_ok());
assert!(validator.validate_transport("file:///path/to/repo").is_ok());
}
#[test]
fn test_airgap_strict_mode_blocks_shares() {
let config = AirgapConfig {
enabled: true,
strict_mode: true,
..Default::default()
};
let validator = AirgapValidator { config };
assert!(validator.validate_transport(r"\\server\share").is_err());
}
#[test]
fn test_path_normalization() {
let validator = AirgapValidator {
config: AirgapConfig::default(),
};
assert_eq!(
validator.normalize_path("file:///tmp/test").unwrap(),
"/tmp/test"
);
}
}