use std::path::Path; use std::process::Command; use crate::error::{ConfigError, Result}; /// SOPS (Secrets Operations) integration for decrypting encrypted configs #[derive(Debug, Clone)] pub struct SopsDecryptor { sops_executable: String, } impl SopsDecryptor { /// Create a new SOPS decryptor pub fn new() -> Result { // Check if sops is installed match Command::new("sops").arg("--version").output() { Ok(output) if output.status.success() => { let version = String::from_utf8_lossy(&output.stdout); tracing::debug!("SOPS available: {}", version.trim()); Ok(Self { sops_executable: "sops".to_string(), }) } _ => Err(ConfigError::io_error( "SOPS executable not found. Install SOPS: https://github.com/mozilla/sops", )), } } /// Check if SOPS is available pub fn is_available() -> bool { Command::new("sops") .arg("--version") .output() .map(|output| output.status.success()) .unwrap_or(false) } /// Decrypt a SOPS-encrypted file and return the plaintext content pub fn decrypt_file>(&self, path: P) -> Result { let path = path.as_ref(); if !path.exists() { return Err(ConfigError::not_found(format!( "SOPS file not found: {:?}", path ))); } tracing::debug!("Decrypting SOPS file: {:?}", path); match Command::new(&self.sops_executable) .arg("--decrypt") .arg(path) .output() { Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ConfigError::validation_failed(format!( "SOPS decryption failed: {}", stderr ))); } let content = String::from_utf8(output.stdout).map_err(|e| { ConfigError::deserialization_failed(format!( "Invalid UTF-8 in SOPS output: {}", e )) })?; tracing::debug!("Successfully decrypted SOPS file"); Ok(content) } Err(e) => Err(ConfigError::io_error(format!( "Failed to execute SOPS: {}", e ))), } } /// Try to find and decrypt a secrets file for a service /// Looks for patterns: {service}.secrets.ncl.sops, /// {service}.secrets.toml.sops pub fn find_and_decrypt_secrets( &self, service_name: &str, search_dir: &Path, ) -> Option { let patterns = vec![ format!("{}.secrets.ncl.sops", service_name), format!("{}.secrets.toml.sops", service_name), format!("secrets.{}.ncl.sops", service_name), format!("secrets.{}.toml.sops", service_name), ]; for pattern in patterns { let secret_file = search_dir.join(&pattern); if secret_file.exists() { match self.decrypt_file(&secret_file) { Ok(content) => { tracing::info!("Loaded secrets for {}: {}", service_name, pattern); return Some(content); } Err(e) => { tracing::warn!("Failed to decrypt {}: {}", pattern, e); } } } } None } /// Check if a file appears to be SOPS-encrypted pub fn is_sops_encrypted(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) .map(|ext| ext == "sops") .unwrap_or(false) } /// Get the original file format from a SOPS filename /// Example: "config.ncl.sops" → "ncl" pub fn get_original_format(sops_path: &Path) -> Option { sops_path .file_stem() .and_then(|stem| stem.to_str()) .and_then(|stem_str| stem_str.split('.').next_back().map(|ext| ext.to_string())) } } impl Default for SopsDecryptor { fn default() -> Self { Self::new().unwrap_or_else(|_| Self { sops_executable: "sops".to_string(), }) } } #[cfg(test)] mod tests { use tempfile::TempDir; use super::*; #[test] fn test_sops_availability() { // This test checks if SOPS is installed let available = SopsDecryptor::is_available(); if available { println!("✓ SOPS is available"); } else { println!("ℹ SOPS not installed (expected in CI)"); } } #[test] fn test_sops_decryptor_new() { match SopsDecryptor::new() { Ok(decryptor) => { assert_eq!(decryptor.sops_executable, "sops"); println!("✓ SOPS decryptor created"); } Err(e) => { println!("ℹ SOPS not available: {}", e); } } } #[test] fn test_is_sops_encrypted() { assert!(SopsDecryptor::is_sops_encrypted(Path::new( "config.ncl.sops" ))); assert!(SopsDecryptor::is_sops_encrypted(Path::new( "secrets.toml.sops" ))); assert!(!SopsDecryptor::is_sops_encrypted(Path::new("config.ncl"))); assert!(!SopsDecryptor::is_sops_encrypted(Path::new("secrets.toml"))); } #[test] fn test_get_original_format() { assert_eq!( SopsDecryptor::get_original_format(Path::new("config.ncl.sops")), Some("ncl".to_string()) ); assert_eq!( SopsDecryptor::get_original_format(Path::new("secrets.toml.sops")), Some("toml".to_string()) ); assert_eq!( SopsDecryptor::get_original_format(Path::new("config.yaml.sops")), Some("yaml".to_string()) ); } #[test] fn test_find_and_decrypt_secrets_missing() { let temp_dir = TempDir::new().unwrap(); match SopsDecryptor::new() { Ok(decryptor) => { let result = decryptor.find_and_decrypt_secrets("vault-service", temp_dir.path()); assert!(result.is_none(), "Should not find any secrets"); println!("✓ Missing secrets handled correctly"); } Err(_) => { println!("ℹ SOPS not available, skipping decrypt test"); } } } }