1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GitExecArgs {
46 pub operation: String,
48 pub url: Option<String>,
50 pub path: Option<String>,
52 pub branch: Option<String>,
54 pub message: Option<String>,
56 pub files: Option<Vec<String>>,
58 pub commit_sha: Option<String>,
60 pub timeout_secs: Option<u64>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct GitState {
67 pub commit_sha: Option<String>,
69 pub branch: Option<String>,
71 pub remote_url: Option<String>,
73 pub repo_path: String,
75 pub is_clean: bool,
77}
78
79const 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
109const MAX_CLEAN_FILES: usize = 1000;
111
112pub struct GitExec {
117 backup_mgr: BackupManager,
118}
119
120impl GitExec {
121 pub fn new(backup_dir: PathBuf) -> Result<Self> {
128 Ok(Self {
129 backup_mgr: BackupManager::new(backup_dir)?,
130 })
131 }
132
133 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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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}