aptu-core 0.4.2

Core library for Aptu - OSS issue triage with AI assistance
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
// SPDX-License-Identifier: Apache-2.0

//! Patch application and Git utilities for automated patch deployment.

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::security::scanner::SecurityScanner;

/// Steps reported via progress callback during `apply_patch_and_push`.
#[derive(Debug, Clone, PartialEq)]
pub enum PatchStep {
    /// Checking git version for CVE-2023-23946 compatibility.
    CheckingGitVersion,
    /// Validating patch file integrity and path safety.
    ValidatingPatch,
    /// Scanning patch content for security findings.
    SecurityScan,
    /// Running `git apply --check` to verify the patch applies cleanly before committing.
    ApplyCheck,
    /// Creating feature branch from base.
    CreatingBranch,
    /// Applying patch to working directory.
    ApplyingPatch,
    /// Creating signed commit with patch changes.
    Committing,
    /// Pushing branch to origin remote.
    Pushing,
}

/// Errors returned by patch operations.
#[derive(Debug, thiserror::Error)]
pub enum PatchError {
    /// Patch file not found.
    #[error("patch file not found: {0}")]
    NotFound(PathBuf),
    /// Patch file exceeds 50MB limit.
    #[error("patch file too large ({size} bytes); maximum is 50MB")]
    TooLarge {
        /// Actual file size in bytes.
        size: u64,
    },
    /// Patch contains unsafe path traversal.
    #[error("patch contains unsafe path: {path} - refusing to apply")]
    PathTraversal {
        /// The offending path extracted from the patch header.
        path: String,
    },
    /// Patch attempts to create a symlink.
    #[error("patch creates a symlink ({path}) - refusing to apply")]
    SymlinkMode {
        /// The offending path extracted from the patch header.
        path: String,
    },
    /// Security scanner found issues in patch.
    #[error("security findings in patch ({count}). Pass --force to apply anyway.")]
    SecurityFindings {
        /// Number of security findings detected.
        count: usize,
    },
    /// Patch does not apply cleanly to target branch.
    #[error("patch does not apply cleanly:\n{detail}")]
    ApplyCheckFailed {
        /// Stderr output from `git apply --check`.
        detail: String,
    },
    /// Branch name already exists on origin.
    #[error("branch {name} already exists. Use --branch to specify a different name.")]
    BranchCollision {
        /// The branch name that collided.
        name: String,
    },
    /// Git version is too old for safe patching.
    #[error("git >= 2.39.2 required (found {version}). CVE-2023-23946 is unpatched.")]
    GitTooOld {
        /// The version string reported by the system git binary.
        version: String,
    },
    /// Git command execution failed.
    #[error("git command failed: {detail}")]
    GitFailed {
        /// Stderr output from the failed git invocation.
        detail: String,
    },
    /// I/O error.
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

/// Run a git command in the given directory. Returns trimmed stdout on success.
fn run_git(args: &[&str], cwd: &Path) -> Result<String, PatchError> {
    let output = Command::new("git").args(args).current_dir(cwd).output()?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
    } else {
        Err(PatchError::GitFailed {
            detail: String::from_utf8_lossy(&output.stderr).trim().to_string(),
        })
    }
}

/// Read a git config value. Returns None if not set.
fn git_config_get(key: &str, cwd: &Path) -> Option<String> {
    let output = Command::new("git")
        .args(["config", "--get", key])
        .current_dir(cwd)
        .output()
        .ok()?;
    if output.status.success() {
        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
    } else {
        None
    }
}

/// Parse a git version string like `"git version 2.39.2"` or `"git version 2.46.2 (Apple Git-139)"`.
/// Returns `Err(PatchError::GitTooOld)` if version < 2.39.2 or if parsing fails.
pub fn parse_git_version_str(s: &str) -> Result<(), PatchError> {
    // "git version 2.39.2" or "git version 2.46.2 (Apple Git-139)"
    let version_part = s
        .split_whitespace()
        .nth(2)
        .ok_or_else(|| PatchError::GitTooOld {
            version: s.to_string(),
        })?
        .split('(')
        .next()
        .ok_or_else(|| PatchError::GitTooOld {
            version: s.to_string(),
        })?
        .trim_end_matches('.')
        .to_string();

    let parts: Vec<u64> = version_part
        .split('.')
        .filter_map(|p| p.parse().ok())
        .collect();

    let (major, minor, patch) = match parts.as_slice() {
        [ma, mi, pa, ..] => (*ma, *mi, *pa),
        [ma, mi] => (*ma, *mi, 0),
        [ma] => (*ma, 0, 0),
        [] => {
            return Err(PatchError::GitTooOld {
                version: version_part,
            });
        }
    };

    // Required: >= 2.39.2
    let ok = (major, minor, patch) >= (2, 39, 2);
    if ok {
        Ok(())
    } else {
        Err(PatchError::GitTooOld {
            version: version_part,
        })
    }
}

