1use std::fs;
8use std::path::Path;
9
10use crate::errors::{EnvVaultError, Result};
11
12const HOOK_NAME: &str = "pre-commit";
14
15pub const SECRET_PATTERNS: &[(&str, &str)] = &[
18 ("AWS Access Key", r"AKIA[0-9A-Z]{16}"),
19 (
20 "AWS Secret Key",
21 r#"(?i)(aws_secret|secret_key)\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}"#,
22 ),
23 ("GitHub Token", r"gh[ps]_[A-Za-z0-9_]{36,}"),
24 (
25 "Generic API Key",
26 r#"(?i)(api[_-]?key|apikey)\s*[=:]\s*["']?[A-Za-z0-9_\-]{20,}"#,
27 ),
28 (
29 "Generic Secret",
30 r#"(?i)(secret|password|passwd|token)\s*[=:]\s*["']?[^\s'"]{8,}"#,
31 ),
32 ("Stripe Key", r"sk_(?:live|test)_[A-Za-z0-9]{24,}"),
33 ("GitHub Fine-Grained Token", r"github_pat_[A-Za-z0-9_]{82}"),
34 ("Slack Token", r"xox[bpas]-[A-Za-z0-9\-]+"),
35 ("Anthropic API Key", r"sk-ant-[A-Za-z0-9\-]+"),
36 (
37 "Private Key Header",
38 r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
39 ),
40];
41
42fn hook_script() -> String {
44 use std::fmt::Write;
45 let mut patterns = String::new();
46 for (name, pattern) in SECRET_PATTERNS {
47 let _ = write!(
48 patterns,
49 " if echo \"$staged_content\" | grep -qE '{pattern}'; then\n\
50 \x20 echo \" [!] Possible {name} found in staged files\"\n\
51 \x20 found=1\n\
52 \x20 fi\n",
53 );
54 }
55
56 format!(
57 r#"#!/bin/sh
58# EnvVault pre-commit hook — blocks commits containing hardcoded secrets.
59# Auto-installed by `envvault init`. Remove this file to disable.
60
61staged_content=$(git diff --cached --diff-filter=ACM -U0)
62found=0
63
64{patterns}
65if [ "$found" -eq 1 ]; then
66 echo ""
67 echo " EnvVault: Potential secrets detected in staged files!"
68 echo " Use 'envvault set <KEY>' to store secrets securely."
69 echo " To bypass this check: git commit --no-verify"
70 echo ""
71 exit 1
72fi
73
74exit 0
75"#
76 )
77}
78
79pub fn install_hook(project_dir: &Path) -> Result<InstallResult> {
84 let git_dir = project_dir.join(".git");
85 if !git_dir.is_dir() {
86 return Ok(InstallResult::NotAGitRepo);
87 }
88
89 let hooks_dir = git_dir.join("hooks");
90 if !hooks_dir.exists() {
91 fs::create_dir_all(&hooks_dir).map_err(|e| {
92 EnvVaultError::CommandFailed(format!("failed to create hooks dir: {e}"))
93 })?;
94 }
95
96 let hook_path = hooks_dir.join(HOOK_NAME);
97
98 if hook_path.exists() {
99 let existing = fs::read_to_string(&hook_path).unwrap_or_default();
101 if existing.contains("EnvVault pre-commit hook") {
102 return Ok(InstallResult::AlreadyInstalled);
103 }
104 return Ok(InstallResult::ExistingHookFound);
105 }
106
107 let script = hook_script();
108 fs::write(&hook_path, script).map_err(|e| {
109 EnvVaultError::CommandFailed(format!("failed to write pre-commit hook: {e}"))
110 })?;
111
112 #[cfg(unix)]
114 {
115 use std::os::unix::fs::PermissionsExt;
116 let perms = fs::Permissions::from_mode(0o755);
117 fs::set_permissions(&hook_path, perms).map_err(|e| {
118 EnvVaultError::CommandFailed(format!("failed to set hook permissions: {e}"))
119 })?;
120 }
121
122 Ok(InstallResult::Installed)
123}
124
125pub enum InstallResult {
127 Installed,
129 AlreadyInstalled,
131 ExistingHookFound,
133 NotAGitRepo,
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use tempfile::TempDir;
141
142 #[test]
143 fn install_hook_in_non_git_dir() {
144 let dir = TempDir::new().unwrap();
145 match install_hook(dir.path()).unwrap() {
146 InstallResult::NotAGitRepo => {}
147 _ => panic!("expected NotAGitRepo"),
148 }
149 }
150
151 #[test]
152 fn install_hook_creates_hook_file() {
153 let dir = TempDir::new().unwrap();
154 fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
156
157 match install_hook(dir.path()).unwrap() {
158 InstallResult::Installed => {}
159 _ => panic!("expected Installed"),
160 }
161
162 let hook_path = dir.path().join(".git/hooks/pre-commit");
163 assert!(hook_path.exists());
164
165 let content = fs::read_to_string(&hook_path).unwrap();
166 assert!(content.contains("EnvVault pre-commit hook"));
167 }
168
169 #[test]
170 fn install_hook_twice_returns_already_installed() {
171 let dir = TempDir::new().unwrap();
172 fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
173
174 install_hook(dir.path()).unwrap();
175
176 match install_hook(dir.path()).unwrap() {
177 InstallResult::AlreadyInstalled => {}
178 _ => panic!("expected AlreadyInstalled"),
179 }
180 }
181
182 #[test]
183 fn install_hook_respects_existing_hook() {
184 let dir = TempDir::new().unwrap();
185 let hooks_dir = dir.path().join(".git/hooks");
186 fs::create_dir_all(&hooks_dir).unwrap();
187
188 fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho hi\n").unwrap();
190
191 match install_hook(dir.path()).unwrap() {
192 InstallResult::ExistingHookFound => {}
193 _ => panic!("expected ExistingHookFound"),
194 }
195 }
196
197 #[test]
198 fn hook_script_contains_secret_patterns() {
199 let script = hook_script();
200 assert!(script.contains("AWS Access Key"));
201 assert!(script.contains("Stripe Key"));
202 assert!(script.contains("GitHub Fine-Grained Token"));
203 assert!(script.contains("Slack Token"));
204 assert!(script.contains("Anthropic API Key"));
205 assert!(script.contains("Private Key Header"));
206 assert!(script.contains("EnvVault"));
207 }
208}