Skip to main content

aptu_core/git/
patch.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Patch application and Git utilities for automated patch deployment.
4
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use crate::security::scanner::SecurityScanner;
9
10/// Steps reported via progress callback during `apply_patch_and_push`.
11#[derive(Debug, Clone, PartialEq)]
12pub enum PatchStep {
13    /// Checking git version for CVE-2023-23946 compatibility.
14    CheckingGitVersion,
15    /// Validating patch file integrity and path safety.
16    ValidatingPatch,
17    /// Scanning patch content for security findings.
18    SecurityScan,
19    /// Running `git apply --check` to verify the patch applies cleanly before committing.
20    ApplyCheck,
21    /// Creating feature branch from base.
22    CreatingBranch,
23    /// Applying patch to working directory.
24    ApplyingPatch,
25    /// Creating signed commit with patch changes.
26    Committing,
27    /// Pushing branch to origin remote.
28    Pushing,
29}
30
31/// Errors returned by patch operations.
32#[derive(Debug, thiserror::Error)]
33pub enum PatchError {
34    /// Patch file not found.
35    #[error("patch file not found: {0}")]
36    NotFound(PathBuf),
37    /// Patch file exceeds 50MB limit.
38    #[error("patch file too large ({size} bytes); maximum is 50MB")]
39    TooLarge {
40        /// Actual file size in bytes.
41        size: u64,
42    },
43    /// Patch contains unsafe path traversal.
44    #[error("patch contains unsafe path: {path} - refusing to apply")]
45    PathTraversal {
46        /// The offending path extracted from the patch header.
47        path: String,
48    },
49    /// Patch attempts to create a symlink.
50    #[error("patch creates a symlink ({path}) - refusing to apply")]
51    SymlinkMode {
52        /// The offending path extracted from the patch header.
53        path: String,
54    },
55    /// Security scanner found issues in patch.
56    #[error("security findings in patch ({count}). Pass --force to apply anyway.")]
57    SecurityFindings {
58        /// Number of security findings detected.
59        count: usize,
60    },
61    /// Patch does not apply cleanly to target branch.
62    #[error("patch does not apply cleanly:\n{detail}")]
63    ApplyCheckFailed {
64        /// Stderr output from `git apply --check`.
65        detail: String,
66    },
67    /// Branch name already exists on origin.
68    #[error("branch {name} already exists. Use --branch to specify a different name.")]
69    BranchCollision {
70        /// The branch name that collided.
71        name: String,
72    },
73    /// Git version is too old for safe patching.
74    #[error("git >= 2.39.2 required (found {version}). CVE-2023-23946 is unpatched.")]
75    GitTooOld {
76        /// The version string reported by the system git binary.
77        version: String,
78    },
79    /// Git command execution failed.
80    #[error("git command failed: {detail}")]
81    GitFailed {
82        /// Stderr output from the failed git invocation.
83        detail: String,
84    },
85    /// I/O error.
86    #[error(transparent)]
87    Io(#[from] std::io::Error),
88}
89
90/// Run a git command in the given directory. Returns trimmed stdout on success.
91fn 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
102/// Read a git config value. Returns None if not set.
103fn 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
116/// Parse a git version string like `"git version 2.39.2"` or `"git version 2.46.2 (Apple Git-139)"`.
117/// Returns `Err(PatchError::GitTooOld)` if version < 2.39.2 or if parsing fails.
118pub fn parse_git_version_str(s: &str) -> Result<(), PatchError> {
119    // "git version 2.39.2" or "git version 2.46.2 (Apple Git-139)"
120    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    // Required: >= 2.39.2
151    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
161/// Check that the system git binary is >= 2.39.2 (CVE-2023-23946 patched).
162pub 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
171/// Validate patch file path headers for traversal and symlink attacks.
172pub fn validate_patch_paths(content: &str) -> Result<(), PatchError> {
173    for line in content.lines() {
174        // Check for symlink mode creation
175        if line.starts_with("new file mode 120000") {
176            // Find path from previous +++ or --- line -- but just flag on this line
177            return Err(PatchError::SymlinkMode {
178                path: "(symlink)".to_string(),
179            });
180        }
181        // Check +++ b/<path> headers
182        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        // Also check --- a/<path> headers
191        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/// Slugify a PR title into a valid git branch name segment.
204/// Follows the algorithm from issue #1126.
205#[must_use]
206pub fn slugify_title(title: &str) -> String {
207    // Lowercase, drop non-ASCII
208    let lower: String = title
209        .chars()
210        .filter(char::is_ascii)
211        .collect::<String>()
212        .to_lowercase();
213
214    // Replace runs of non-alnum with hyphens
215    let mut slug = String::new();
216    let mut last_hyphen = true; // suppress leading hyphens
217    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    // Strip trailing hyphen
227    let slug = slug.trim_end_matches('-').to_string();
228    let slug = slug.trim_start_matches('-').to_string();
229
230    // Detect conventional-commit prefix
231    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            // Replace "feat-" prefix with "feat/"
239            result = format!("{}/{}", prefix, &slug[slug_prefix_with_hyphen.len()..]);
240            break;
241        }
242    }
243
244    // Truncate to 60 chars at a hyphen/slash boundary
245    if result.len() > 60 {
246        let truncated = &result[..60];
247        // Find last hyphen or slash within the 60-char window
248        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/// Apply a patch file, commit, and push to origin. Returns the branch name that was pushed.
259#[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; // 50MB
271
272    // Step 1: git version check
273    progress(PatchStep::CheckingGitVersion);
274    git_version_check(repo_root)?;
275
276    // Step 2: Validate patch
277    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    // Step 3: Security scan
290    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    // Step 4: Verify patch applies cleanly before touching the branch
300    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    // Derive branch name
318    let branch_name = branch.map_or_else(|| slugify_title(title), str::to_owned);
319
320    // Collision check and suffix logic
321    let branch_name = resolve_branch_name(&branch_name, repo_root)?;
322
323    // Step 5: Create branch
324    progress(PatchStep::CreatingBranch);
325    let base_ref = format!("origin/{base}");
326    run_git(&["checkout", "-b", &branch_name, &base_ref], repo_root)?;
327
328    // Step 6: Apply patch
329    progress(PatchStep::ApplyingPatch);
330    run_git(&["apply", &patch_str], repo_root)?;
331
332    // Stage all changes
333    run_git(&["add", "-A"], repo_root)?;
334
335    // Step 7: Commit
336    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    // Step 8: Push
352    progress(PatchStep::Pushing);
353    run_git(&["push", "origin", &branch_name], repo_root)?;
354
355    Ok(branch_name)
356}
357
358/// Resolve branch name with collision handling.
359fn 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    // Try with date suffix
367    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    // Try with random hex suffix
374    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        // "Fix login bug" -> lowercase -> "fix-login-bug" -> detect "fix-" prefix -> "fix/login-bug"
449        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}