/// Check that the system git binary is >= 2.39.2 (CVE-2023-23946 patched).
pub fn git_version_check(cwd: &Path) -> Result<(), PatchError> {
    let output = Command::new("git")
        .arg("--version")
        .current_dir(cwd)
        .output()?;
    let s = String::from_utf8_lossy(&output.stdout).to_string();
    parse_git_version_str(&s)
}

/// Validate patch file path headers for traversal and symlink attacks.
pub fn validate_patch_paths(content: &str) -> Result<(), PatchError> {
    for line in content.lines() {
        // Check for symlink mode creation
        if line.starts_with("new file mode 120000") {
            // Find path from previous +++ or --- line -- but just flag on this line
            return Err(PatchError::SymlinkMode {
                path: "(symlink)".to_string(),
            });
        }
        // Check +++ b/<path> headers
        if let Some(path) = line.strip_prefix("+++ b/") {
            let path = path.trim();
            if path.starts_with('/') || path.contains("../") || path.contains("\\..") {
                return Err(PatchError::PathTraversal {
                    path: path.to_string(),
                });
            }
        }
        // Also check --- a/<path> headers
        if let Some(path) = line.strip_prefix("--- a/") {
            let path = path.trim();
            if path.starts_with('/') || path.contains("../") || path.contains("\\..") {
                return Err(PatchError::PathTraversal {
                    path: path.to_string(),
                });
            }
        }
    }
    Ok(())
}

/// Slugify a PR title into a valid git branch name segment.
/// Follows the algorithm from issue #1126.
#[must_use]
pub fn slugify_title(title: &str) -> String {
    // Lowercase, drop non-ASCII
    let lower: String = title
        .chars()
        .filter(char::is_ascii)
        .collect::<String>()
        .to_lowercase();

    // Replace runs of non-alnum with hyphens
    let mut slug = String::new();
    let mut last_hyphen = true; // suppress leading hyphens
    for c in lower.chars() {
        if c.is_ascii_alphanumeric() {
            last_hyphen = false;
            slug.push(c);
        } else if !last_hyphen {
            slug.push('-');
            last_hyphen = true;
        }
    }
    // Strip trailing hyphen
    let slug = slug.trim_end_matches('-').to_string();
    let slug = slug.trim_start_matches('-').to_string();

    // Detect conventional-commit prefix
    let conventional_prefixes = [
        "feat", "fix", "docs", "chore", "test", "refactor", "perf", "ci", "build", "style",
    ];
    let mut result = slug.clone();
    for prefix in &conventional_prefixes {
        let slug_prefix_with_hyphen = format!("{prefix}-");
        if slug.starts_with(&slug_prefix_with_hyphen) {
            // Replace "feat-" prefix with "feat/"
            result = format!("{}/{}", prefix, &slug[slug_prefix_with_hyphen.len()..]);
            break;
        }
    }

    // Truncate to 60 chars at a hyphen/slash boundary
    if result.len() > 60 {
        let truncated = &result[..60];
        // Find last hyphen or slash within the 60-char window
        if let Some(pos) = truncated.rfind(&['-', '/'][..]) {
            result = truncated[..pos].to_string();
        } else {
            result = truncated.to_string();
        }
    }

    result
}

/// Apply a patch file, commit, and push to origin. Returns the branch name that was pushed.
#[allow(clippy::too_many_arguments)]
pub async fn apply_patch_and_push(
    patch_path: &Path,
    repo_root: &Path,
    branch: Option<&str>,
    base: &str,
    title: &str,
    dco_signoff: bool,
    force: bool,
    progress: impl Fn(PatchStep),
) -> Result<String, PatchError> {
    const MAX_SIZE: u64 = 50 * 1024 * 1024; // 50MB

    // Step 1: git version check
    progress(PatchStep::CheckingGitVersion);
    git_version_check(repo_root)?;

    // Step 2: Validate patch
    progress(PatchStep::ValidatingPatch);
    if !patch_path.exists() {
        return Err(PatchError::NotFound(patch_path.to_path_buf()));
    }
    let metadata = std::fs::metadata(patch_path)?;
    let size = metadata.len();
    if size > MAX_SIZE {
        return Err(PatchError::TooLarge { size });
    }
    let content = std::fs::read_to_string(patch_path)?;
    validate_patch_paths(&content)?;

    // Step 3: Security scan
    progress(PatchStep::SecurityScan);
    let scanner = SecurityScanner::new();
    let findings = scanner.scan_diff(&content);
    if !findings.is_empty() && !force {
        return Err(PatchError::SecurityFindings {
            count: findings.len(),
        });
    }

    // Step 4: Verify patch applies cleanly before touching the branch
    progress(PatchStep::ApplyCheck);
    let patch_abs = patch_path
        .canonicalize()
        .unwrap_or_else(|_| patch_path.to_path_buf());
    let patch_str = patch_abs.to_string_lossy();
    let check_output = Command::new("git")
        .args(["apply", "--check", &patch_str])
        .current_dir(repo_root)
        .output()?;
    if !check_output.status.success() {
        return Err(PatchError::ApplyCheckFailed {
            detail: String::from_utf8_lossy(&check_output.stderr)
                .trim()
                .to_string(),
        });
    }

    // Derive branch name
    let branch_name = branch.map_or_else(|| slugify_title(title), str::to_owned);

    // Collision check and suffix logic
    let branch_name = resolve_branch_name(&branch_name, repo_root)?;

    // Step 5: Create branch
    progress(PatchStep::CreatingBranch);
    let base_ref = format!("origin/{base}");
    run_git(&["checkout", "-b", &branch_name, &base_ref], repo_root)?;

    // Step 6: Apply patch
    progress(PatchStep::ApplyingPatch);
    run_git(&["apply", &patch_str], repo_root)?;

    // Stage all changes
    run_git(&["add", "-A"], repo_root)?;

    // Step 7: Commit
    progress(PatchStep::Committing);
    let gpg_sign =
        git_config_get("commit.gpgSign", repo_root).is_some_and(|v| v.eq_ignore_ascii_case("true"));

    let mut commit_args: Vec<String> =
        vec!["commit".to_string(), "-m".to_string(), title.to_string()];
    if gpg_sign {
        commit_args.push("-S".to_string());
    }
    if dco_signoff {
        commit_args.push("--signoff".to_string());
    }
    let commit_args_ref: Vec<&str> = commit_args.iter().map(String::as_str).collect();
    run_git(&commit_args_ref, repo_root)?;

    // Step 8: Push
    progress(PatchStep::Pushing);
    run_git(&["push", "origin", &branch_name], repo_root)?;

    Ok(branch_name)
}

