215 lines
6.6 KiB
Rust
215 lines
6.6 KiB
Rust
|
|
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");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|