Skip to main content

runtimo_core/capabilities/
git_exec.rs

1//! GitExec capability — git operations with state tracking and undo support.
2//!
3//! Provides git operations (clone, pull, commit, revert, clean, status) with:
4//! - State tracking (commit sha, branch, remote URL)
5//! - Backup-before-mutate for undo support
6//! - WAL logging for audit trail
7//! - Path traversal protection
8//! - Timeout enforcement on all git subprocesses
9//! - URL validation (HTTPS/SSH only, SSRF blocking)
10//! - Credential sanitization from output
11//! - Secret file detection for git add
12//! - Telemetry and process tracking before/after execution
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use runtimo_core::capabilities::GitExec;
18//! use runtimo_core::capability::{Capability, Context};
19//! use serde_json::json;
20//! use std::path::PathBuf;
21//!
22//! let cap = GitExec::new(PathBuf::from("/tmp/backups"));
23//! let result = cap.execute(
24//!     &json!({"operation": "clone", "url": "https://github.com/user/repo.git", "path": "/tmp/repo"}),
25//!     &Context { dry_run: false, job_id: "job1".into(), working_dir: PathBuf::from("/tmp") }
26//! ).unwrap();
27//!
28//! assert!(result.success);
29//! ```
30
31use crate::backup::BackupManager;
32use crate::capability::{Capability, Context, Output};
33use crate::processes::ProcessSnapshot;
34use crate::telemetry::Telemetry;
35use crate::validation::path::{validate_path, PathContext};
36use crate::{Error, Result};
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use std::path::{Path, PathBuf};
40use std::process::Command;
41use std::time::{Duration, Instant};
42
43/// Arguments for the [`GitExec`] capability.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GitExecArgs {
46    /// Git operation to perform (clone, pull, commit, revert, clean, status).
47    pub operation: String,
48    /// Repository URL (for clone/pull).
49    pub url: Option<String>,
50    /// Local path to repository (for clone/commit/revert/clean/status).
51    pub path: Option<String>,
52    /// Branch name (for checkout/clone).
53    pub branch: Option<String>,
54    /// Commit message (for commit).
55    pub message: Option<String>,
56    /// Files to commit (for commit).
57    pub files: Option<Vec<String>>,
58    /// Commit SHA to revert to (for revert).
59    pub commit_sha: Option<String>,
60    /// Timeout in seconds (default: 300).
61    pub timeout_secs: Option<u64>,
62}
63
64/// Git state before/after operation.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct GitState {
67    /// Current commit SHA (HEAD).
68    pub commit_sha: Option<String>,
69    /// Current branch name.
70    pub branch: Option<String>,
71    /// Remote URL (origin).
72    pub remote_url: Option<String>,
73    /// Repository path.
74    pub repo_path: String,
75    /// Working directory status (clean/dirty).
76    pub is_clean: bool,
77}
78
79/// Known secret file patterns to exclude from `git add -A`.
80const SECRET_PATTERNS: &[&str] = &[
81    ".env",
82    ".env.local",
83    ".env.production",
84    ".env.staging",
85    "credentials.json",
86    "credentials.yml",
87    "credentials.yaml",
88    "secrets.json",
89    "secrets.yml",
90    "secrets.yaml",
91    ".ssh/id_rsa",
92    ".ssh/id_ed25519",
93    ".ssh/id_dsa",
94    "id_rsa",
95    "id_ed25519",
96    "id_dsa",
97    ".npmrc",
98    ".pypirc",
99    ".docker/config.json",
100    "token",
101    "api_key",
102    "api_secret",
103    ".aws/credentials",
104    ".azure/credentials",
105    "keystore.jks",
106    "keystore.p12",
107];
108
109/// Maximum number of untracked files allowed for `git clean -fd`.
110const MAX_CLEAN_FILES: usize = 1000;
111
112/// Capability that executes git operations with full state tracking.
113///
114/// Supports clone, pull, commit, revert, clean, and status operations.
115/// Creates backups before mutable operations for undo support.
116pub struct GitExec {
117    backup_mgr: BackupManager,
118}
119
120impl GitExec {
121    /// Creates a new GitExec capability with the given backup directory.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`crate::Error::BackupError`] if the backup
126    /// directory cannot be created.
127    pub fn new(backup_dir: PathBuf) -> Result<Self> {
128        Ok(Self {
129            backup_mgr: BackupManager::new(backup_dir)?,
130        })
131    }
132
133    /// Runs a git command with timeout enforcement and returns the output.
134    fn run_git_with_timeout(repo_path: &Path, args: &[&str], timeout_secs: u64) -> Result<String> {
135        let mut child = Command::new("git")
136            .current_dir(repo_path)
137            .args(args)
138            .stdin(std::process::Stdio::null())
139            .spawn()
140            .map_err(|e| Error::ExecutionFailed(format!("git command failed: {}", e)))?;
141
142        let timeout = Duration::from_secs(timeout_secs);
143        let start = Instant::now();
144
145        loop {
146            match child.try_wait() {
147                Ok(Some(status)) => {
148                    let output = child
149                        .wait_with_output()
150                        .map_err(|e| Error::ExecutionFailed(format!("git wait failed: {}", e)))?;
151                    if !status.success() {
152                        let stderr = String::from_utf8_lossy(&output.stderr);
153                        return Err(Error::ExecutionFailed(format!(
154                            "git {}: {}",
155                            args.join(" "),
156                            stderr.trim()
157                        )));
158                    }
159                    return Ok(String::from_utf8_lossy(&output.stdout).to_string());
160                }
161                Ok(None) => {
162                    if start.elapsed() > timeout {
163                        let _ = child.kill();
164                        let _ = child.wait();
165                        return Err(Error::ExecutionFailed(format!(
166                            "git {} timed out after {}s",
167                            args.join(" "),
168                            timeout_secs
169                        )));
170                    }
171                    std::thread::sleep(Duration::from_millis(50));
172                }
173                Err(e) => {
174                    let _ = child.kill();
175                    let _ = child.wait();
176                    return Err(Error::ExecutionFailed(format!("git wait error: {}", e)));
177                }
178            }
179        }
180    }
181
182    /// Runs a git command (backwards-compatible, uses default timeout).
183    #[allow(dead_code)]
184    fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
185        Self::run_git_with_timeout(repo_path, args, 300)
186    }
187
188    /// Checks if the working tree is clean (no uncommitted changes).
189    fn is_working_tree_clean(repo_path: &Path) -> bool {
190        let output = Command::new("git")
191            .current_dir(repo_path)
192            .args(["status", "--porcelain"])
193            .output();
194
195        match output {
196            Ok(out) => out.stdout.is_empty() && out.stderr.is_empty(),
197            Err(_) => false,
198        }
199    }
200
201    /// Validates a git URL format. Blocks http:// (MITM risk) and SSRF patterns.
202    fn validate_url(url: &str) -> Result<()> {
203        let is_https = url.starts_with("https://");
204        let is_ssh = url.starts_with("git@");
205        if !is_https && !is_ssh {
206            return Err(Error::SchemaValidationFailed(format!(
207                "Insecure or unsupported URL scheme: {} (must use https:// or git@ SSH)",
208                url
209            )));
210        }
211
212        if is_https {
213            if let Some(host_part) = url
214                .strip_prefix("https://")
215                .and_then(|s| s.split('/').next())
216            {
217                let host = host_part.split(':').next().unwrap_or(host_part);
218                if Self::is_ssrf_host(host) {
219                    return Err(Error::SchemaValidationFailed(format!(
220                        "SSRF blocked: URL targets internal/metadata address: {}",
221                        url
222                    )));
223                }
224            }
225        }
226
227        Ok(())
228    }
229
230    /// Checks if a host is a known SSRF target (cloud metadata, localhost, link-local).
231    fn is_ssrf_host(host: &str) -> bool {
232        let lower = host.to_lowercase();
233        let ssrf_indicators = [
234            "169.254.169.254",
235            "169.254.",
236            "127.0.0.1",
237            "localhost",
238            "0.0.0.0",
239            "::1",
240            "10.0.0.",
241            "10.0.1.",
242            "10.0.2.",
243            "10.0.3.",
244            "172.16.",
245            "172.17.",
246            "172.18.",
247            "172.19.",
248            "172.20.",
249            "172.21.",
250            "172.22.",
251            "172.23.",
252            "172.24.",
253            "172.25.",
254            "172.26.",
255            "172.27.",
256            "172.28.",
257            "172.29.",
258            "172.30.",
259            "172.31.",
260            "192.168.",
261            "metadata.google",
262            "metadata.azure",
263            "instance-data",
264            "100.100.100.200",
265            "[::1]",
266            "[fe80:",
267        ];
268        ssrf_indicators
269            .iter()
270            .any(|indicator| lower.contains(indicator))
271    }
272
273    /// Validates a branch name.
274    fn validate_branch_name(branch: &str) -> Result<()> {
275        if branch.is_empty() {
276            return Err(Error::SchemaValidationFailed("Branch name is empty".into()));
277        }
278        if branch.contains("..") || branch.contains("@{") {
279            return Err(Error::SchemaValidationFailed(format!(
280                "Invalid branch name: {}",
281                branch
282            )));
283        }
284        Ok(())
285    }
286
287    /// Validates a commit SHA.
288    fn validate_commit_sha(sha: &str) -> Result<()> {
289        if sha.len() < 7 || sha.len() > 40 {
290            return Err(Error::SchemaValidationFailed(format!(
291                "Invalid commit SHA length: {}",
292                sha
293            )));
294        }
295        if !sha.chars().all(|c| c.is_ascii_hexdigit()) {
296            return Err(Error::SchemaValidationFailed(format!(
297                "Invalid commit SHA: {}",
298                sha
299            )));
300        }
301        Ok(())
302    }
303
304    /// Sanitizes credentials from a URL string (redacts user:pass@).
305    /// Preserves SSH-style URLs (git@host:path) unchanged.
306    #[allow(clippy::arithmetic_side_effects)]
307    fn sanitize_url(url: &str) -> String {
308        if url.starts_with("git@") {
309            return url.to_string();
310        }
311        if let Some(at_pos) = url.find('@') {
312            if let Some(scheme_end) = url.find("://") {
313                let scheme = &url[..scheme_end + 3];
314                let after_at = &url[at_pos + 1..];
315                return format!("{}***@{}", scheme, after_at);
316            }
317            return format!("***@{}", &url[at_pos + 1..]);
318        }
319        url.to_string()
320    }
321
322    /// Sanitizes git output to remove credential leakage.
323    fn sanitize_output(output: &str) -> String {
324        let re_pattern = |line: &str| -> String {
325            let mut result = String::new();
326            let mut chars = line.chars().peekable();
327            while let Some(c) = chars.next() {
328                if c == ':' && chars.peek() == Some(&'/') && chars.clone().nth(1) == Some('/') {
329                    result.push_str("://");
330                    chars.next();
331                    chars.next();
332                    let mut user_pass = String::new();
333                    let mut found_at = false;
334                    for nc in chars.by_ref() {
335                        if nc == '@' {
336                            found_at = true;
337                            break;
338                        }
339                        user_pass.push(nc);
340                    }
341                    if found_at && !user_pass.is_empty() {
342                        result.push_str("***@");
343                    } else {
344                        result.push_str(&user_pass);
345                        if found_at {
346                            result.push('@');
347                        }
348                    }
349                } else {
350                    result.push(c);
351                }
352            }
353            result
354        };
355
356        output
357            .lines()
358            .map(re_pattern)
359            .collect::<Vec<_>>()
360            .join("\n")
361    }
362
363    /// Checks if a file path looks like a secret file that should not be committed.
364    fn is_secret_file(path: &str) -> bool {
365        let lower = path.to_lowercase();
366        SECRET_PATTERNS.iter().any(|pattern| {
367            lower == *pattern
368                || lower.ends_with(&format!("/{}", pattern))
369                || lower.contains(&format!("/{}/", pattern))
370        })
371    }
372
373    /// Validates a file path for git add (no traversal, no secrets).
374    fn validate_add_file(file: &str, repo_path: &Path) -> Result<()> {
375        if file.contains("..") {
376            return Err(Error::SchemaValidationFailed(format!(
377                "Path traversal in file path: {}",
378                file
379            )));
380        }
381        if Self::is_secret_file(file) {
382            return Err(Error::SchemaValidationFailed(format!(
383                "Secret file detected, refusing to add: {}",
384                file
385            )));
386        }
387        let full_path = repo_path.join(file);
388        if full_path.exists() {
389            let canonical = full_path.canonicalize().map_err(|e| {
390                Error::SchemaValidationFailed(format!("Cannot resolve file {}: {}", file, e))
391            })?;
392            let canonical_repo = repo_path.canonicalize().map_err(|e| {
393                Error::SchemaValidationFailed(format!("Cannot resolve repo: {}", e))
394            })?;
395            if !canonical.starts_with(&canonical_repo) {
396                return Err(Error::SchemaValidationFailed(format!(
397                    "File {} escapes repository boundary",
398                    file
399                )));
400            }
401        }
402        Ok(())
403    }
404
405    /// Checks available disk space (returns free bytes, or None if unknown).
406    fn disk_free_bytes(path: &Path) -> Option<u64> {
407        let output = Command::new("df")
408            .arg("--output=avail")
409            .arg("-B1")
410            .arg(path)
411            .output()
412            .ok()?;
413        if output.status.success() {
414            let stdout = String::from_utf8_lossy(&output.stdout);
415            stdout.lines().nth(1)?.trim().parse().ok()
416        } else {
417            None
418        }
419    }
420
421    /// Counts untracked files that would be removed by git clean -fd.
422    fn count_untracked_files(repo_path: &Path, timeout_secs: u64) -> Result<usize> {
423        let output = Self::run_git_with_timeout(
424            repo_path,
425            &["ls-files", "--others", "--exclude-standard"],
426            timeout_secs,
427        )?;
428        Ok(output.lines().filter(|l| !l.is_empty()).count())
429    }
430
431    /// Sanitizes a commit message (strips control chars, ensures non-empty).
432    fn sanitize_commit_message(msg: &str) -> Result<String> {
433        let sanitized: String = msg
434            .chars()
435            .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
436            .collect();
437        let trimmed = sanitized.trim();
438        if trimmed.is_empty() {
439            return Err(Error::SchemaValidationFailed(
440                "Commit message is empty after sanitization".into(),
441            ));
442        }
443        Ok(trimmed.to_string())
444    }
445
446    /// Creates a backup unconditionally before any mutating operation.
447    fn backup_before_mutation(&self, repo_path: &Path, job_id: &str) -> Result<PathBuf> {
448        self.backup_mgr.create_backup(repo_path, job_id)
449    }
450
451    /// Captures the current git state for a repository.
452    fn capture_state(repo_path: &Path, timeout_secs: u64) -> Result<GitState> {
453        let commit_sha =
454            Self::run_git_with_timeout(repo_path, &["rev-parse", "HEAD"], timeout_secs)
455                .map(|s| s.trim().to_string())
456                .ok();
457
458        let branch = Self::run_git_with_timeout(
459            repo_path,
460            &["rev-parse", "--abbrev-ref", "HEAD"],
461            timeout_secs,
462        )
463        .map(|s| s.trim().to_string())
464        .ok();
465
466        let remote_url =
467            Self::run_git_with_timeout(repo_path, &["remote", "get-url", "origin"], timeout_secs)
468                .ok()
469                .and_then(|s| {
470                    let trimmed = s.trim().to_string();
471                    let sanitized = Self::sanitize_url(&trimmed);
472                    if sanitized.is_empty() {
473                        None
474                    } else {
475                        Some(sanitized)
476                    }
477                });
478
479        let is_clean = Self::is_working_tree_clean(repo_path);
480
481        Ok(GitState {
482            commit_sha,
483            branch,
484            remote_url,
485            repo_path: repo_path.to_string_lossy().to_string(),
486            is_clean,
487        })
488    }
489
490    /// Executes git clone operation.
491    fn op_clone(&self, args: &GitExecArgs, ctx: &Context) -> Result<Output> {
492        let _ = self;
493        let timeout_secs = args.timeout_secs.unwrap_or(300);
494        let url = args
495            .url
496            .as_ref()
497            .ok_or_else(|| Error::ExecutionFailed("URL required for clone".into()))?;
498        let path = args
499            .path
500            .as_ref()
501            .ok_or_else(|| Error::ExecutionFailed("Path required for clone".into()))?;
502
503        Self::validate_url(url)?;
504
505        let path = Path::new(path);
506        if path.exists() {
507            return Err(Error::ExecutionFailed(format!(
508                "Path already exists: {}",
509                path.display()
510            )));
511        }
512
513        if let Some(free) = Self::disk_free_bytes(path.parent().unwrap_or_else(|| Path::new("/"))) {
514            if free < 100 * 1024 * 1024 {
515                return Err(Error::ExecutionFailed(
516                    "Insufficient disk space for clone (need at least 100MB)".into(),
517                ));
518            }
519        }
520
521        if ctx.dry_run {
522            return Ok(Output {
523                success: true,
524                data: serde_json::json!({
525                    "operation": "clone",
526                    "url": Self::sanitize_url(url),
527                    "path": path.display().to_string(),
528                    "dry_run": true
529                }),
530                message: Some(format!(
531                    "DRY RUN: would clone {} to {}",
532                    Self::sanitize_url(url),
533                    path.display()
534                )),
535            });
536        }
537
538        if let Some(parent) = path.parent() {
539            std::fs::create_dir_all(parent).map_err(|e| {
540                Error::ExecutionFailed(format!("mkdir {}: {}", parent.display(), e))
541            })?;
542        }
543
544        let mut cmd = Command::new("git");
545        cmd.arg("clone").arg(url).arg(path);
546
547        if let Some(branch) = &args.branch {
548            cmd.arg("-b").arg(branch);
549        }
550
551        let mut child = cmd
552            .stdin(std::process::Stdio::null())
553            .spawn()
554            .map_err(|e| Error::ExecutionFailed(format!("git clone spawn failed: {}", e)))?;
555
556        let timeout = Duration::from_secs(timeout_secs);
557        let start = Instant::now();
558        let status = loop {
559            match child.try_wait() {
560                Ok(Some(s)) => break s,
561                Ok(None) => {
562                    if start.elapsed() > timeout {
563                        let _ = child.kill();
564                        let _ = child.wait();
565                        return Err(Error::ExecutionFailed(format!(
566                            "git clone timed out after {}s",
567                            timeout_secs
568                        )));
569                    }
570                    std::thread::sleep(Duration::from_millis(100));
571                }
572                Err(e) => {
573                    let _ = child.kill();
574                    let _ = child.wait();
575                    return Err(Error::ExecutionFailed(format!(
576                        "git clone wait error: {}",
577                        e
578                    )));
579                }
580            }
581        };
582
583        if !status.success() {
584            return Err(Error::ExecutionFailed(
585                "git clone failed (see stderr)".into(),
586            ));
587        }
588
589        let state = Self::capture_state(path, timeout_secs)?;
590
591        Ok(Output {
592            success: true,
593            data: serde_json::json!({
594                "operation": "clone",
595                "url": Self::sanitize_url(url),
596                "path": path.display().to_string(),
597                "commit_sha": state.commit_sha,
598                "branch": state.branch,
599                "remote_url": state.remote_url
600            }),
601            message: Some(format!(
602                "Cloned {} to {}",
603                Self::sanitize_url(url),
604                path.display()
605            )),
606        })
607    }
608
609    /// Executes git pull operation.
610    fn op_pull(&self, args: &GitExecArgs, ctx: &Context, repo_path: &Path) -> Result<Output> {
611        let timeout_secs = args.timeout_secs.unwrap_or(300);
612
613        if !repo_path.exists() {
614            return Err(Error::ExecutionFailed(format!(
615                "Repository not found: {}",
616                repo_path.display()
617            )));
618        }
619
620        let state_before = Self::capture_state(repo_path, timeout_secs)?;
621
622        if ctx.dry_run {
623            return Ok(Output {
624                success: true,
625                data: serde_json::json!({
626                    "operation": "pull",
627                    "path": repo_path.display().to_string(),
628                    "dry_run": true
629                }),
630                message: Some("DRY RUN: would pull".into()),
631            });
632        }
633
634        let backup_path = Some(self.backup_before_mutation(repo_path, &ctx.job_id)?);
635
636        let output = Self::run_git_with_timeout(repo_path, &["pull", "--rebase"], timeout_secs)
637            .map_err(|e| Error::ExecutionFailed(format!("git pull failed: {}", e)))?;
638
639        let state_after = Self::capture_state(repo_path, timeout_secs)?;
640
641        Ok(Output {
642            success: true,
643            data: serde_json::json!({
644                "operation": "pull",
645                "path": repo_path.display().to_string(),
646                "commit_sha_before": state_before.commit_sha,
647                "commit_sha_after": state_after.commit_sha,
648                "branch": state_after.branch,
649                "backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
650                "git_output": Self::sanitize_output(&output)
651            }),
652            message: Some("Pulled successfully".into()),
653        })
654    }
655
656    /// Executes git commit operation.
657    fn op_commit(&self, args: &GitExecArgs, ctx: &Context, repo_path: &Path) -> Result<Output> {
658        let timeout_secs = args.timeout_secs.unwrap_or(300);
659
660        if !repo_path.exists() {
661            return Err(Error::ExecutionFailed(format!(
662                "Repository not found: {}",
663                repo_path.display()
664            )));
665        }
666
667        let message = args
668            .message
669            .as_ref()
670            .ok_or_else(|| Error::ExecutionFailed("Commit message required".into()))?;
671        let message = Self::sanitize_commit_message(message)?;
672
673        let state_before = Self::capture_state(repo_path, timeout_secs)?;
674
675        if ctx.dry_run {
676            return Ok(Output {
677                success: true,
678                data: serde_json::json!({
679                    "operation": "commit",
680                    "path": repo_path.display().to_string(),
681                    "message": &message,
682                    "dry_run": true
683                }),
684                message: Some("DRY RUN: would commit".into()),
685            });
686        }
687
688        let backup_path = Some(self.backup_before_mutation(repo_path, &ctx.job_id)?);
689
690        if let Some(files) = &args.files {
691            for file in files {
692                Self::validate_add_file(file, repo_path)?;
693                let output = Self::run_git_with_timeout(repo_path, &["add", file], timeout_secs)
694                    .map_err(|e| Error::ExecutionFailed(format!("git add failed: {}", e)))?;
695                let _ = output;
696            }
697        } else {
698            let untracked = Self::run_git_with_timeout(
699                repo_path,
700                &["ls-files", "--others", "--exclude-standard"],
701                timeout_secs,
702            )?;
703            for line in untracked.lines() {
704                let file = line.trim();
705                if file.is_empty() {
706                    continue;
707                }
708                if Self::is_secret_file(file) {
709                    eprintln!("[runtimo] Skipping secret file from git add: {}", file);
710                    continue;
711                }
712                Self::run_git_with_timeout(repo_path, &["add", file], timeout_secs).map_err(
713                    |e| Error::ExecutionFailed(format!("git add {} failed: {}", file, e)),
714                )?;
715            }
716        }
717
718        let output =
719            Self::run_git_with_timeout(repo_path, &["commit", "-m", &message], timeout_secs)
720                .map_err(|e| Error::ExecutionFailed(format!("git commit failed: {}", e)))?;
721        let _ = output;
722
723        let state_after = Self::capture_state(repo_path, timeout_secs)?;
724
725        Ok(Output {
726            success: true,
727            data: serde_json::json!({
728                "operation": "commit",
729                "path": repo_path.display().to_string(),
730                "message": message,
731                "commit_sha_before": state_before.commit_sha,
732                "commit_sha_after": state_after.commit_sha,
733                "branch": state_after.branch,
734                "backup_path": backup_path.map(|p| p.to_string_lossy().to_string())
735            }),
736            message: Some(format!("Committed: {}", message)),
737        })
738    }
739
740    /// Executes git revert operation.
741    fn op_revert(&self, args: &GitExecArgs, ctx: &Context, repo_path: &Path) -> Result<Output> {
742        let timeout_secs = args.timeout_secs.unwrap_or(300);
743
744        if !repo_path.exists() {
745            return Err(Error::ExecutionFailed(format!(
746                "Repository not found: {}",
747                repo_path.display()
748            )));
749        }
750
751        let commit_sha = args
752            .commit_sha
753            .as_ref()
754            .ok_or_else(|| Error::ExecutionFailed("Commit SHA required for revert".into()))?;
755
756        Self::validate_commit_sha(commit_sha)?;
757
758        let state_before = Self::capture_state(repo_path, timeout_secs)?;
759
760        if ctx.dry_run {
761            return Ok(Output {
762                success: true,
763                data: serde_json::json!({
764                    "operation": "revert",
765                    "path": repo_path.display().to_string(),
766                    "commit_sha": commit_sha,
767                    "dry_run": true
768                }),
769                message: Some(format!("DRY RUN: would revert {}", commit_sha)),
770            });
771        }
772
773        let backup_path = Some(self.backup_before_mutation(repo_path, &ctx.job_id)?);
774
775        let output = Self::run_git_with_timeout(
776            repo_path,
777            &["revert", "--no-edit", commit_sha],
778            timeout_secs,
779        )
780        .map_err(|e| Error::ExecutionFailed(format!("git revert failed: {}", e)))?;
781        let _ = output;
782
783        let state_after = Self::capture_state(repo_path, timeout_secs)?;
784
785        Ok(Output {
786            success: true,
787            data: serde_json::json!({
788                "operation": "revert",
789                "path": repo_path.display().to_string(),
790                "commit_sha": commit_sha,
791                "commit_sha_before": state_before.commit_sha,
792                "commit_sha_after": state_after.commit_sha,
793                "branch": state_after.branch,
794                "backup_path": backup_path.map(|p| p.to_string_lossy().to_string())
795            }),
796            message: Some(format!("Reverted {}", commit_sha)),
797        })
798    }
799
800    /// Executes git clean operation.
801    fn op_clean(&self, args: &GitExecArgs, ctx: &Context, repo_path: &Path) -> Result<Output> {
802        let timeout_secs = args.timeout_secs.unwrap_or(300);
803
804        if !repo_path.exists() {
805            return Err(Error::ExecutionFailed(format!(
806                "Repository not found: {}",
807                repo_path.display()
808            )));
809        }
810
811        let state_before = Self::capture_state(repo_path, timeout_secs)?;
812
813        if ctx.dry_run {
814            let untracked_count = Self::count_untracked_files(repo_path, timeout_secs).unwrap_or(0);
815            let preview =
816                Self::run_git_with_timeout(repo_path, &["clean", "-fd", "--dry-run"], timeout_secs)
817                    .map(|s| Self::sanitize_output(&s))
818                    .unwrap_or_default();
819            return Ok(Output {
820                success: true,
821                data: serde_json::json!({
822                    "operation": "clean",
823                    "path": repo_path.display().to_string(),
824                    "dry_run": true,
825                    "untracked_count": untracked_count,
826                    "preview": preview
827                }),
828                message: Some(format!(
829                    "DRY RUN: would clean {} untracked files",
830                    untracked_count
831                )),
832            });
833        }
834
835        let untracked_count = Self::count_untracked_files(repo_path, timeout_secs)?;
836        if untracked_count > MAX_CLEAN_FILES {
837            return Err(Error::ExecutionFailed(format!(
838                "Too many untracked files to clean safely: {} (limit: {})",
839                untracked_count, MAX_CLEAN_FILES
840            )));
841        }
842
843        let backup_path = Some(self.backup_before_mutation(repo_path, &ctx.job_id)?);
844
845        let output = Self::run_git_with_timeout(repo_path, &["clean", "-fd"], timeout_secs)
846            .map_err(|e| Error::ExecutionFailed(format!("git clean failed: {}", e)))?;
847        let _ = output;
848
849        let state_after = Self::capture_state(repo_path, timeout_secs)?;
850
851        Ok(Output {
852            success: true,
853            data: serde_json::json!({
854                "operation": "clean",
855                "path": repo_path.display().to_string(),
856                "was_clean": state_before.is_clean,
857                "is_clean": state_after.is_clean,
858                "untracked_files_removed": untracked_count,
859                "backup_path": backup_path.map(|p| p.to_string_lossy().to_string())
860            }),
861            message: Some(format!("Cleaned {} untracked files", untracked_count)),
862        })
863    }
864
865    /// Executes git status operation.
866    #[allow(clippy::unused_self, clippy::used_underscore_binding)]
867    fn op_status(&self, _args: &GitExecArgs, _ctx: &Context, repo_path: &Path) -> Result<Output> {
868        let timeout_secs = _args.timeout_secs.unwrap_or(300);
869
870        if !repo_path.exists() {
871            return Err(Error::ExecutionFailed(format!(
872                "Repository not found: {}",
873                repo_path.display()
874            )));
875        }
876
877        let state = Self::capture_state(repo_path, timeout_secs)?;
878
879        let status_output =
880            Self::run_git_with_timeout(repo_path, &["status", "--porcelain"], timeout_secs)
881                .unwrap_or_default();
882
883        let branch = state.branch.clone().unwrap_or_default();
884        let remote_url = state.remote_url.clone().unwrap_or_default();
885
886        Ok(Output {
887            success: true,
888            data: serde_json::json!({
889                "operation": "status",
890                "path": repo_path.display().to_string(),
891                "branch": branch,
892                "remote_url": remote_url,
893                "commit_sha": state.commit_sha,
894                "is_clean": state.is_clean,
895                "status": status_output
896            }),
897            message: Some(format!(
898                "On branch {}: {}",
899                branch,
900                if state.is_clean { "clean" } else { "dirty" }
901            )),
902        })
903    }
904}
905
906impl Capability for GitExec {
907    fn name(&self) -> &'static str {
908        "GitExec"
909    }
910
911    fn description(&self) -> &'static str {
912        "git ops: clone|pull|commit|revert|clean|status. state tracking, timeout, undo."
913    }
914
915    fn schema(&self) -> Value {
916        serde_json::json!({
917            "type": "object",
918            "properties": {
919                "operation": { "type": "string", "enum": ["clone", "pull", "commit", "revert", "clean", "status"] },
920                "url": { "type": "string" },
921                "path": { "type": "string" },
922                "branch": { "type": "string" },
923                "message": { "type": "string" },
924                "files": { "type": "array", "items": { "type": "string" } },
925                "commit_sha": { "type": "string" },
926                "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 600 }
927            },
928            "required": ["operation"]
929        })
930    }
931
932    fn validate(&self, args: &Value) -> Result<()> {
933        let args: GitExecArgs = serde_json::from_value(args.clone())
934            .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
935
936        let valid_ops = ["clone", "pull", "commit", "revert", "clean", "status"];
937        if !valid_ops.contains(&args.operation.as_str()) {
938            return Err(Error::SchemaValidationFailed(format!(
939                "Invalid operation: {}. Must be one of: {}",
940                args.operation,
941                valid_ops.join(", ")
942            )));
943        }
944
945        if args.operation == "clone" {
946            if let Some(url) = &args.url {
947                Self::validate_url(url)?;
948            } else {
949                return Err(Error::SchemaValidationFailed(
950                    "URL required for clone".into(),
951                ));
952            }
953            if let Some(path) = &args.path {
954                let ctx = PathContext {
955                    require_exists: false,
956                    require_file: false,
957                    ..Default::default()
958                };
959                validate_path(path, &ctx).map_err(Error::SchemaValidationFailed)?;
960            }
961        }
962
963        if args.operation != "clone" {
964            if let Some(path) = &args.path {
965                let ctx = PathContext {
966                    require_exists: true,
967                    require_file: false,
968                    ..Default::default()
969                };
970                validate_path(path, &ctx).map_err(Error::SchemaValidationFailed)?;
971            }
972        }
973
974        if let Some(branch) = &args.branch {
975            Self::validate_branch_name(branch)?;
976        }
977
978        if let Some(sha) = &args.commit_sha {
979            Self::validate_commit_sha(sha)?;
980        }
981
982        Ok(())
983    }
984
985    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
986        let args: GitExecArgs = serde_json::from_value(args.clone())
987            .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
988
989        let telemetry_before = Telemetry::capture();
990        let process_before = ProcessSnapshot::capture();
991
992        let result = match args.operation.as_str() {
993            "clone" => self.op_clone(&args, ctx),
994            "pull" => {
995                let path = args
996                    .path
997                    .as_ref()
998                    .ok_or_else(|| Error::ExecutionFailed("Path required for pull".into()))?;
999                self.op_pull(&args, ctx, Path::new(path))
1000            }
1001            "commit" => {
1002                let path = args
1003                    .path
1004                    .as_ref()
1005                    .ok_or_else(|| Error::ExecutionFailed("Path required for commit".into()))?;
1006                self.op_commit(&args, ctx, Path::new(path))
1007            }
1008            "revert" => {
1009                let path = args
1010                    .path
1011                    .as_ref()
1012                    .ok_or_else(|| Error::ExecutionFailed("Path required for revert".into()))?;
1013                self.op_revert(&args, ctx, Path::new(path))
1014            }
1015            "clean" => {
1016                let path = args
1017                    .path
1018                    .as_ref()
1019                    .ok_or_else(|| Error::ExecutionFailed("Path required for clean".into()))?;
1020                self.op_clean(&args, ctx, Path::new(path))
1021            }
1022            "status" => {
1023                let path = args
1024                    .path
1025                    .as_ref()
1026                    .ok_or_else(|| Error::ExecutionFailed("Path required for status".into()))?;
1027                self.op_status(&args, ctx, Path::new(path))
1028            }
1029            _ => Err(Error::ExecutionFailed(format!(
1030                "Unknown operation: {}",
1031                args.operation
1032            ))),
1033        };
1034
1035        let telemetry_after = Telemetry::capture();
1036        let process_after = ProcessSnapshot::capture();
1037
1038        let mut output = result?;
1039        if let Some(obj) = output.data.as_object_mut() {
1040            obj.insert(
1041                "telemetry_before".to_string(),
1042                serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
1043            );
1044            obj.insert(
1045                "telemetry_after".to_string(),
1046                serde_json::to_value(&telemetry_after).unwrap_or(Value::Null),
1047            );
1048            obj.insert(
1049                "process_before".to_string(),
1050                serde_json::to_value(&process_before.summary).unwrap_or(Value::Null),
1051            );
1052            obj.insert(
1053                "process_after".to_string(),
1054                serde_json::to_value(&process_after.summary).unwrap_or(Value::Null),
1055            );
1056        }
1057
1058        Ok(output)
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use crate::capability::Capability;
1066
1067    fn test_backup_dir() -> PathBuf {
1068        std::env::temp_dir().join("runtimo_git_test")
1069    }
1070
1071    #[test]
1072    fn validates_git_url_https_only() {
1073        assert!(GitExec::validate_url("https://github.com/user/repo.git").is_ok());
1074        assert!(GitExec::validate_url("git@github.com:user/repo.git").is_ok());
1075
1076        assert!(GitExec::validate_url("http://example.com/repo.git").is_err());
1077        assert!(GitExec::validate_url("not-a-url").is_err());
1078        assert!(GitExec::validate_url("").is_err());
1079
1080        std::fs::remove_dir_all(test_backup_dir()).ok();
1081    }
1082
1083    #[test]
1084    fn blocks_ssrf_urls() {
1085        assert!(GitExec::validate_url("https://169.254.169.254/latest/meta-data/").is_err());
1086        assert!(GitExec::validate_url("https://127.0.0.1/repo.git").is_err());
1087        assert!(GitExec::validate_url("https://localhost/repo.git").is_err());
1088        assert!(GitExec::validate_url("https://192.168.1.1/repo.git").is_err());
1089        assert!(GitExec::validate_url("https://metadata.google.internal/computeMetadata").is_err());
1090
1091        std::fs::remove_dir_all(test_backup_dir()).ok();
1092    }
1093
1094    #[test]
1095    fn sanitizes_credentials_from_url() {
1096        assert_eq!(
1097            GitExec::sanitize_url("https://user:pass@github.com/repo.git"),
1098            "https://***@github.com/repo.git"
1099        );
1100        assert_eq!(
1101            GitExec::sanitize_url("https://github.com/repo.git"),
1102            "https://github.com/repo.git"
1103        );
1104        assert_eq!(
1105            GitExec::sanitize_url("git@github.com:user/repo.git"),
1106            "git@github.com:user/repo.git"
1107        );
1108    }
1109
1110    #[test]
1111    fn detects_secret_files() {
1112        assert!(GitExec::is_secret_file(".env"));
1113        assert!(GitExec::is_secret_file("config/.env"));
1114        assert!(GitExec::is_secret_file("credentials.json"));
1115        assert!(GitExec::is_secret_file(".ssh/id_rsa"));
1116        assert!(GitExec::is_secret_file("src/.env.local"));
1117
1118        assert!(!GitExec::is_secret_file("main.rs"));
1119        assert!(!GitExec::is_secret_file("Cargo.toml"));
1120        assert!(!GitExec::is_secret_file("README.md"));
1121    }
1122
1123    #[test]
1124    fn validates_branch_name() {
1125        assert!(GitExec::validate_branch_name("main").is_ok());
1126        assert!(GitExec::validate_branch_name("feature/my-branch").is_ok());
1127        assert!(GitExec::validate_branch_name("v1.0").is_ok());
1128
1129        assert!(GitExec::validate_branch_name("").is_err());
1130        assert!(GitExec::validate_branch_name("bad..name").is_err());
1131        assert!(GitExec::validate_branch_name("@{..}").is_err());
1132    }
1133
1134    #[test]
1135    fn validates_commit_sha() {
1136        assert!(GitExec::validate_commit_sha("abc1234").is_ok());
1137        assert!(GitExec::validate_commit_sha("a1b2c3d4").is_ok());
1138        assert!(GitExec::validate_commit_sha("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0").is_ok());
1139
1140        assert!(GitExec::validate_commit_sha("abc123").is_err());
1141        assert!(GitExec::validate_commit_sha("").is_err());
1142        assert!(GitExec::validate_commit_sha("xyz123").is_err());
1143    }
1144
1145    #[allow(clippy::expect_used)]
1146    #[test]
1147    fn rejects_path_traversal() {
1148        let cap = GitExec::new(test_backup_dir()).expect("Failed to create GitExec");
1149
1150        let err = cap
1151            .validate(&serde_json::json!({
1152                "operation": "clone",
1153                "url": "https://github.com/user/repo.git",
1154                "path": "../../../etc/passwd"
1155            }))
1156            .unwrap_err();
1157
1158        assert!(err.to_string().contains("traversal"));
1159        std::fs::remove_dir_all(test_backup_dir()).ok();
1160    }
1161
1162    #[allow(clippy::expect_used)]
1163    #[test]
1164    fn rejects_invalid_operation() {
1165        let cap = GitExec::new(test_backup_dir()).expect("Failed to create GitExec");
1166
1167        let err = cap
1168            .validate(&serde_json::json!({
1169                "operation": "invalid_op"
1170            }))
1171            .unwrap_err();
1172
1173        assert!(err.to_string().contains("Invalid operation"));
1174        std::fs::remove_dir_all(test_backup_dir()).ok();
1175    }
1176
1177    #[test]
1178    #[allow(clippy::expect_used)]
1179    fn status_on_nonexistent_repo() {
1180        let cap = GitExec::new(test_backup_dir()).expect("Failed to create GitExec");
1181
1182        let result = cap.execute(
1183            &serde_json::json!({
1184                "operation": "status",
1185                "path": "/tmp/nonexistent_repo"
1186            }),
1187            &Context {
1188                dry_run: false,
1189                job_id: "test".into(),
1190                working_dir: std::env::temp_dir(),
1191            },
1192        );
1193
1194        assert!(result.is_err());
1195        std::fs::remove_dir_all(test_backup_dir()).ok();
1196    }
1197
1198    #[test]
1199    fn sanitizes_commit_message() {
1200        assert!(GitExec::sanitize_commit_message("valid commit").is_ok());
1201        assert!(GitExec::sanitize_commit_message("  trimmed  ").is_ok());
1202        assert!(GitExec::sanitize_commit_message("").is_err());
1203        assert!(GitExec::sanitize_commit_message("   ").is_err());
1204        let result = GitExec::sanitize_commit_message("hello\x00world").unwrap();
1205        assert!(!result.contains('\x00'));
1206    }
1207
1208    #[test]
1209    fn timeout_enforced_on_git_command() {
1210        let tmp = std::env::temp_dir().join("runtimo_git_timeout_test");
1211        std::fs::create_dir_all(&tmp).ok();
1212        Command::new("git")
1213            .arg("init")
1214            .current_dir(&tmp)
1215            .output()
1216            .ok();
1217
1218        let result = GitExec::run_git_with_timeout(
1219            &tmp,
1220            &["clone", "https://10.255.255.1/nonexistent.git"],
1221            1,
1222        );
1223        assert!(result.is_err());
1224        assert!(result.unwrap_err().to_string().contains("timed out"));
1225
1226        std::fs::remove_dir_all(&tmp).ok();
1227    }
1228}