sal_git/
git_executor.rs

1use redis::Cmd;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::error::Error;
5use std::fmt;
6use std::process::{Command, Output};
7
8// Simple redis client functionality with configurable connection
9fn execute_redis_command(cmd: &mut redis::Cmd) -> redis::RedisResult<String> {
10    // Get Redis URL from environment variables with fallback
11    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
19/// Get Redis URL from environment variables with secure fallbacks
20fn 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
26/// Mask sensitive information in Redis URL for logging
27fn 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// Define a custom error type for GitExecutor operations
47#[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
58// Implement Display for GitExecutorError
59impl 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
77// Implement Error trait for GitExecutorError
78impl 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
89// From implementations for error conversion
90impl 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// Status enum for GitConfig
109#[derive(Debug, Serialize, Deserialize, PartialEq)]
110pub enum GitConfigStatus {
111    #[serde(rename = "error")]
112    Error,
113    #[serde(rename = "ok")]
114    Ok,
115}
116
117// Auth configuration for a specific git server
118#[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// Main configuration structure from Redis
127#[derive(Debug, Serialize, Deserialize)]
128pub struct GitConfig {
129    pub status: GitConfigStatus,
130    pub auth: HashMap<String, GitServerAuth>,
131}
132
133// GitExecutor struct
134pub struct GitExecutor {
135    config: Option<GitConfig>,
136}
137
138impl GitExecutor {
139    // Create a new GitExecutor
140    pub fn new() -> Self {
141        GitExecutor { config: None }
142    }
143
144    // Initialize by loading configuration from Redis
145    pub fn init(&mut self) -> Result<(), GitExecutorError> {
146        // Try to load config from Redis
147        match self.load_config_from_redis() {
148            Ok(config) => {
149                self.config = Some(config);
150                Ok(())
151            }
152            Err(e) => {
153                // If Redis error, we'll proceed without config
154                // This is not a fatal error as we might use default git behavior
155                log::warn!("Failed to load git config from Redis: {}", e);
156                self.config = None;
157                Ok(())
158            }
159        }
160    }
161
162    // Load configuration from Redis
163    fn load_config_from_redis(&self) -> Result<GitConfig, GitExecutorError> {
164        // Create Redis command to get the herocontext:git key
165        let mut cmd = Cmd::new();
166        cmd.arg("GET").arg("herocontext:git");
167
168        // Execute the command
169        let result: redis::RedisResult<String> = execute_redis_command(&mut cmd);
170
171        match result {
172            Ok(json_str) => {
173                // Parse the JSON string into GitConfig
174                let config: GitConfig = serde_json::from_str(&json_str)?;
175
176                // Validate the config
177                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    // Check if SSH agent is loaded
190    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    // Get authentication configuration for a git URL
200    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    // Validate authentication configuration
211    fn validate_auth_config(&self, auth: &GitServerAuth) -> Result<(), GitExecutorError> {
212        // Rule: If sshagent is true, other fields should be empty
213        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            // Check if SSH agent is actually loaded
220            if !self.is_ssh_agent_loaded() {
221                return Err(GitExecutorError::SshAgentNotLoaded);
222            }
223        }
224
225        // Rule: If key is set, other fields should be empty
226        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        // Rule: If username is set, password should be set and other fields empty
236        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    // Execute a git command with authentication
253    pub fn execute(&self, args: &[&str]) -> Result<Output, GitExecutorError> {
254        // Extract the git URL if this is a command that needs authentication
255        let url_arg = self.extract_git_url_from_args(args);
256
257        // If we have a URL and authentication config, use it
258        if let Some(url) = url_arg {
259            if let Some(auth) = self.get_auth_for_url(&url) {
260                // Validate the authentication configuration
261                self.validate_auth_config(auth)?;
262
263                // Execute with the appropriate authentication method
264                return self.execute_with_auth(args, auth);
265            }
266        }
267
268        // No special authentication needed, execute normally
269        self.execute_git_command(args)
270    }
271
272    // Extract git URL from command arguments
273    fn extract_git_url_from_args<'a>(&self, args: &[&'a str]) -> Option<&'a str> {
274        // Commands that might contain a git URL
275        if args.contains(&"clone")
276            || args.contains(&"fetch")
277            || args.contains(&"pull")
278            || args.contains(&"push")
279        {
280            // The URL is typically the last argument for clone, or after remote for others
281            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                    // For these commands, the URL might be specified as a remote name
287                    // We'd need more complex logic to resolve remote names to URLs
288                    // For now, we'll just return None
289                    return None;
290                }
291            }
292        }
293        None
294    }
295
296    // Execute git command with authentication
297    fn execute_with_auth(
298        &self,
299        args: &[&str],
300        auth: &GitServerAuth,
301    ) -> Result<Output, GitExecutorError> {
302        // Handle different authentication methods
303        if let Some(true) = auth.sshagent {
304            // Use SSH agent (already validated that it's loaded)
305            self.execute_git_command(args)
306        } else if let Some(key) = &auth.key {
307            // Use SSH key
308            self.execute_with_ssh_key(args, key)
309        } else if let Some(username) = &auth.username {
310            // Use username/password
311            if let Some(password) = &auth.password {
312                self.execute_with_credentials(args, username, password)
313            } else {
314                // This should never happen due to validation
315                Err(GitExecutorError::AuthenticationError(
316                    "Password is required when username is set".to_string(),
317                ))
318            }
319        } else {
320            // No authentication method specified, use default
321            self.execute_git_command(args)
322        }
323    }
324
325    // Execute git command with SSH key
326    fn execute_with_ssh_key(&self, args: &[&str], key: &str) -> Result<Output, GitExecutorError> {
327        // Create a command with GIT_SSH_COMMAND to specify the key
328        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    // Execute git command with username/password using secure credential helper
345    fn execute_with_credentials(
346        &self,
347        args: &[&str],
348        username: &str,
349        password: &str,
350    ) -> Result<Output, GitExecutorError> {
351        // Use git credential helper approach for security
352        // Create a temporary credential helper script
353        let temp_dir = std::env::temp_dir();
354        let helper_script = temp_dir.join(format!("git_helper_{}", std::process::id()));
355
356        // Create credential helper script content
357        let script_content = format!(
358            "#!/bin/bash\necho username={}\necho password={}\n",
359            username, password
360        );
361
362        // Write the helper script
363        std::fs::write(&helper_script, script_content)
364            .map_err(|e| GitExecutorError::CommandExecutionError(e))?;
365
366        // Make it executable
367        #[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        // Execute git command with credential helper
379        let mut command = Command::new("git");
380        command.args(args);
381        command.env("GIT_ASKPASS", &helper_script);
382        command.env("GIT_TERMINAL_PROMPT", "0"); // Disable terminal prompts
383
384        log::debug!("Executing git command with credential helper");
385        let output = command.output()?;
386
387        // Clean up the temporary helper script
388        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    // Basic git command execution
400    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
415// Implement Default for GitExecutor
416impl Default for GitExecutor {
417    fn default() -> Self {
418        Self::new()
419    }
420}