1use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::sync::LazyLock;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SafetyCheckResult {
16 pub safe: bool,
18 pub reason: Option<String>,
20 pub warning: Option<String>,
22 pub suggestion: Option<String>,
24}
25
26impl SafetyCheckResult {
27 pub fn safe() -> Self {
29 Self {
30 safe: true,
31 reason: None,
32 warning: None,
33 suggestion: None,
34 }
35 }
36
37 pub fn safe_with_warning(warning: impl Into<String>, suggestion: impl Into<String>) -> Self {
39 Self {
40 safe: true,
41 reason: None,
42 warning: Some(warning.into()),
43 suggestion: Some(suggestion.into()),
44 }
45 }
46
47 pub fn unsafe_result(reason: impl Into<String>, suggestion: impl Into<String>) -> Self {
49 Self {
50 safe: false,
51 reason: Some(reason.into()),
52 warning: None,
53 suggestion: Some(suggestion.into()),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SensitiveFilesCheck {
61 pub has_sensitive_files: bool,
63 pub sensitive_files: Vec<String>,
65 pub warnings: Vec<String>,
67}
68
69static DANGEROUS_COMMANDS: &[&str] = &[
71 "push --force",
72 "push -f",
73 "reset --hard",
74 "clean -fd",
75 "clean -fdx",
76 "clean -f",
77 "filter-branch",
78 "rebase --force",
79];
80
81static CAUTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
83 vec![
84 Regex::new(r"git\s+push.*--force").unwrap(),
85 Regex::new(r"git\s+push.*-f\b").unwrap(),
86 Regex::new(r"git\s+reset\s+--hard").unwrap(),
87 Regex::new(r"git\s+clean\s+-[fdx]+").unwrap(),
88 Regex::new(r"git\s+commit.*--amend").unwrap(),
89 Regex::new(r"git\s+rebase.*-i").unwrap(),
90 Regex::new(r"git\s+config").unwrap(),
91 Regex::new(r"--no-verify").unwrap(),
92 Regex::new(r"--no-gpg-sign").unwrap(),
93 ]
94});
95
96static SENSITIVE_FILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
98 vec![
99 Regex::new(r"\.env$").unwrap(),
100 Regex::new(r"\.env\.").unwrap(),
101 Regex::new(r"credentials\.json$").unwrap(),
102 Regex::new(r"secrets\.json$").unwrap(),
103 Regex::new(r"\.pem$").unwrap(),
104 Regex::new(r"\.key$").unwrap(),
105 Regex::new(r"\.cert$").unwrap(),
106 Regex::new(r"id_rsa$").unwrap(),
107 Regex::new(r"id_ed25519$").unwrap(),
108 Regex::new(r"\.aws/credentials$").unwrap(),
109 Regex::new(r"\.ssh/id_").unwrap(),
110 Regex::new(r"(?i)password").unwrap(),
111 Regex::new(r"(?i)secret").unwrap(),
112 Regex::new(r"(?i)token").unwrap(),
113 Regex::new(r"(?i)api[_-]?key").unwrap(),
114 ]
115});
116
117pub struct GitSafety;
119
120impl GitSafety {
121 pub fn validate_git_command(command: &str) -> SafetyCheckResult {
123 for dangerous in DANGEROUS_COMMANDS {
125 if command.contains(dangerous) {
126 return SafetyCheckResult::unsafe_result(
127 format!("检测到危险命令: {}", dangerous),
128 "此操作具有破坏性且不可逆。如需继续,请明确确认。",
129 );
130 }
131 }
132
133 for pattern in CAUTION_PATTERNS.iter() {
135 if pattern.is_match(command) {
136 return SafetyCheckResult::safe_with_warning(
137 "检测到潜在危险的命令模式,请谨慎使用。",
138 "请确保您了解此操作的后果。",
139 );
140 }
141 }
142
143 SafetyCheckResult::safe()
144 }
145
146 pub fn is_dangerous(command: &str) -> bool {
148 !Self::validate_git_command(command).safe
149 }
150
151 pub fn check_force_push_to_main(command: &str, current_branch: &str) -> SafetyCheckResult {
153 let force_push_re = Regex::new(r"push.*--force|push.*-f\b").unwrap();
154 let is_force_push = force_push_re.is_match(command);
155 let is_main_branch = current_branch == "main" || current_branch == "master";
156
157 if is_force_push && is_main_branch {
158 return SafetyCheckResult::unsafe_result(
159 format!("强制推送到 {} 分支非常危险", current_branch),
160 "永远不要强制推送到 main/master。请创建新分支并提交 PR。",
161 );
162 }
163
164 if is_force_push {
165 return SafetyCheckResult::safe_with_warning(
166 format!("检测到强制推送到分支: {}", current_branch),
167 "请确保没有其他人在此分支上工作。",
168 );
169 }
170
171 SafetyCheckResult::safe()
172 }
173
174 pub fn check_sensitive_files(files: &[String]) -> SensitiveFilesCheck {
176 let mut sensitive_files = Vec::new();
177 let mut warnings = Vec::new();
178
179 for file in files {
180 for pattern in SENSITIVE_FILE_PATTERNS.iter() {
181 if pattern.is_match(file) {
182 sensitive_files.push(file.clone());
183 warnings.push(format!("检测到敏感文件: {}", file));
184 break;
185 }
186 }
187 }
188
189 SensitiveFilesCheck {
190 has_sensitive_files: !sensitive_files.is_empty(),
191 sensitive_files,
192 warnings,
193 }
194 }
195
196 pub fn check_skip_hooks(command: &str) -> SafetyCheckResult {
198 if command.contains("--no-verify") {
199 return SafetyCheckResult::unsafe_result(
200 "尝试使用 --no-verify 跳过 Git 钩子",
201 "除非用户明确要求,否则不要跳过钩子。",
202 );
203 }
204
205 if command.contains("--no-gpg-sign") {
206 return SafetyCheckResult::unsafe_result(
207 "尝试使用 --no-gpg-sign 跳过 GPG 签名",
208 "除非用户明确要求,否则不要跳过 GPG 签名。",
209 );
210 }
211
212 SafetyCheckResult::safe()
213 }
214
215 pub fn check_config_change(command: &str) -> SafetyCheckResult {
217 let config_re = Regex::new(r"git\s+config").unwrap();
218 if config_re.is_match(command) {
219 return SafetyCheckResult::unsafe_result(
220 "尝试修改 Git 配置",
221 "除非用户明确要求,否则永远不要修改 git 配置。",
222 );
223 }
224
225 SafetyCheckResult::safe()
226 }
227
228 pub fn comprehensive_check(
230 command: &str,
231 current_branch: Option<&str>,
232 files: Option<&[String]>,
233 ) -> SafetyCheckResult {
234 let config_check = Self::check_config_change(command);
236 if !config_check.safe {
237 return config_check;
238 }
239
240 let hooks_check = Self::check_skip_hooks(command);
242 if !hooks_check.safe {
243 return hooks_check;
244 }
245
246 let danger_check = Self::validate_git_command(command);
248 if !danger_check.safe {
249 return danger_check;
250 }
251
252 if let Some(branch) = current_branch {
254 let force_push_check = Self::check_force_push_to_main(command, branch);
255 if !force_push_check.safe {
256 return force_push_check;
257 }
258 }
259
260 if let Some(file_list) = files {
262 if command.contains("git add") || command.contains("git commit") {
263 let sensitive_check = Self::check_sensitive_files(file_list);
264 if sensitive_check.has_sensitive_files {
265 return SafetyCheckResult::safe_with_warning(
266 format!(
267 "检测到敏感文件: {}",
268 sensitive_check.sensitive_files.join(", ")
269 ),
270 "不要提交可能包含密钥的文件 (.env, credentials.json 等)。",
271 );
272 }
273 }
274 }
275
276 if danger_check.warning.is_some() {
278 return danger_check;
279 }
280
281 SafetyCheckResult::safe()
282 }
283}
284
285pub fn is_dangerous_command(command: &str) -> bool {
287 GitSafety::is_dangerous(command)
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_dangerous_commands() {
296 assert!(is_dangerous_command("git push --force"));
297 assert!(is_dangerous_command("git push -f origin main"));
298 assert!(is_dangerous_command("git reset --hard HEAD~1"));
299 assert!(is_dangerous_command("git clean -fd"));
300 assert!(!is_dangerous_command("git push origin main"));
301 assert!(!is_dangerous_command("git commit -m 'test'"));
302 }
303
304 #[test]
305 fn test_force_push_to_main() {
306 let result = GitSafety::check_force_push_to_main("git push --force", "main");
307 assert!(!result.safe);
308
309 let result = GitSafety::check_force_push_to_main("git push --force", "feature");
310 assert!(result.safe);
311 assert!(result.warning.is_some());
312
313 let result = GitSafety::check_force_push_to_main("git push", "main");
314 assert!(result.safe);
315 }
316
317 #[test]
318 fn test_sensitive_files() {
319 let files = vec![
320 ".env".to_string(),
321 "config.json".to_string(),
322 "credentials.json".to_string(),
323 ];
324 let result = GitSafety::check_sensitive_files(&files);
325 assert!(result.has_sensitive_files);
326 assert_eq!(result.sensitive_files.len(), 2);
327 }
328
329 #[test]
330 fn test_skip_hooks() {
331 let result = GitSafety::check_skip_hooks("git commit --no-verify -m 'test'");
332 assert!(!result.safe);
333
334 let result = GitSafety::check_skip_hooks("git commit -m 'test'");
335 assert!(result.safe);
336 }
337
338 #[test]
339 fn test_config_change() {
340 let result = GitSafety::check_config_change("git config user.email test@test.com");
341 assert!(!result.safe);
342
343 let result = GitSafety::check_config_change("git status");
344 assert!(result.safe);
345 }
346}