use super::{GitRepo, GitError, GitResult};
use crate::crypto::CryptoEngine;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tokio::fs;
use serde::{Deserialize, Serialize};
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookType {
PreCommit,
PrePush,
PostCommit,
PostCheckout,
PostMerge,
}
impl HookType {
pub fn filename(&self) -> &'static str {
match self {
Self::PreCommit => "pre-commit",
Self::PrePush => "pre-push",
Self::PostCommit => "post-commit",
Self::PostCheckout => "post-checkout",
Self::PostMerge => "post-merge",
}
}
pub fn is_pre_hook(&self) -> bool {
matches!(self, Self::PreCommit | Self::PrePush)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub auto_install: bool,
pub backup_existing: bool,
pub secret_detection: SecretDetectionConfig,
pub validation: ValidationConfig,
pub custom_scripts: HashMap<String, String>,
}
impl Default for HookConfig {
fn default() -> Self {
Self {
auto_install: true,
backup_existing: true,
secret_detection: SecretDetectionConfig::default(),
validation: ValidationConfig::default(),
custom_scripts: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretDetectionConfig {
pub enabled: bool,
pub fail_on_detection: bool,
pub patterns: Vec<SecretPattern>,
pub exclude_files: Vec<String>,
pub use_ml_detection: bool,
pub ml_threshold: f32,
}
impl Default for SecretDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
fail_on_detection: true,
patterns: vec![
SecretPattern::new("api_key", r"(?i)api[_-]?key\s*[:=]\s*[a-zA-Z0-9]{16,}"),
SecretPattern::new("aws_key", r"AKIA[0-9A-Z]{16}"),
SecretPattern::new("private_key", r"-----BEGIN (RSA |EC |)PRIVATE KEY-----"),
SecretPattern::new("password", r"(?i)password\s*[:=]\s*[^\s]{8,}"),
SecretPattern::new("token", r"(?i)token\s*[:=]\s*[a-zA-Z0-9]{20,}"),
SecretPattern::new("secret", r"(?i)secret\s*[:=]\s*[a-zA-Z0-9]{16,}"),
],
exclude_files: vec![
"*.test.js".to_string(),
"*.spec.ts".to_string(),
"test/**".to_string(),
"tests/**".to_string(),
"*.md".to_string(),
],
use_ml_detection: true,
ml_threshold: 0.8,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationConfig {
pub validate_encryption: bool,
pub validate_gitignore: bool,
pub validate_attributes: bool,
pub validate_team_keys: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
validate_encryption: true,
validate_gitignore: true,
validate_attributes: true,
validate_team_keys: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretPattern {
pub name: String,
pub pattern: String,
#[serde(skip)]
pub regex: Option<Regex>,
}
impl SecretPattern {
pub fn new(name: &str, pattern: &str) -> Self {
let regex = Regex::new(pattern).ok();
Self {
name: name.to_string(),
pattern: pattern.to_string(),
regex,
}
}
pub fn matches(&self, content: &str) -> bool {
if let Some(ref regex) = self.regex {
regex.is_match(content)
} else {
content.contains(&self.name)
}
}
pub fn find_matches(&self, content: &str) -> Vec<SecretMatch> {
let mut matches = Vec::new();
if let Some(ref regex) = self.regex {
for (line_num, line) in content.lines().enumerate() {
for mat in regex.find_iter(line) {
matches.push(SecretMatch {
pattern_name: self.name.clone(),
line_number: line_num + 1,
column: mat.start(),
matched_text: mat.as_str().to_string(),
confidence: 1.0, });
}
}
}
matches
}
}
#[derive(Debug, Clone)]
pub struct SecretMatch {
pub pattern_name: String,
pub line_number: usize,
pub column: usize,
pub matched_text: String,
pub confidence: f32,
}
pub struct GitHooks {
repo: GitRepo,
hooks_dir: PathBuf,
config: HookConfig,
}
impl GitHooks {
pub fn new(repo: &GitRepo) -> GitResult<Self> {
let hooks_dir = repo.git_dir().join("hooks");
let config = HookConfig::default();
Ok(Self {
repo: repo.clone(),
hooks_dir,
config,
})
}
pub fn with_config(repo: &GitRepo, config: HookConfig) -> GitResult<Self> {
let hooks_dir = repo.git_dir().join("hooks");
Ok(Self {
repo: repo.clone(),
hooks_dir,
config,
})
}
pub async fn install_hook(&self, hook_type: HookType, hook: Box<dyn GitHook>) -> GitResult<()> {
let hook_path = self.hooks_dir.join(hook_type.filename());
if self.config.backup_existing && hook_path.exists() {
let backup_path = hook_path.with_extension(&format!("{}.backup", hook_type.filename()));
fs::copy(&hook_path, backup_path).await
.map_err(|e| GitError::HookFailed(format!("Failed to backup hook: {}", e)))?;
}
let script_content = hook.generate_script(&self.config)?;
fs::write(&hook_path, script_content).await
.map_err(|e| GitError::HookFailed(format!("Failed to write hook: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path).await
.map_err(|e| GitError::HookFailed(format!("Failed to get permissions: {}", e)))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).await
.map_err(|e| GitError::HookFailed(format!("Failed to set permissions: {}", e)))?;
}
Ok(())
}
pub async fn install_secret_detection_hook(&self) -> GitResult<()> {
let hook = SecretDetectionHook::new(&CryptoEngine::new())?;
self.install_hook(HookType::PreCommit, Box::new(hook)).await
}
pub async fn install_encryption_validation_hook(&self) -> GitResult<()> {
let hook = EncryptionValidationHook::new()?;
self.install_hook(HookType::PrePush, Box::new(hook)).await
}
pub fn are_installed(&self) -> bool {
let required_hooks = [HookType::PreCommit, HookType::PrePush];
required_hooks.iter().all(|hook_type| {
let hook_path = self.hooks_dir.join(hook_type.filename());
hook_path.exists()
})
}
pub async fn uninstall_hooks(&self) -> GitResult<()> {
let cargocrypt_hooks = [HookType::PreCommit, HookType::PrePush];
for hook_type in &cargocrypt_hooks {
let hook_path = self.hooks_dir.join(hook_type.filename());
if hook_path.exists() {
let content = fs::read_to_string(&hook_path).await
.map_err(|e| GitError::HookFailed(format!("Failed to read hook: {}", e)))?;
if content.contains("CargoCrypt") {
fs::remove_file(&hook_path).await
.map_err(|e| GitError::HookFailed(format!("Failed to remove hook: {}", e)))?;
let backup_path = hook_path.with_extension(&format!("{}.backup", hook_type.filename()));
if backup_path.exists() {
fs::rename(backup_path, hook_path).await
.map_err(|e| GitError::HookFailed(format!("Failed to restore backup: {}", e)))?;
}
}
}
}
Ok(())
}
pub fn hooks_dir(&self) -> &Path {
&self.hooks_dir
}
pub fn config(&self) -> &HookConfig {
&self.config
}
}
pub trait GitHook {
fn generate_script(&self, config: &HookConfig) -> GitResult<String>;
fn name(&self) -> &str;
fn description(&self) -> &str;
}
pub struct SecretDetectionHook {
crypto: CryptoEngine,
}
impl SecretDetectionHook {
pub fn new(crypto: &CryptoEngine) -> GitResult<Self> {
Ok(Self {
crypto: crypto.clone(),
})
}
pub async fn detect_secrets_in_staged_files(&self, config: &SecretDetectionConfig) -> GitResult<Vec<SecretDetection>> {
let mut detections = Vec::new();
let output = Command::new("git")
.args(&["diff", "--cached", "--name-only"])
.output()
.map_err(|e| GitError::HookFailed(format!("Failed to get staged files: {}", e)))?;
let staged_files = String::from_utf8_lossy(&output.stdout);
for file_path in staged_files.lines() {
if self.should_check_file(file_path, config) {
if let Ok(content) = fs::read_to_string(file_path).await {
let file_detections = self.detect_secrets_in_content(&content, file_path, config).await?;
detections.extend(file_detections);
}
}
}
Ok(detections)
}
fn should_check_file(&self, file_path: &str, config: &SecretDetectionConfig) -> bool {
if self.is_binary_file(file_path) {
return false;
}
for exclude_pattern in &config.exclude_files {
if self.matches_pattern(file_path, exclude_pattern) {
return false;
}
}
true
}
fn is_binary_file(&self, file_path: &str) -> bool {
let binary_extensions = [
".exe", ".dll", ".so", ".dylib", ".a", ".o", ".obj",
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff",
".mp3", ".mp4", ".avi", ".mov", ".wmv",
".zip", ".tar", ".gz", ".rar", ".7z",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
];
binary_extensions.iter().any(|ext| file_path.ends_with(ext))
}
fn matches_pattern(&self, file_path: &str, pattern: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
return file_path.starts_with(parts[0]) && file_path.ends_with(parts[1]);
}
}
if pattern.starts_with("*.") {
let extension = &pattern[1..];
return file_path.ends_with(extension);
}
file_path.contains(pattern)
}
async fn detect_secrets_in_content(&self, content: &str, file_path: &str, config: &SecretDetectionConfig) -> GitResult<Vec<SecretDetection>> {
let mut detections = Vec::new();
for pattern in &config.patterns {
let matches = pattern.find_matches(content);
for secret_match in matches {
detections.push(SecretDetection {
file_path: file_path.to_string(),
secret_match,
detection_type: DetectionType::Pattern,
});
}
}
if config.use_ml_detection {
}
Ok(detections)
}
}
impl GitHook for SecretDetectionHook {
fn generate_script(&self, config: &HookConfig) -> GitResult<String> {
let script = format!(r#"#!/bin/bash
# CargoCrypt Pre-commit Hook - Secret Detection
# This hook prevents committing files that contain secrets
set -e
echo "🔍 CargoCrypt: Scanning for secrets..."
# Check if cargocrypt is available
if ! command -v cargocrypt &> /dev/null; then
echo "❌ CargoCrypt not found in PATH"
exit 1
fi
# Run secret detection on staged files
if cargocrypt scan --staged --fail-on-detection; then
echo "✅ No secrets detected in staged files"
exit 0
else
echo "❌ Secrets detected! Commit blocked."
echo "Run 'cargocrypt scan --staged' to see details"
echo "To encrypt sensitive files: 'cargocrypt encrypt <file>'"
exit 1
fi
"#);
Ok(script)
}
fn name(&self) -> &str {
"secret-detection"
}
fn description(&self) -> &str {
"Prevents committing files that contain secrets"
}
}
pub struct EncryptionValidationHook;
impl EncryptionValidationHook {
pub fn new() -> GitResult<Self> {
Ok(Self)
}
}
impl GitHook for EncryptionValidationHook {
fn generate_script(&self, config: &HookConfig) -> GitResult<String> {
let script = r#"#!/bin/bash
# CargoCrypt Pre-push Hook - Encryption Validation
# This hook validates that encrypted files are properly encrypted
set -e
echo "🔐 CargoCrypt: Validating encrypted files..."
# Check if cargocrypt is available
if ! command -v cargocrypt &> /dev/null; then
echo "❌ CargoCrypt not found in PATH"
exit 1
fi
# Validate encryption for files marked as encrypted
if cargocrypt validate --encryption; then
echo "✅ All encrypted files are valid"
exit 0
else
echo "❌ Encryption validation failed! Push blocked."
echo "Run 'cargocrypt validate --encryption' to see details"
exit 1
fi
"#;
Ok(script.to_string())
}
fn name(&self) -> &str {
"encryption-validation"
}
fn description(&self) -> &str {
"Validates that encrypted files are properly encrypted before push"
}
}
#[derive(Debug, Clone)]
pub struct SecretDetection {
pub file_path: String,
pub secret_match: SecretMatch,
pub detection_type: DetectionType,
}
#[derive(Debug, Clone)]
pub enum DetectionType {
Pattern,
MachineLearning,
Entropy,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_git_hooks_creation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let hooks = GitHooks::new(&repo).unwrap();
assert!(hooks.hooks_dir().exists());
}
#[tokio::test]
async fn test_secret_detection_hook() {
let crypto = CryptoEngine::new();
let hook = SecretDetectionHook::new(&crypto).unwrap();
let config = HookConfig::default();
let script = hook.generate_script(&config).unwrap();
assert!(script.contains("CargoCrypt"));
assert!(script.contains("secret detection"));
}
#[test]
fn test_secret_pattern_matching() {
let pattern = SecretPattern::new("api_key", r"api_key\s*=\s*[a-zA-Z0-9_-]+");
let test_content = r#"
config = {
api_key = "sk-1234567890abcdef"
database_url = "postgres://..."
}
"#;
assert!(pattern.matches(test_content));
let matches = pattern.find_matches(test_content);
assert!(!matches.is_empty());
}
#[test]
fn test_hook_types() {
assert_eq!(HookType::PreCommit.filename(), "pre-commit");
assert_eq!(HookType::PrePush.filename(), "pre-push");
assert!(HookType::PreCommit.is_pre_hook());
assert!(!HookType::PostCommit.is_pre_hook());
}
#[tokio::test]
async fn test_hook_installation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let hooks = GitHooks::new(&repo).unwrap();
fs::create_dir_all(hooks.hooks_dir()).await.unwrap();
let crypto = CryptoEngine::new();
let hook = SecretDetectionHook::new(&crypto).unwrap();
hooks.install_hook(HookType::PreCommit, Box::new(hook)).await.unwrap();
let hook_path = hooks.hooks_dir().join("pre-commit");
assert!(hook_path.exists());
let content = fs::read_to_string(&hook_path).await.unwrap();
assert!(content.contains("CargoCrypt"));
}
}