1use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use crate::security::scanner::SecurityScanner;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum PatchStep {
13 CheckingGitVersion,
15 ValidatingPatch,
17 SecurityScan,
19 ApplyCheck,
21 CreatingBranch,
23 ApplyingPatch,
25 Committing,
27 Pushing,
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum PatchError {
34 #[error("patch file not found: {0}")]
36 NotFound(PathBuf),
37 #[error("patch file too large ({size} bytes); maximum is 50MB")]
39 TooLarge {
40 size: u64,
42 },
43 #[error("patch contains unsafe path: {path} - refusing to apply")]
45 PathTraversal {
46 path: String,
48 },
49 #[error("patch creates a symlink ({path}) - refusing to apply")]
51 SymlinkMode {
52 path: String,
54 },
55 #[error("security findings in patch ({count}). Pass --force to apply anyway.")]
57 SecurityFindings {
58 count: usize,
60 },
61 #[error("patch does not apply cleanly:\n{detail}")]
63 ApplyCheckFailed {
64 detail: String,
66 },
67 #[error("branch {name} already exists. Use --branch to specify a different name.")]
69 BranchCollision {
70 name: String,
72 },
73 #[error("git >= 2.39.2 required (found {version}). CVE-2023-23946 is unpatched.")]
75 GitTooOld {
76 version: String,
78 },
79 #[error("git command failed: {detail}")]
81 GitFailed {
82 detail: String,
84 },
85 #[error(transparent)]
87 Io(#[from] std::io::Error),
88}
89
90fn run_git(args: &[&str], cwd: &Path) -> Result<String, PatchError> {
92 let output = Command::new("git").args(args).current_dir(cwd).output()?;
93 if output.status.success() {
94 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
95 } else {
96 Err(PatchError::GitFailed {
97 detail: String::from_utf8_lossy(&output.stderr).trim().to_string(),
98 })
99 }
100}
101
102fn git_config_get(key: &str, cwd: &Path) -> Option<String> {
104 let output = Command::new("git")
105 .args(["config", "--get", key])
106 .current_dir(cwd)
107 .output()
108 .ok()?;
109 if output.status.success() {
110 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
111 } else {
112 None
113 }
114}
115
116pub fn parse_git_version_str(s: &str) -> Result<(), PatchError> {
119 let version_part = s
121 .split_whitespace()
122 .nth(2)
123 .ok_or_else(|| PatchError::GitTooOld {
124 version: s.to_string(),
125 })?
126 .split('(')
127 .next()
128 .ok_or_else(|| PatchError::GitTooOld {
129 version: s.to_string(),
130 })?
131 .trim_end_matches('.')
132 .to_string();
133
134 let parts: Vec<u64> = version_part
135 .split('.')
136 .filter_map(|p| p.parse().ok())
137 .collect();
138
139 let (major, minor, patch) = match parts.as_slice() {
140 [ma, mi, pa, ..] => (*ma, *mi, *pa),
141 [ma, mi] => (*ma, *mi, 0),
142 [ma] => (*ma, 0, 0),
143 [] => {
144 return Err(PatchError::GitTooOld {
145 version: version_part,
146 });
147 }
148 };
149
150 let ok = (major, minor, patch) >= (2, 39, 2);
152 if ok {
153 Ok(())
154 } else {
155 Err(PatchError::GitTooOld {
156 version: version_part,
157 })
158 }
159}
160
161pub fn git_version_check(cwd: &Path) -> Result<(), PatchError> {
163 let output = Command::new("git")
164 .arg("--version")
165 .current_dir(cwd)
166 .output()?;
167 let s = String::from_utf8_lossy(&output.stdout).to_string();
168 parse_git_version_str(&s)
169}
170
171pub fn validate_patch_paths(content: &str) -> Result<(), PatchError> {
173 for line in content.lines() {
174 if line.starts_with("new file mode 120000") {
176 return Err(PatchError::SymlinkMode {
178 path: "(symlink)".to_string(),
179 });
180 }
181 if let Some(path) = line.strip_prefix("+++ b/") {
183 let path = path.trim();
184 if path.starts_with('/') || path.contains("../") || path.contains("\\..") {
185 return Err(PatchError::PathTraversal {
186 path: path.to_string(),
187 });
188 }
189 }
190 if let Some(path) = line.strip_prefix("--- a/") {
192 let path = path.trim();
193 if path.starts_with('/') || path.contains("../") || path.contains("\\..") {
194 return Err(PatchError::PathTraversal {
195 path: path.to_string(),
196 });
197 }
198 }
199 }
200 Ok(())
201}
202
203#[must_use]
206pub fn slugify_title(title: &str) -> String {
207 let lower: String = title
209 .chars()
210 .filter(char::is_ascii)
211 .collect::<String>()
212 .to_lowercase();
213
214 let mut slug = String::new();
216 let mut last_hyphen = true; for c in lower.chars() {
218 if c.is_ascii_alphanumeric() {
219 last_hyphen = false;
220 slug.push(c);
221 } else if !last_hyphen {
222 slug.push('-');
223 last_hyphen = true;
224 }
225 }
226 let slug = slug.trim_end_matches('-').to_string();
228 let slug = slug.trim_start_matches('-').to_string();
229
230 let conventional_prefixes = [
232 "feat", "fix", "docs", "chore", "test", "refactor", "perf", "ci", "build", "style",
233 ];
234 let mut result = slug.clone();
235 for prefix in &conventional_prefixes {
236 let slug_prefix_with_hyphen = format!("{prefix}-");
237 if slug.starts_with(&slug_prefix_with_hyphen) {
238 result = format!("{}/{}", prefix, &slug[slug_prefix_with_hyphen.len()..]);
240 break;
241 }
242 }
243
244 if result.len() > 60 {
246 let truncated = &result[..60];
247 if let Some(pos) = truncated.rfind(&['-', '/'][..]) {
249 result = truncated[..pos].to_string();
250 } else {
251 result = truncated.to_string();
252 }
253 }
254
255 result
256}
257
258#[allow(clippy::too_many_arguments)]
260pub async fn apply_patch_and_push(
261 patch_path: &Path,
262 repo_root: &Path,
263 branch: Option<&str>,
264 base: &str,
265 title: &str,
266 dco_signoff: bool,
267 force: bool,
268 progress: impl Fn(PatchStep),
269) -> Result<String, PatchError> {
270 const MAX_SIZE: u64 = 50 * 1024 * 1024; progress(PatchStep::CheckingGitVersion);
274 git_version_check(repo_root)?;
275
276 progress(PatchStep::ValidatingPatch);
278 if !patch_path.exists() {
279 return Err(PatchError::NotFound(patch_path.to_path_buf()));
280 }
281 let metadata = std::fs::metadata(patch_path)?;
282 let size = metadata.len();
283 if size > MAX_SIZE {
284 return Err(PatchError::TooLarge { size });
285 }
286 let content = std::fs::read_to_string(patch_path)?;
287 validate_patch_paths(&content)?;
288
289 progress(PatchStep::SecurityScan);
291 let scanner = SecurityScanner::new();
292 let findings = scanner.scan_diff(&content);
293 if !findings.is_empty() && !force {
294 return Err(PatchError::SecurityFindings {
295 count: findings.len(),
296 });
297 }
298
299 progress(PatchStep::ApplyCheck);
301 let patch_abs = patch_path
302 .canonicalize()
303 .unwrap_or_else(|_| patch_path.to_path_buf());
304 let patch_str = patch_abs.to_string_lossy();
305 let check_output = Command::new("git")
306 .args(["apply", "--check", &patch_str])
307 .current_dir(repo_root)
308 .output()?;
309 if !check_output.status.success() {
310 return Err(PatchError::ApplyCheckFailed {
311 detail: String::from_utf8_lossy(&check_output.stderr)
312 .trim()
313 .to_string(),
314 });
315 }
316
317 let branch_name = branch.map_or_else(|| slugify_title(title), str::to_owned);
319
320 let branch_name = resolve_branch_name(&branch_name, repo_root)?;
322
323 progress(PatchStep::CreatingBranch);
325 let base_ref = format!("origin/{base}");
326 run_git(&["checkout", "-b", &branch_name, &base_ref], repo_root)?;
327
328 progress(PatchStep::ApplyingPatch);
330 run_git(&["apply", &patch_str], repo_root)?;
331
332 run_git(&["add", "-A"], repo_root)?;
334
335 progress(PatchStep::Committing);
337 let gpg_sign =
338 git_config_get("commit.gpgSign", repo_root).is_some_and(|v| v.eq_ignore_ascii_case("true"));
339
340 let mut commit_args: Vec<String> =
341 vec!["commit".to_string(), "-m".to_string(), title.to_string()];
342 if gpg_sign {
343 commit_args.push("-S".to_string());
344 }
345 if dco_signoff {
346 commit_args.push("--signoff".to_string());
347 }
348 let commit_args_ref: Vec<&str> = commit_args.iter().map(String::as_str).collect();
349 run_git(&commit_args_ref, repo_root)?;
350
351 progress(PatchStep::Pushing);
353 run_git(&["push", "origin", &branch_name], repo_root)?;
354
355 Ok(branch_name)
356}
357
358fn resolve_branch_name(name: &str, repo_root: &Path) -> Result<String, PatchError> {
360 use std::time::{SystemTime, UNIX_EPOCH};
361
362 if !branch_exists_remote(name, repo_root) {
363 return Ok(name.to_string());
364 }
365
366 let date = chrono::Utc::now().format("%Y%m%d").to_string();
368 let with_date = format!("{name}-{date}");
369 if !branch_exists_remote(&with_date, repo_root) {
370 return Ok(with_date);
371 }
372
373 let seed = SystemTime::now()
375 .duration_since(UNIX_EPOCH)
376 .map_or(0, |d| d.subsec_nanos());
377 let hex_suffix = format!("{seed:06x}");
378 let with_hex = format!("{name}-{hex_suffix}");
379 if !branch_exists_remote(&with_hex, repo_root) {
380 return Ok(with_hex);
381 }
382
383 Err(PatchError::BranchCollision {
384 name: name.to_string(),
385 })
386}
387
388fn branch_exists_remote(name: &str, repo_root: &Path) -> bool {
389 let refspec = format!("refs/heads/{name}");
390 let output = Command::new("git")
391 .args(["ls-remote", "origin", &refspec])
392 .current_dir(repo_root)
393 .output()
394 .ok();
395 output.is_some_and(|o| o.status.success() && !o.stdout.is_empty())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_git_version_parse_valid() {
404 assert!(parse_git_version_str("git version 2.39.2\n").is_ok());
405 assert!(parse_git_version_str("git version 2.43.0\n").is_ok());
406 }
407
408 #[test]
409 fn test_git_version_parse_apple_git() {
410 assert!(parse_git_version_str("git version 2.46.2 (Apple Git-139)\n").is_ok());
411 }
412
413 #[test]
414 fn test_git_version_too_old() {
415 let err = parse_git_version_str("git version 2.38.0\n");
416 assert!(matches!(err, Err(PatchError::GitTooOld { .. })));
417 }
418
419 #[test]
420 fn test_validate_patch_paths_safe() {
421 let diff = "+++ b/src/main.rs\n--- a/src/main.rs\n";
422 assert!(validate_patch_paths(diff).is_ok());
423 }
424
425 #[test]
426 fn test_validate_patch_paths_traversal() {
427 let diff = "+++ b/../etc/passwd\n";
428 let err = validate_patch_paths(diff);
429 assert!(matches!(err, Err(PatchError::PathTraversal { .. })));
430 }
431
432 #[test]
433 fn test_validate_patch_paths_absolute() {
434 let diff = "+++ b//etc/shadow\n";
435 let err = validate_patch_paths(diff);
436 assert!(matches!(err, Err(PatchError::PathTraversal { .. })));
437 }
438
439 #[test]
440 fn test_validate_patch_paths_symlink_mode() {
441 let diff = "new file mode 120000\n";
442 let err = validate_patch_paths(diff);
443 assert!(matches!(err, Err(PatchError::SymlinkMode { .. })));
444 }
445
446 #[test]
447 fn test_slugify_basic() {
448 assert_eq!(slugify_title("Fix login bug"), "fix/login-bug");
450 }
451
452 #[test]
453 fn test_slugify_conventional_prefix() {
454 assert_eq!(slugify_title("fix: add retry logic"), "fix/add-retry-logic");
455 }
456
457 #[test]
458 fn test_slugify_truncation() {
459 let long_title = "feat: this is a very long title that exceeds sixty characters limit here";
460 let result = slugify_title(long_title);
461 assert!(result.len() <= 60, "slug too long: {result}");
462 }
463}