ggen_core/security/
command.rs1use ggen_utils::error::{Error, Result};
9use std::path::Path;
10use std::process::{Command, Output};
11
12#[derive(Debug, thiserror::Error)]
14pub enum CommandError {
15 #[error("Invalid command: {0}")]
16 InvalidCommand(String),
17
18 #[error("Command not allowed: {0}")]
19 NotAllowed(String),
20
21 #[error("Command execution failed: {0}")]
22 ExecutionFailed(String),
23
24 #[error("Invalid argument: {0}")]
25 InvalidArgument(String),
26
27 #[error("Command output invalid UTF-8")]
28 InvalidUtf8,
29}
30
31impl From<CommandError> for Error {
32 fn from(err: CommandError) -> Self {
33 Error::new(&err.to_string())
34 }
35}
36
37#[derive(Clone)]
66pub struct SafeCommand {
67 program: String,
68 args: Vec<String>,
69 current_dir: Option<String>,
70}
71
72impl SafeCommand {
73 const ALLOWED_COMMANDS: &'static [&'static str] =
75 &["git", "cargo", "npm", "node", "rustc", "rustup"];
76
77 const DANGEROUS_CHARS: &'static [char] = &[
79 ';', '|', '&', '$', '`', '\n', '\r', '<', '>', '(', ')', '{', '}',
80 ];
81
82 pub fn new(program: &str) -> Result<Self> {
90 if program.is_empty() {
92 return Err(CommandError::InvalidCommand("Empty command".to_string()).into());
93 }
94
95 if program.chars().any(|c| Self::DANGEROUS_CHARS.contains(&c)) {
97 return Err(CommandError::InvalidCommand(format!(
98 "Command contains dangerous characters: {}",
99 program
100 ))
101 .into());
102 }
103
104 if !Self::ALLOWED_COMMANDS.contains(&program) {
106 return Err(CommandError::NotAllowed(format!(
107 "Command '{}' is not in allowed list",
108 program
109 ))
110 .into());
111 }
112
113 Ok(Self {
114 program: program.to_string(),
115 args: Vec::new(),
116 current_dir: None,
117 })
118 }
119
120 pub fn arg(mut self, arg: &str) -> Result<Self> {
127 if arg.chars().any(|c| Self::DANGEROUS_CHARS.contains(&c)) {
129 return Err(CommandError::InvalidArgument(format!(
130 "Argument contains dangerous characters: {}",
131 arg
132 ))
133 .into());
134 }
135
136 self.args.push(arg.to_string());
137 Ok(self)
138 }
139
140 pub fn args<I, S>(mut self, args: I) -> Result<Self>
142 where
143 I: IntoIterator<Item = S>,
144 S: AsRef<str>,
145 {
146 for arg in args {
147 self = self.arg(arg.as_ref())?;
148 }
149 Ok(self)
150 }
151
152 pub fn current_dir(mut self, dir: &Path) -> Result<Self> {
159 if !dir.exists() {
161 return Err(Error::new(&format!(
162 "Directory does not exist: {}",
163 dir.display()
164 )));
165 }
166
167 if !dir.is_dir() {
168 return Err(Error::new(&format!(
169 "Path is not a directory: {}",
170 dir.display()
171 )));
172 }
173
174 self.current_dir = Some(dir.to_string_lossy().to_string());
176 Ok(self)
177 }
178
179 pub fn execute(self) -> Result<Output> {
187 let mut cmd = Command::new(&self.program);
188
189 for arg in &self.args {
191 cmd.arg(arg);
192 }
193
194 if let Some(dir) = &self.current_dir {
196 cmd.current_dir(dir);
197 }
198
199 let output = cmd
201 .output()
202 .map_err(|e| CommandError::ExecutionFailed(format!("{}: {}", self.program, e)))?;
203
204 Ok(output)
205 }
206
207 pub fn execute_stdout(self) -> Result<String> {
214 let program = self.program.clone();
215 let output = self.execute()?;
216
217 if !output.status.success() {
218 let stderr = String::from_utf8_lossy(&output.stderr);
219 return Err(CommandError::ExecutionFailed(format!("{}: {}", program, stderr)).into());
220 }
221
222 String::from_utf8(output.stdout).map_err(|_| CommandError::InvalidUtf8.into())
223 }
224}
225
226pub struct CommandExecutor;
228
229impl CommandExecutor {
230 pub fn git(args: &[&str]) -> Result<Output> {
232 let mut cmd = SafeCommand::new("git")?;
233 for arg in args {
234 cmd = cmd.arg(arg)?;
235 }
236 cmd.execute()
237 }
238
239 pub fn cargo(args: &[&str]) -> Result<Output> {
241 let mut cmd = SafeCommand::new("cargo")?;
242 for arg in args {
243 cmd = cmd.arg(arg)?;
244 }
245 cmd.execute()
246 }
247
248 pub fn npm(args: &[&str]) -> Result<Output> {
250 let mut cmd = SafeCommand::new("npm")?;
251 for arg in args {
252 cmd = cmd.arg(arg)?;
253 }
254 cmd.execute()
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_safe_command_new_validates_whitelist() {
264 assert!(SafeCommand::new("git").is_ok());
266 assert!(SafeCommand::new("cargo").is_ok());
267
268 assert!(SafeCommand::new("rm").is_err());
270 assert!(SafeCommand::new("sh").is_err());
271 }
272
273 #[test]
274 fn test_safe_command_rejects_dangerous_chars() {
275 assert!(SafeCommand::new("git; rm -rf /").is_err());
277 assert!(SafeCommand::new("git | cat").is_err());
278 assert!(SafeCommand::new("git && ls").is_err());
279
280 let cmd1 = SafeCommand::new("git").unwrap();
282 assert!(cmd1.arg("init; rm -rf /").is_err());
283
284 let cmd2 = SafeCommand::new("git").unwrap();
285 assert!(cmd2.arg("init | cat").is_err());
286 }
287
288 #[test]
289 fn test_safe_command_arg_validation() {
290 let cmd = SafeCommand::new("git").unwrap();
291
292 assert!(cmd.clone().arg("init").is_ok());
294 assert!(cmd.clone().arg("status").is_ok());
295
296 assert!(cmd.clone().arg("init; ls").is_err());
298 assert!(cmd.clone().arg("$(whoami)").is_err());
299 assert!(cmd.clone().arg("`whoami`").is_err());
300 }
301
302 #[test]
303 fn test_command_injection_prevention() {
304 let result = SafeCommand::new("git")
306 .unwrap()
307 .arg("init")
308 .unwrap()
309 .arg("; rm -rf /");
310
311 assert!(result.is_err());
312
313 let result = SafeCommand::new("git; whoami");
315 assert!(result.is_err());
316 }
317
318 #[test]
319 fn test_executor_git() {
320 let result = CommandExecutor::git(&["--version"]);
322 let _ = result;
324 }
325}