/// Resolve branch name with collision handling.
fn resolve_branch_name(name: &str, repo_root: &Path) -> Result<String, PatchError> {
    use std::time::{SystemTime, UNIX_EPOCH};

    if !branch_exists_remote(name, repo_root) {
        return Ok(name.to_string());
    }

    // Try with date suffix
    let date = chrono::Utc::now().format("%Y%m%d").to_string();
    let with_date = format!("{name}-{date}");
    if !branch_exists_remote(&with_date, repo_root) {
        return Ok(with_date);
    }

    // Try with random hex suffix
    let seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.subsec_nanos());
    let hex_suffix = format!("{seed:06x}");
    let with_hex = format!("{name}-{hex_suffix}");
    if !branch_exists_remote(&with_hex, repo_root) {
        return Ok(with_hex);
    }

    Err(PatchError::BranchCollision {
        name: name.to_string(),
    })
}

fn branch_exists_remote(name: &str, repo_root: &Path) -> bool {
    let refspec = format!("refs/heads/{name}");
    let output = Command::new("git")
        .args(["ls-remote", "origin", &refspec])
        .current_dir(repo_root)
        .output()
        .ok();
    output.is_some_and(|o| o.status.success() && !o.stdout.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_git_version_parse_valid() {
        assert!(parse_git_version_str("git version 2.39.2\n").is_ok());
        assert!(parse_git_version_str("git version 2.43.0\n").is_ok());
    }

    #[test]
    fn test_git_version_parse_apple_git() {
        assert!(parse_git_version_str("git version 2.46.2 (Apple Git-139)\n").is_ok());
    }

    #[test]
    fn test_git_version_too_old() {
        let err = parse_git_version_str("git version 2.38.0\n");
        assert!(matches!(err, Err(PatchError::GitTooOld { .. })));
    }

    #[test]
    fn test_validate_patch_paths_safe() {
        let diff = "+++ b/src/main.rs\n--- a/src/main.rs\n";
        assert!(validate_patch_paths(diff).is_ok());
    }

    #[test]
    fn test_validate_patch_paths_traversal() {
        let diff = "+++ b/../etc/passwd\n";
        let err = validate_patch_paths(diff);
        assert!(matches!(err, Err(PatchError::PathTraversal { .. })));
    }

    #[test]
    fn test_validate_patch_paths_absolute() {
        let diff = "+++ b//etc/shadow\n";
        let err = validate_patch_paths(diff);
        assert!(matches!(err, Err(PatchError::PathTraversal { .. })));
    }

    #[test]
    fn test_validate_patch_paths_symlink_mode() {
        let diff = "new file mode 120000\n";
        let err = validate_patch_paths(diff);
        assert!(matches!(err, Err(PatchError::SymlinkMode { .. })));
    }

    #[test]
    fn test_slugify_basic() {
        // "Fix login bug" -> lowercase -> "fix-login-bug" -> detect "fix-" prefix -> "fix/login-bug"
        assert_eq!(slugify_title("Fix login bug"), "fix/login-bug");
    }

    #[test]
    fn test_slugify_conventional_prefix() {
        assert_eq!(slugify_title("fix: add retry logic"), "fix/add-retry-logic");
    }

    #[test]
    fn test_slugify_truncation() {
        let long_title = "feat: this is a very long title that exceeds sixty characters limit here";
        let result = slugify_title(long_title);
        assert!(result.len() <= 60, "slug too long: {result}");
    }
}