use super::{GitRepo, GitError, GitResult, GitCryptConfig};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributeConfig {
pub patterns: HashMap<String, String>,
pub filters: HashMap<String, FilterConfig>,
pub enable_filters: bool,
pub default_encrypt_attr: String,
}
impl Default for AttributeConfig {
fn default() -> Self {
let mut patterns = HashMap::new();
patterns.insert("*.secret".to_string(), "cargocrypt-encrypt".to_string());
patterns.insert("*.key".to_string(), "cargocrypt-encrypt".to_string());
patterns.insert("secrets/*".to_string(), "cargocrypt-encrypt".to_string());
patterns.insert("config/secrets.*".to_string(), "cargocrypt-encrypt".to_string());
patterns.insert("*.env.local".to_string(), "cargocrypt-encrypt".to_string());
patterns.insert("*.env.production".to_string(), "cargocrypt-encrypt".to_string());
let mut filters = HashMap::new();
filters.insert("cargocrypt-encrypt".to_string(), FilterConfig {
clean: "cargocrypt filter-clean %f".to_string(),
smudge: "cargocrypt filter-smudge %f".to_string(),
required: true,
});
Self {
patterns,
filters,
enable_filters: true,
default_encrypt_attr: "cargocrypt-encrypt".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterConfig {
pub clean: String,
pub smudge: String,
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptionPattern {
pub pattern: String,
pub attribute: String,
pub extra_attrs: Vec<String>,
}
impl EncryptionPattern {
pub fn new(pattern: &str, attribute: &str) -> Self {
Self {
pattern: pattern.to_string(),
attribute: attribute.to_string(),
extra_attrs: Vec::new(),
}
}
pub fn with_attr(mut self, attr: &str) -> Self {
self.extra_attrs.push(attr.to_string());
self
}
pub fn to_line(&self) -> String {
let mut line = format!("{} {}", self.pattern, self.attribute);
for attr in &self.extra_attrs {
line.push(' ');
line.push_str(attr);
}
line
}
pub fn from_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 {
let pattern = parts[0].to_string();
let attribute = parts[1].to_string();
let extra_attrs = parts[2..].iter().map(|s| s.to_string()).collect();
Some(Self {
pattern,
attribute,
extra_attrs,
})
} else {
None
}
}
pub fn matches_path(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
if self.pattern.starts_with("*.") {
let extension = &self.pattern[2..];
path_str.ends_with(&format!(".{}", extension))
} else if self.pattern.ends_with("/*") {
let dir = &self.pattern[..self.pattern.len() - 2];
path_str.starts_with(&format!("{}/", dir))
} else {
path_str == self.pattern
}
}
}
pub struct GitAttributes {
repo: GitRepo,
attributes_path: PathBuf,
patterns: Vec<EncryptionPattern>,
config: AttributeConfig,
}
impl GitAttributes {
pub fn new(repo: &GitRepo) -> GitResult<Self> {
let attributes_path = repo.workdir().join(".gitattributes");
let config = AttributeConfig::default();
Ok(Self {
repo: repo.clone(),
attributes_path,
patterns: Vec::new(),
config,
})
}
pub fn with_config(repo: &GitRepo, config: AttributeConfig) -> GitResult<Self> {
let attributes_path = repo.workdir().join(".gitattributes");
Ok(Self {
repo: repo.clone(),
attributes_path,
patterns: Vec::new(),
config,
})
}
pub async fn load(&mut self) -> GitResult<()> {
if self.attributes_path.exists() {
let content = fs::read_to_string(&self.attributes_path).await
.map_err(|e| GitError::AttributesFailed(format!("Failed to read .gitattributes: {}", e)))?;
self.patterns = content
.lines()
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
.filter_map(EncryptionPattern::from_line)
.collect();
}
Ok(())
}
pub async fn save(&self) -> GitResult<()> {
let mut content = String::new();
content.push_str("# CargoCrypt - Automatic encryption patterns\n");
content.push_str("# Files matching these patterns will be automatically encrypted/decrypted\n\n");
for pattern in &self.patterns {
content.push_str(&pattern.to_line());
content.push('\n');
}
fs::write(&self.attributes_path, content).await
.map_err(|e| GitError::AttributesFailed(format!("Failed to write .gitattributes: {}", e)))?;
Ok(())
}
pub async fn add_pattern(&mut self, pattern: &str, attribute: &str) -> GitResult<()> {
let encryption_pattern = EncryptionPattern::new(pattern, attribute);
if !self.patterns.iter().any(|p| p.pattern == pattern) {
self.patterns.push(encryption_pattern);
}
Ok(())
}
pub async fn add_cargocrypt_patterns(&mut self) -> GitResult<()> {
let patterns: Vec<(String, String)> = self.config.patterns.clone()
.into_iter()
.collect();
for (pattern, attribute) in patterns {
self.add_pattern(&pattern, &attribute).await?;
}
Ok(())
}
pub async fn configure_filters(&self, git_config: &GitCryptConfig) -> GitResult<()> {
if !self.config.enable_filters {
return Ok(());
}
let git_config_path = self.repo.git_dir().join("config");
let mut config_content = if git_config_path.exists() {
fs::read_to_string(&git_config_path).await
.map_err(|e| GitError::AttributesFailed(format!("Failed to read git config: {}", e)))?
} else {
String::new()
};
for (filter_name, filter_config) in &self.config.filters {
let filter_section = format!(
"\n[filter \"{}\"]\n\tclean = {}\n\tsmudge = {}\n\trequired = {}\n",
filter_name,
filter_config.clean,
filter_config.smudge,
filter_config.required
);
if !config_content.contains(&format!("[filter \"{}\"]", filter_name)) {
config_content.push_str(&filter_section);
}
}
fs::write(&git_config_path, config_content).await
.map_err(|e| GitError::AttributesFailed(format!("Failed to write git config: {}", e)))?;
Ok(())
}
pub fn should_encrypt(&self, file_path: &Path) -> bool {
self.patterns.iter().any(|pattern| pattern.matches_path(file_path))
}
pub fn get_encryption_attribute(&self, file_path: &Path) -> Option<&str> {
self.patterns
.iter()
.find(|pattern| pattern.matches_path(file_path))
.map(|pattern| pattern.attribute.as_str())
}
pub fn has_cargocrypt_patterns(&self) -> bool {
self.patterns.iter().any(|p| {
p.attribute.contains("cargocrypt") ||
p.pattern.contains("secret") ||
p.pattern.contains("*.key")
})
}
pub async fn remove_cargocrypt_patterns(&mut self) -> GitResult<()> {
self.patterns.retain(|p| !p.attribute.contains("cargocrypt"));
Ok(())
}
pub fn get_patterns_for_attribute(&self, attribute: &str) -> Vec<&EncryptionPattern> {
self.patterns
.iter()
.filter(|p| p.attribute == attribute)
.collect()
}
pub async fn update_smart_patterns(&mut self) -> GitResult<()> {
let workdir = self.repo.workdir();
let mut discovered_patterns = Vec::new();
for env_file in ["*.env", "*.env.local", "*.env.production", "*.env.staging"] {
if self.has_files_matching_pattern(env_file).await? {
discovered_patterns.push((env_file.to_string(), self.config.default_encrypt_attr.clone()));
}
}
for key_pattern in ["*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_ed25519"] {
if self.has_files_matching_pattern(key_pattern).await? {
discovered_patterns.push((key_pattern.to_string(), self.config.default_encrypt_attr.clone()));
}
}
for config_dir in ["secrets/", "config/secrets/", "keys/", "certs/"] {
let dir_path = workdir.join(config_dir);
if dir_path.exists() && dir_path.is_dir() {
discovered_patterns.push((format!("{}*", config_dir), self.config.default_encrypt_attr.clone()));
}
}
for (pattern, attribute) in discovered_patterns {
self.add_pattern(&pattern, &attribute).await?;
}
Ok(())
}
async fn has_files_matching_pattern(&self, pattern: &str) -> GitResult<bool> {
let workdir = self.repo.workdir();
use walkdir::WalkDir;
for entry in WalkDir::new(workdir).max_depth(3) {
let entry = entry.map_err(|e| GitError::AttributesFailed(format!("Walk error: {}", e)))?;
let path = entry.path();
if path.is_file() {
let relative_path = path.strip_prefix(workdir).unwrap_or(path);
let test_pattern = EncryptionPattern::new(pattern, "test");
if test_pattern.matches_path(relative_path) {
return Ok(true);
}
}
}
Ok(false)
}
pub fn validate_attributes(&self) -> GitResult<Vec<String>> {
let mut warnings = Vec::new();
for (i, pattern1) in self.patterns.iter().enumerate() {
for (j, pattern2) in self.patterns.iter().enumerate() {
if i != j && pattern1.pattern == pattern2.pattern && pattern1.attribute != pattern2.attribute {
warnings.push(format!(
"Conflicting attributes for pattern '{}': '{}' and '{}'",
pattern1.pattern, pattern1.attribute, pattern2.attribute
));
}
}
}
for pattern in &self.patterns {
if pattern.pattern == "*" {
warnings.push("Pattern '*' will encrypt all files - this may not be intended".to_string());
}
}
Ok(warnings)
}
pub fn export_git_crypt_format(&self) -> String {
let mut output = String::new();
output.push_str("# git-crypt compatible format\n");
for pattern in &self.patterns {
if pattern.attribute.contains("encrypt") {
output.push_str(&format!("{} filter=git-crypt diff=git-crypt\n", pattern.pattern));
}
}
output
}
pub async fn import_git_crypt_patterns(&mut self, content: &str) -> GitResult<()> {
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1].contains("git-crypt") {
let attr = self.config.default_encrypt_attr.clone();
self.add_pattern(parts[0], &attr).await?;
}
}
Ok(())
}
pub fn get_patterns(&self) -> &[EncryptionPattern] {
&self.patterns
}
pub fn repo(&self) -> &GitRepo {
&self.repo
}
pub fn path(&self) -> &Path {
&self.attributes_path
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs::File;
use std::io::Write;
#[tokio::test]
async fn test_git_attributes_creation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let attributes = GitAttributes::new(&repo).unwrap();
assert_eq!(attributes.path(), &temp_dir.path().join(".gitattributes"));
}
#[tokio::test]
async fn test_pattern_operations() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let mut attributes = GitAttributes::new(&repo).unwrap();
attributes.add_pattern("*.secret", "cargocrypt-encrypt").await.unwrap();
assert_eq!(attributes.patterns.len(), 1);
assert!(attributes.should_encrypt(Path::new("test.secret")));
assert!(!attributes.should_encrypt(Path::new("test.txt")));
}
#[test]
fn test_encryption_pattern() {
let pattern = EncryptionPattern::new("*.secret", "cargocrypt-encrypt")
.with_attr("binary");
assert_eq!(pattern.to_line(), "*.secret cargocrypt-encrypt binary");
let parsed = EncryptionPattern::from_line("*.key cargocrypt-encrypt required").unwrap();
assert_eq!(parsed.pattern, "*.key");
assert_eq!(parsed.attribute, "cargocrypt-encrypt");
assert_eq!(parsed.extra_attrs, vec!["required"]);
}
#[test]
fn test_pattern_matching() {
let pattern = EncryptionPattern::new("*.secret", "cargocrypt-encrypt");
assert!(pattern.matches_path(Path::new("test.secret")));
assert!(pattern.matches_path(Path::new("config/prod.secret")));
assert!(!pattern.matches_path(Path::new("test.txt")));
let dir_pattern = EncryptionPattern::new("secrets/*", "cargocrypt-encrypt");
assert!(dir_pattern.matches_path(Path::new("secrets/api.key")));
assert!(!dir_pattern.matches_path(Path::new("config/api.key")));
}
#[tokio::test]
async fn test_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let mut attributes = GitAttributes::new(&repo).unwrap();
attributes.add_pattern("*.test", "cargocrypt-encrypt").await.unwrap();
attributes.save().await.unwrap();
let mut attributes2 = GitAttributes::new(&repo).unwrap();
attributes2.load().await.unwrap();
assert!(attributes2.should_encrypt(Path::new("file.test")));
}
#[tokio::test]
async fn test_cargocrypt_patterns() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let mut attributes = GitAttributes::new(&repo).unwrap();
attributes.add_cargocrypt_patterns().await.unwrap();
assert!(attributes.has_cargocrypt_patterns());
assert!(attributes.should_encrypt(Path::new("api.secret")));
assert!(attributes.should_encrypt(Path::new("secrets/database.key")));
}
}