syncable_cli/analyzer/hadolint/shell/
mod.rs1pub mod shellcheck;
11
12use crate::analyzer::hadolint::parser::instruction::{Arguments, RunArgs};
13
14#[derive(Debug, Clone, Default)]
16pub struct ParsedShell {
17 pub original: String,
19 pub commands: Vec<Command>,
21 pub has_pipes: bool,
23}
24
25impl ParsedShell {
26 pub fn parse(script: &str) -> Self {
28 let original = script.to_string();
29 let commands = extract_commands(script);
30 let has_pipes = script.contains('|');
31
32 Self {
33 original,
34 commands,
35 has_pipes,
36 }
37 }
38
39 pub fn from_run_args(args: &RunArgs) -> Self {
41 match &args.arguments {
42 Arguments::Text(text) => Self::parse(text),
43 Arguments::List(list) => {
44 let script = list.join(" ");
46 Self::parse(&script)
47 }
48 }
49 }
50
51 pub fn any_command<F>(&self, pred: F) -> bool
53 where
54 F: Fn(&Command) -> bool,
55 {
56 self.commands.iter().any(pred)
57 }
58
59 pub fn all_commands<F>(&self, pred: F) -> bool
61 where
62 F: Fn(&Command) -> bool,
63 {
64 self.commands.iter().all(pred)
65 }
66
67 pub fn no_commands<F>(&self, pred: F) -> bool
69 where
70 F: Fn(&Command) -> bool,
71 {
72 !self.any_command(pred)
73 }
74
75 pub fn find_command_names(&self) -> Vec<&str> {
77 self.commands.iter().map(|c| c.name.as_str()).collect()
78 }
79
80 pub fn using_program(&self, prog: &str) -> bool {
82 self.commands.iter().any(|c| c.name == prog)
83 }
84
85 pub fn is_pip_install(&self, cmd: &Command) -> bool {
87 cmd.is_pip_install()
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct Command {
94 pub name: String,
96 pub arguments: Vec<String>,
98 pub flags: Vec<String>,
100}
101
102impl Command {
103 pub fn new(name: impl Into<String>) -> Self {
105 Self {
106 name: name.into(),
107 arguments: Vec::new(),
108 flags: Vec::new(),
109 }
110 }
111
112 pub fn has_args(&self, expected_name: &str, expected_args: &[&str]) -> bool {
114 if self.name != expected_name {
115 return false;
116 }
117 expected_args
118 .iter()
119 .all(|arg| self.arguments.iter().any(|a| a == *arg))
120 }
121
122 pub fn has_any_arg(&self, args: &[&str]) -> bool {
124 args.iter()
125 .any(|arg| self.arguments.iter().any(|a| a == *arg))
126 }
127
128 pub fn has_flag(&self, flag: &str) -> bool {
130 self.flags.iter().any(|f| f == flag)
131 }
132
133 pub fn has_any_flag(&self, flags: &[&str]) -> bool {
135 flags.iter().any(|f| self.has_flag(f))
136 }
137
138 pub fn args_no_flags(&self) -> Vec<&str> {
140 self.arguments
141 .iter()
142 .filter(|a| !a.starts_with('-'))
143 .map(|s| s.as_str())
144 .collect()
145 }
146
147 pub fn get_flag_value(&self, flag: &str) -> Option<&str> {
149 for arg in &self.arguments {
151 if let Some(stripped) = arg.strip_prefix(&format!("--{}=", flag)) {
152 return Some(stripped);
153 }
154 if let Some(stripped) = arg.strip_prefix(&format!("-{}=", flag)) {
155 return Some(stripped);
156 }
157 }
158
159 let mut iter = self.arguments.iter();
161 while let Some(arg) = iter.next() {
162 if arg == &format!("--{}", flag) || arg == &format!("-{}", flag) {
163 return iter.next().map(|s| s.as_str());
164 }
165 }
166
167 None
168 }
169
170 pub fn is_pip_install(&self) -> bool {
172 if (self.name.starts_with("pip") && !self.name.starts_with("pipenv"))
174 && self.arguments.iter().any(|a| a == "install")
175 {
176 return true;
177 }
178
179 if self.name.starts_with("python") {
181 let args: Vec<&str> = self.arguments.iter().map(|s| s.as_str()).collect();
182 if args.windows(3).any(|w| w == ["-m", "pip", "install"]) {
183 return true;
184 }
185 }
186
187 false
188 }
189
190 pub fn is_apt_get_install(&self) -> bool {
192 self.name == "apt-get" && self.arguments.iter().any(|a| a == "install")
193 }
194
195 pub fn is_apk_add(&self) -> bool {
197 self.name == "apk" && self.arguments.iter().any(|a| a == "add")
198 }
199}
200
201fn extract_commands(script: &str) -> Vec<Command> {
203 let mut commands = Vec::new();
204
205 let separators = ["&&", "||", ";", "|", "\n"];
207
208 let mut remaining = script.trim();
209
210 while !remaining.is_empty() {
211 let next_sep = separators
213 .iter()
214 .filter_map(|sep| remaining.find(sep).map(|pos| (pos, sep.len())))
215 .min_by_key(|(pos, _)| *pos);
216
217 let cmd_str = match next_sep {
218 Some((pos, len)) => {
219 let cmd = &remaining[..pos];
220 remaining = &remaining[pos + len..];
221 cmd
222 }
223 None => {
224 let cmd = remaining;
225 remaining = "";
226 cmd
227 }
228 };
229
230 if let Some(cmd) = parse_single_command(cmd_str.trim()) {
232 commands.push(cmd);
233 }
234
235 remaining = remaining.trim_start();
236 }
237
238 commands
239}
240
241fn parse_single_command(cmd_str: &str) -> Option<Command> {
243 let cmd_str = cmd_str.trim();
244 if cmd_str.is_empty() {
245 return None;
246 }
247
248 let cmd_str = cmd_str.trim_start_matches('(').trim_end_matches(')').trim();
250
251 let words: Vec<&str> = shell_words(cmd_str);
253
254 if words.is_empty() {
255 return None;
256 }
257
258 let name = words[0].to_string();
259 let arguments: Vec<String> = words[1..].iter().map(|s| s.to_string()).collect();
260 let flags = extract_flags(&arguments);
261
262 Some(Command {
263 name,
264 arguments,
265 flags,
266 })
267}
268
269fn shell_words(input: &str) -> Vec<&str> {
271 let mut words = Vec::new();
272 let mut in_single_quote = false;
273 let mut in_double_quote = false;
274 let mut word_start = None;
275 let mut escaped = false;
276
277 for (i, c) in input.char_indices() {
278 if escaped {
279 escaped = false;
280 continue;
281 }
282
283 if c == '\\' && !in_single_quote {
284 escaped = true;
285 if word_start.is_none() {
286 word_start = Some(i);
287 }
288 continue;
289 }
290
291 if c == '\'' && !in_double_quote {
292 in_single_quote = !in_single_quote;
293 if word_start.is_none() {
294 word_start = Some(i);
295 }
296 continue;
297 }
298
299 if c == '"' && !in_single_quote {
300 in_double_quote = !in_double_quote;
301 if word_start.is_none() {
302 word_start = Some(i);
303 }
304 continue;
305 }
306
307 if c.is_whitespace() && !in_single_quote && !in_double_quote {
308 if let Some(start) = word_start {
309 let word = &input[start..i];
310 let word = word.trim_matches(|c| c == '\'' || c == '"');
311 if !word.is_empty() {
312 words.push(word);
313 }
314 word_start = None;
315 }
316 } else if word_start.is_none() {
317 word_start = Some(i);
318 }
319 }
320
321 if let Some(start) = word_start {
323 let word = &input[start..];
324 let word = word.trim_matches(|c| c == '\'' || c == '"');
325 if !word.is_empty() {
326 words.push(word);
327 }
328 }
329
330 words
331}
332
333fn extract_flags(arguments: &[String]) -> Vec<String> {
335 let mut flags = Vec::new();
336
337 for arg in arguments {
338 if arg == "--" || arg == "-" {
339 continue;
340 }
341
342 if let Some(stripped) = arg.strip_prefix("--") {
343 let flag = stripped.split('=').next().unwrap_or(stripped);
345 flags.push(flag.to_string());
346 } else if let Some(stripped) = arg.strip_prefix('-') {
347 for c in stripped.chars() {
349 if c == '=' {
350 break;
351 }
352 flags.push(c.to_string());
353 }
354 }
355 }
356
357 flags
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_parse_simple_command() {
366 let shell = ParsedShell::parse("apt-get update");
367 assert_eq!(shell.commands.len(), 1);
368 assert_eq!(shell.commands[0].name, "apt-get");
369 assert_eq!(shell.commands[0].arguments, vec!["update"]);
370 }
371
372 #[test]
373 fn test_parse_chained_commands() {
374 let shell = ParsedShell::parse("apt-get update && apt-get install -y nginx");
375 assert_eq!(shell.commands.len(), 2);
376 assert_eq!(shell.commands[0].name, "apt-get");
377 assert_eq!(shell.commands[1].name, "apt-get");
378 assert!(shell.commands[1].has_flag("y"));
379 }
380
381 #[test]
382 fn test_parse_pipe() {
383 let shell = ParsedShell::parse("cat file | grep pattern");
384 assert!(shell.has_pipes);
385 assert_eq!(shell.commands.len(), 2);
386 }
387
388 #[test]
389 fn test_command_has_args() {
390 let cmd = Command {
391 name: "apt-get".to_string(),
392 arguments: vec!["install".to_string(), "-y".to_string(), "nginx".to_string()],
393 flags: vec!["y".to_string()],
394 };
395
396 assert!(cmd.has_args("apt-get", &["install"]));
397 assert!(cmd.has_flag("y"));
398 assert!(!cmd.has_flag("q"));
399 }
400
401 #[test]
402 fn test_is_pip_install() {
403 let cmd = Command {
404 name: "pip".to_string(),
405 arguments: vec!["install".to_string(), "requests".to_string()],
406 flags: vec![],
407 };
408 assert!(cmd.is_pip_install());
409
410 let cmd2 = Command {
411 name: "pipenv".to_string(),
412 arguments: vec!["install".to_string()],
413 flags: vec![],
414 };
415 assert!(!cmd2.is_pip_install());
416 }
417
418 #[test]
419 fn test_is_apt_get_install() {
420 let cmd = Command {
421 name: "apt-get".to_string(),
422 arguments: vec!["install".to_string(), "-y".to_string(), "nginx".to_string()],
423 flags: vec!["y".to_string()],
424 };
425 assert!(cmd.is_apt_get_install());
426 }
427
428 #[test]
429 fn test_args_no_flags() {
430 let cmd = Command {
431 name: "apt-get".to_string(),
432 arguments: vec![
433 "install".to_string(),
434 "-y".to_string(),
435 "nginx".to_string(),
436 "curl".to_string(),
437 ],
438 flags: vec!["y".to_string()],
439 };
440
441 let args = cmd.args_no_flags();
442 assert_eq!(args, vec!["install", "nginx", "curl"]);
443 }
444
445 #[test]
446 fn test_using_program() {
447 let shell = ParsedShell::parse("apt-get update && curl -O http://example.com/file");
448 assert!(shell.using_program("apt-get"));
449 assert!(shell.using_program("curl"));
450 assert!(!shell.using_program("wget"));
451 }
452}