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