1use redis::Cmd;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::error::Error;
5use std::fmt;
6use std::process::{Command, Output};
7
8fn execute_redis_command(cmd: &mut redis::Cmd) -> redis::RedisResult<String> {
10 let redis_url = get_redis_url();
12 log::debug!("Connecting to Redis at: {}", mask_redis_url(&redis_url));
13
14 let client = redis::Client::open(redis_url)?;
15 let mut con = client.get_connection()?;
16 cmd.query(&mut con)
17}
18
19fn get_redis_url() -> String {
21 std::env::var("REDIS_URL")
22 .or_else(|_| std::env::var("SAL_REDIS_URL"))
23 .unwrap_or_else(|_| "redis://127.0.0.1/".to_string())
24}
25
26fn mask_redis_url(url: &str) -> String {
28 if let Ok(parsed) = url::Url::parse(url) {
29 if parsed.password().is_some() {
30 format!(
31 "{}://{}:***@{}:{}/{}",
32 parsed.scheme(),
33 parsed.username(),
34 parsed.host_str().unwrap_or("unknown"),
35 parsed.port().unwrap_or(6379),
36 parsed.path().trim_start_matches('/')
37 )
38 } else {
39 url.to_string()
40 }
41 } else {
42 "redis://***masked***".to_string()
43 }
44}
45
46#[derive(Debug)]
48pub enum GitExecutorError {
49 GitCommandFailed(String),
50 CommandExecutionError(std::io::Error),
51 RedisError(redis::RedisError),
52 JsonError(serde_json::Error),
53 AuthenticationError(String),
54 SshAgentNotLoaded,
55 InvalidAuthConfig(String),
56}
57
58impl fmt::Display for GitExecutorError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 GitExecutorError::GitCommandFailed(e) => write!(f, "Git command failed: {}", e),
63 GitExecutorError::CommandExecutionError(e) => {
64 write!(f, "Command execution error: {}", e)
65 }
66 GitExecutorError::RedisError(e) => write!(f, "Redis error: {}", e),
67 GitExecutorError::JsonError(e) => write!(f, "JSON error: {}", e),
68 GitExecutorError::AuthenticationError(e) => write!(f, "Authentication error: {}", e),
69 GitExecutorError::SshAgentNotLoaded => write!(f, "SSH agent is not loaded"),
70 GitExecutorError::InvalidAuthConfig(e) => {
71 write!(f, "Invalid authentication configuration: {}", e)
72 }
73 }
74 }
75}
76
77impl Error for GitExecutorError {
79 fn source(&self) -> Option<&(dyn Error + 'static)> {
80 match self {
81 GitExecutorError::CommandExecutionError(e) => Some(e),
82 GitExecutorError::RedisError(e) => Some(e),
83 GitExecutorError::JsonError(e) => Some(e),
84 _ => None,
85 }
86 }
87}
88
89impl From<redis::RedisError> for GitExecutorError {
91 fn from(err: redis::RedisError) -> Self {
92 GitExecutorError::RedisError(err)
93 }
94}
95
96impl From<serde_json::Error> for GitExecutorError {
97 fn from(err: serde_json::Error) -> Self {
98 GitExecutorError::JsonError(err)
99 }
100}
101
102impl From<std::io::Error> for GitExecutorError {
103 fn from(err: std::io::Error) -> Self {
104 GitExecutorError::CommandExecutionError(err)
105 }
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq)]
110pub enum GitConfigStatus {
111 #[serde(rename = "error")]
112 Error,
113 #[serde(rename = "ok")]
114 Ok,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
119pub struct GitServerAuth {
120 pub sshagent: Option<bool>,
121 pub key: Option<String>,
122 pub username: Option<String>,
123 pub password: Option<String>,
124}
125
126#[derive(Debug, Serialize, Deserialize)]
128pub struct GitConfig {
129 pub status: GitConfigStatus,
130 pub auth: HashMap<String, GitServerAuth>,
131}
132
133pub struct GitExecutor {
135 config: Option<GitConfig>,
136}
137
138impl GitExecutor {
139 pub fn new() -> Self {
141 GitExecutor { config: None }
142 }
143
144 pub fn init(&mut self) -> Result<(), GitExecutorError> {
146 match self.load_config_from_redis() {
148 Ok(config) => {
149 self.config = Some(config);
150 Ok(())
151 }
152 Err(e) => {
153 log::warn!("Failed to load git config from Redis: {}", e);
156 self.config = None;
157 Ok(())
158 }
159 }
160 }
161
162 fn load_config_from_redis(&self) -> Result<GitConfig, GitExecutorError> {
164 let mut cmd = Cmd::new();
166 cmd.arg("GET").arg("herocontext:git");
167
168 let result: redis::RedisResult<String> = execute_redis_command(&mut cmd);
170
171 match result {
172 Ok(json_str) => {
173 let config: GitConfig = serde_json::from_str(&json_str)?;
175
176 if config.status == GitConfigStatus::Error {
178 return Err(GitExecutorError::InvalidAuthConfig(
179 "Config status is error".to_string(),
180 ));
181 }
182
183 Ok(config)
184 }
185 Err(e) => Err(GitExecutorError::RedisError(e)),
186 }
187 }
188
189 fn is_ssh_agent_loaded(&self) -> bool {
191 let output = Command::new("ssh-add").arg("-l").output();
192
193 match output {
194 Ok(output) => output.status.success() && !output.stdout.is_empty(),
195 Err(_) => false,
196 }
197 }
198
199 fn get_auth_for_url(&self, url: &str) -> Option<&GitServerAuth> {
201 if let Some(config) = &self.config {
202 let (server, _, _) = crate::parse_git_url(url);
203 if !server.is_empty() {
204 return config.auth.get(&server);
205 }
206 }
207 None
208 }
209
210 fn validate_auth_config(&self, auth: &GitServerAuth) -> Result<(), GitExecutorError> {
212 if let Some(true) = auth.sshagent {
214 if auth.key.is_some() || auth.username.is_some() || auth.password.is_some() {
215 return Err(GitExecutorError::InvalidAuthConfig(
216 "When sshagent is true, key, username, and password must be empty".to_string(),
217 ));
218 }
219 if !self.is_ssh_agent_loaded() {
221 return Err(GitExecutorError::SshAgentNotLoaded);
222 }
223 }
224
225 if let Some(_) = &auth.key {
227 if auth.sshagent.unwrap_or(false) || auth.username.is_some() || auth.password.is_some()
228 {
229 return Err(GitExecutorError::InvalidAuthConfig(
230 "When key is set, sshagent, username, and password must be empty".to_string(),
231 ));
232 }
233 }
234
235 if let Some(_) = &auth.username {
237 if auth.sshagent.unwrap_or(false) || auth.key.is_some() {
238 return Err(GitExecutorError::InvalidAuthConfig(
239 "When username is set, sshagent and key must be empty".to_string(),
240 ));
241 }
242 if auth.password.is_none() {
243 return Err(GitExecutorError::InvalidAuthConfig(
244 "When username is set, password must also be set".to_string(),
245 ));
246 }
247 }
248
249 Ok(())
250 }
251
252 pub fn execute(&self, args: &[&str]) -> Result<Output, GitExecutorError> {
254 let url_arg = self.extract_git_url_from_args(args);
256
257 if let Some(url) = url_arg {
259 if let Some(auth) = self.get_auth_for_url(&url) {
260 self.validate_auth_config(auth)?;
262
263 return self.execute_with_auth(args, auth);
265 }
266 }
267
268 self.execute_git_command(args)
270 }
271
272 fn extract_git_url_from_args<'a>(&self, args: &[&'a str]) -> Option<&'a str> {
274 if args.contains(&"clone")
276 || args.contains(&"fetch")
277 || args.contains(&"pull")
278 || args.contains(&"push")
279 {
280 for (i, &arg) in args.iter().enumerate() {
282 if arg == "clone" && i + 1 < args.len() {
283 return Some(args[i + 1]);
284 }
285 if (arg == "fetch" || arg == "pull" || arg == "push") && i + 1 < args.len() {
286 return None;
290 }
291 }
292 }
293 None
294 }
295
296 fn execute_with_auth(
298 &self,
299 args: &[&str],
300 auth: &GitServerAuth,
301 ) -> Result<Output, GitExecutorError> {
302 if let Some(true) = auth.sshagent {
304 self.execute_git_command(args)
306 } else if let Some(key) = &auth.key {
307 self.execute_with_ssh_key(args, key)
309 } else if let Some(username) = &auth.username {
310 if let Some(password) = &auth.password {
312 self.execute_with_credentials(args, username, password)
313 } else {
314 Err(GitExecutorError::AuthenticationError(
316 "Password is required when username is set".to_string(),
317 ))
318 }
319 } else {
320 self.execute_git_command(args)
322 }
323 }
324
325 fn execute_with_ssh_key(&self, args: &[&str], key: &str) -> Result<Output, GitExecutorError> {
327 let ssh_command = format!("ssh -i {} -o IdentitiesOnly=yes", key);
329
330 let mut command = Command::new("git");
331 command.env("GIT_SSH_COMMAND", ssh_command);
332 command.args(args);
333
334 let output = command.output()?;
335
336 if output.status.success() {
337 Ok(output)
338 } else {
339 let error = String::from_utf8_lossy(&output.stderr);
340 Err(GitExecutorError::GitCommandFailed(error.to_string()))
341 }
342 }
343
344 fn execute_with_credentials(
346 &self,
347 args: &[&str],
348 username: &str,
349 password: &str,
350 ) -> Result<Output, GitExecutorError> {
351 let temp_dir = std::env::temp_dir();
354 let helper_script = temp_dir.join(format!("git_helper_{}", std::process::id()));
355
356 let script_content = format!(
358 "#!/bin/bash\necho username={}\necho password={}\n",
359 username, password
360 );
361
362 std::fs::write(&helper_script, script_content)
364 .map_err(|e| GitExecutorError::CommandExecutionError(e))?;
365
366 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 let mut perms = std::fs::metadata(&helper_script)
371 .map_err(|e| GitExecutorError::CommandExecutionError(e))?
372 .permissions();
373 perms.set_mode(0o755);
374 std::fs::set_permissions(&helper_script, perms)
375 .map_err(|e| GitExecutorError::CommandExecutionError(e))?;
376 }
377
378 let mut command = Command::new("git");
380 command.args(args);
381 command.env("GIT_ASKPASS", &helper_script);
382 command.env("GIT_TERMINAL_PROMPT", "0"); log::debug!("Executing git command with credential helper");
385 let output = command.output()?;
386
387 let _ = std::fs::remove_file(&helper_script);
389
390 if output.status.success() {
391 Ok(output)
392 } else {
393 let error = String::from_utf8_lossy(&output.stderr);
394 log::error!("Git command failed: {}", error);
395 Err(GitExecutorError::GitCommandFailed(error.to_string()))
396 }
397 }
398
399 fn execute_git_command(&self, args: &[&str]) -> Result<Output, GitExecutorError> {
401 let mut command = Command::new("git");
402 command.args(args);
403
404 let output = command.output()?;
405
406 if output.status.success() {
407 Ok(output)
408 } else {
409 let error = String::from_utf8_lossy(&output.stderr);
410 Err(GitExecutorError::GitCommandFailed(error.to_string()))
411 }
412 }
413}
414
415impl Default for GitExecutor {
417 fn default() -> Self {
418 Self::new()
419 }
420}