215 lines
6.6 KiB
Rust
Raw Normal View History

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<Self> {
// 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<P: AsRef<Path>>(&self, path: P) -> Result<String> {
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<String> {
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<String> {
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");
}
}
}
}