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