1use super::error::RemoteError;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::Duration;
6use tempfile::{NamedTempFile, TempDir};
7
8pub struct ClonedRepo {
10 pub path: PathBuf,
12 pub url: String,
14 pub git_ref: String,
16 pub commit_sha: Option<String>,
18 _temp_dir: TempDir,
20}
21
22impl ClonedRepo {
23 pub fn path(&self) -> &Path {
25 &self.path
26 }
27}
28
29pub struct GitCloner {
31 auth_token: Option<String>,
33 timeout_secs: u64,
35 max_size_mb: u64,
37}
38
39impl Default for GitCloner {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl GitCloner {
46 pub fn new() -> Self {
48 Self {
49 auth_token: None,
50 timeout_secs: 300, max_size_mb: 0, }
53 }
54
55 pub fn with_auth_token(mut self, token: Option<String>) -> Self {
57 self.auth_token = token;
58 self
59 }
60
61 pub fn with_timeout(mut self, secs: u64) -> Self {
63 self.timeout_secs = secs;
64 self
65 }
66
67 pub fn with_max_size(mut self, mb: u64) -> Self {
69 self.max_size_mb = mb;
70 self
71 }
72
73 pub fn clone(&self, url: &str, git_ref: &str) -> Result<ClonedRepo, RemoteError> {
82 self.validate_url(url)?;
84
85 self.check_git_available()?;
87
88 let temp_dir = TempDir::new().map_err(|e| RemoteError::TempDir(e.to_string()))?;
90 let repo_path = temp_dir.path().to_path_buf();
91
92 self.execute_clone(url, &repo_path, git_ref)?;
94
95 let commit_sha = self.get_commit_sha(&repo_path).ok();
97
98 Ok(ClonedRepo {
99 path: repo_path,
100 url: url.to_string(),
101 git_ref: git_ref.to_string(),
102 commit_sha,
103 _temp_dir: temp_dir,
104 })
105 }
106
107 fn validate_url(&self, url: &str) -> Result<(), RemoteError> {
109 if !url.starts_with("https://") && !url.starts_with("git@") {
111 return Err(RemoteError::InvalidUrl(format!(
112 "URL must start with https:// or git@: {}",
113 url
114 )));
115 }
116
117 if url.starts_with("https://github.com/") || url.starts_with("git@github.com:") {
119 return Ok(());
121 }
122
123 if url.starts_with("https://") {
125 return Ok(());
126 }
127
128 Err(RemoteError::InvalidUrl(format!(
129 "Unsupported URL format: {}",
130 url
131 )))
132 }
133
134 fn check_git_available(&self) -> Result<(), RemoteError> {
136 Command::new("git")
137 .arg("--version")
138 .output()
139 .map_err(|_| RemoteError::GitNotFound)?;
140 Ok(())
141 }
142
143 fn create_askpass_script(&self) -> Result<Option<NamedTempFile>, RemoteError> {
149 let Some(ref token) = self.auth_token else {
150 return Ok(None);
151 };
152
153 let mut script = NamedTempFile::new().map_err(|e| RemoteError::TempDir(e.to_string()))?;
154
155 writeln!(script, "#!/bin/sh").map_err(|e| RemoteError::TempDir(e.to_string()))?;
158 writeln!(script, "echo '{}'", token.replace('\'', "'\"'\"'"))
159 .map_err(|e| RemoteError::TempDir(e.to_string()))?;
160
161 #[cfg(unix)]
163 {
164 use std::os::unix::fs::PermissionsExt;
165 let path = script.path();
166 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
167 .map_err(|e| RemoteError::TempDir(e.to_string()))?;
168 }
169
170 Ok(Some(script))
171 }
172
173 fn sanitize_error_message(&self, message: &str) -> String {
175 let mut sanitized = message.to_string();
176
177 if let Some(ref token) = self.auth_token {
179 sanitized = sanitized.replace(token, "[REDACTED]");
180 }
181
182 let token_pattern = regex::Regex::new(r"https://[^@\s]+@")
185 .unwrap_or_else(|_| regex::Regex::new("^$").unwrap());
186 sanitized = token_pattern
187 .replace_all(&sanitized, "https://[REDACTED]@")
188 .to_string();
189
190 let bearer_pattern =
192 regex::Regex::new(r"Bearer\s+\S+").unwrap_or_else(|_| regex::Regex::new("^$").unwrap());
193 sanitized = bearer_pattern
194 .replace_all(&sanitized, "Bearer [REDACTED]")
195 .to_string();
196
197 sanitized
198 }
199
200 fn execute_clone(&self, url: &str, path: &Path, git_ref: &str) -> Result<(), RemoteError> {
202 let askpass_script = self.create_askpass_script()?;
204
205 let mut cmd = Command::new("git");
207
208 cmd.env("GIT_TEMPLATE_DIR", "");
210
211 if let Some(ref script) = askpass_script {
213 cmd.env("GIT_ASKPASS", script.path());
214 cmd.env("GIT_TERMINAL_PROMPT", "0");
216 }
217
218 cmd.args([
220 "clone",
221 "--depth",
222 "1",
223 "--single-branch",
224 "--no-tags",
225 "-c",
226 "core.hooksPath=/dev/null",
227 "-c",
228 "advice.detachedHead=false",
229 ]);
230
231 if git_ref != "HEAD" && !git_ref.is_empty() {
233 cmd.args(["--branch", git_ref]);
234 }
235
236 cmd.arg(url);
237 cmd.arg(path);
238
239 cmd.stdout(Stdio::piped());
241 cmd.stderr(Stdio::piped());
242
243 let mut child = cmd.spawn().map_err(|e| RemoteError::CloneFailed {
244 url: url.to_string(),
245 message: self.sanitize_error_message(&e.to_string()),
246 })?;
247
248 let timeout = Duration::from_secs(self.timeout_secs);
250 let start = std::time::Instant::now();
251
252 loop {
253 match child.try_wait() {
254 Ok(Some(status)) => {
255 let output =
257 child
258 .wait_with_output()
259 .map_err(|e| RemoteError::CloneFailed {
260 url: url.to_string(),
261 message: self.sanitize_error_message(&e.to_string()),
262 })?;
263
264 if !status.success() {
265 let stderr = String::from_utf8_lossy(&output.stderr);
266 let sanitized_stderr = self.sanitize_error_message(&stderr);
267
268 if stderr.contains("Repository not found") || stderr.contains("404") {
270 return Err(RemoteError::NotFound(url.to_string()));
271 }
272
273 if stderr.contains("Authentication failed")
274 || stderr.contains("could not read Username")
275 {
276 return Err(RemoteError::AuthRequired(url.to_string()));
277 }
278
279 return Err(RemoteError::CloneFailed {
280 url: url.to_string(),
281 message: sanitized_stderr,
282 });
283 }
284
285 return Ok(());
286 }
287 Ok(None) => {
288 if start.elapsed() > timeout {
290 let _ = child.kill();
292 return Err(RemoteError::CloneFailed {
293 url: url.to_string(),
294 message: format!("Clone timed out after {} seconds", self.timeout_secs),
295 });
296 }
297 std::thread::sleep(Duration::from_millis(100));
299 }
300 Err(e) => {
301 return Err(RemoteError::CloneFailed {
302 url: url.to_string(),
303 message: self.sanitize_error_message(&e.to_string()),
304 });
305 }
306 }
307 }
308 }
309
310 fn get_commit_sha(&self, path: &Path) -> Result<String, RemoteError> {
312 let output = Command::new("git")
313 .args(["rev-parse", "HEAD"])
314 .current_dir(path)
315 .output()
316 .map_err(|e| RemoteError::CloneFailed {
317 url: "".to_string(),
318 message: e.to_string(),
319 })?;
320
321 if output.status.success() {
322 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
323 } else {
324 Err(RemoteError::CloneFailed {
325 url: "".to_string(),
326 message: "Failed to get commit SHA".to_string(),
327 })
328 }
329 }
330}
331
332pub fn parse_github_url(url: &str) -> Option<(String, String)> {
334 if url.starts_with("https://github.com/") {
336 let path = url.trim_start_matches("https://github.com/");
337 let path = path.trim_end_matches(".git");
338 let parts: Vec<&str> = path.split('/').collect();
339 if parts.len() >= 2 {
340 return Some((parts[0].to_string(), parts[1].to_string()));
341 }
342 }
343
344 if url.starts_with("git@github.com:") {
346 let path = url.trim_start_matches("git@github.com:");
347 let path = path.trim_end_matches(".git");
348 let parts: Vec<&str> = path.split('/').collect();
349 if parts.len() >= 2 {
350 return Some((parts[0].to_string(), parts[1].to_string()));
351 }
352 }
353
354 None
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_parse_github_url_https() {
363 let result = parse_github_url("https://github.com/owner/repo");
364 assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
365
366 let result = parse_github_url("https://github.com/owner/repo.git");
367 assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
368 }
369
370 #[test]
371 fn test_parse_github_url_ssh() {
372 let result = parse_github_url("git@github.com:owner/repo.git");
373 assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
374 }
375
376 #[test]
377 fn test_parse_github_url_invalid() {
378 assert!(parse_github_url("https://gitlab.com/owner/repo").is_none());
379 assert!(parse_github_url("not-a-url").is_none());
380 }
381
382 #[test]
383 fn test_validate_url_https() {
384 let cloner = GitCloner::new();
385 assert!(cloner.validate_url("https://github.com/owner/repo").is_ok());
386 assert!(cloner.validate_url("https://example.com/repo").is_ok());
387 }
388
389 #[test]
390 fn test_validate_url_invalid() {
391 let cloner = GitCloner::new();
392 assert!(cloner.validate_url("http://github.com/owner/repo").is_err());
393 assert!(cloner.validate_url("ftp://github.com/owner/repo").is_err());
394 }
395
396 #[test]
397 fn test_sanitize_error_message() {
398 let cloner = GitCloner::new().with_auth_token(Some("ghp_secret123".to_string()));
399
400 let msg = "failed with ghp_secret123 in message";
402 assert_eq!(
403 cloner.sanitize_error_message(msg),
404 "failed with [REDACTED] in message"
405 );
406
407 let msg = "failed: https://token123@github.com/repo";
409 assert!(cloner.sanitize_error_message(msg).contains("[REDACTED]"));
410 assert!(!cloner.sanitize_error_message(msg).contains("token123"));
411 }
412
413 #[test]
414 fn test_sanitize_error_message_no_token() {
415 let cloner = GitCloner::new();
416
417 let msg = "failed: https://sometoken@github.com/repo";
419 let sanitized = cloner.sanitize_error_message(msg);
420 assert!(sanitized.contains("[REDACTED]"));
421 }
422
423 #[test]
424 fn test_sanitize_bearer_token() {
425 let cloner = GitCloner::new();
426
427 let msg = "Authorization: Bearer ghp_secret123456";
428 let sanitized = cloner.sanitize_error_message(msg);
429 assert!(!sanitized.contains("ghp_secret123456"));
430 assert!(sanitized.contains("[REDACTED]"));
431 }
432
433 #[cfg(unix)]
434 #[test]
435 fn test_create_askpass_script() {
436 let cloner = GitCloner::new().with_auth_token(Some("test_token".to_string()));
437 let script = cloner.create_askpass_script().unwrap();
438
439 assert!(script.is_some());
440 let script = script.unwrap();
441
442 let path = script.path();
444 assert!(path.exists());
445
446 let metadata = std::fs::metadata(path).unwrap();
447 use std::os::unix::fs::PermissionsExt;
448 assert_eq!(metadata.permissions().mode() & 0o700, 0o700);
449 }
450
451 #[test]
452 fn test_create_askpass_script_no_token() {
453 let cloner = GitCloner::new();
454 let script = cloner.create_askpass_script().unwrap();
455 assert!(script.is_none());
456 }
457
458 #[test]
459 fn test_cloner_with_timeout() {
460 let cloner = GitCloner::new().with_timeout(60);
461 assert_eq!(cloner.timeout_secs, 60);
462 }
463
464 #[test]
465 fn test_cloner_with_max_size() {
466 let cloner = GitCloner::new().with_max_size(100);
467 assert_eq!(cloner.max_size_mb, 100);
468 }
469}