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.iter().all(|arg| self.arguments.iter().any(|a| a == *arg))
118 }
119
120 pub fn has_any_arg(&self, args: &[&str]) -> bool {
122 args.iter().any(|arg| self.arguments.iter().any(|a| a == *arg))
123 }
124
125 pub fn has_flag(&self, flag: &str) -> bool {
127 self.flags.iter().any(|f| f == flag)
128 }
129
130 pub fn has_any_flag(&self, flags: &[&str]) -> bool {
132 flags.iter().any(|f| self.has_flag(f))
133 }
134
135 pub fn args_no_flags(&self) -> Vec<&str> {
137 self.arguments
138 .iter()
139 .filter(|a| !a.starts_with('-'))
140 .map(|s| s.as_str())
141 .collect()
142 }
143
144 pub fn get_flag_value(&self, flag: &str) -> Option<&str> {
146 for arg in &self.arguments {
148 if let Some(stripped) = arg.strip_prefix(&format!("--{}=", flag)) {
149 return Some(stripped);
150 }
151 if let Some(stripped) = arg.strip_prefix(&format!("-{}=", flag)) {
152 return Some(stripped);
153 }
154 }
155
156 let mut iter = self.arguments.iter();
158 while let Some(arg) = iter.next() {
159 if arg == &format!("--{}", flag) || arg == &format!("-{}", flag) {
160 return iter.next().map(|s| s.as_str());
161 }
162 }
163
164 None
165 }
166
167 pub fn is_pip_install(&self) -> bool {
169 if (self.name.starts_with("pip") && !self.name.starts_with("pipenv"))
171 && self.arguments.iter().any(|a| a == "install")
172 {
173 return true;
174 }
175
176 if self.name.starts_with("python") {
178 let args: Vec<&str> = self.arguments.iter().map(|s| s.as_str()).collect();
179 if args.windows(3).any(|w| w == ["-m", "pip", "install"]) {
180 return true;
181 }
182 }
183
184 false
185 }
186
187 pub fn is_apt_get_install(&self) -> bool {
189 self.name == "apt-get" && self.arguments.iter().any(|a| a == "install")
190 }
191
192 pub fn is_apk_add(&self) -> bool {
194 self.name == "apk" && self.arguments.iter().any(|a| a == "add")
195 }
196}
197
198fn extract_commands(script: &str) -> Vec<Command> {
200 let mut commands = Vec::new();
201
202 let separators = ["&&", "||", ";", "|", "\n"];
204
205 let mut remaining = script.trim();
206
207 while !remaining.is_empty() {
208 let next_sep = separators
210 .iter()
211 .filter_map(|sep| remaining.find(sep).map(|pos| (pos, sep.len())))
212 .min_by_key(|(pos, _)| *pos);
213
214 let cmd_str = match next_sep {
215 Some((pos, len)) => {
216 let cmd = &remaining[..pos];
217 remaining = &remaining[pos + len..];
218 cmd
219 }
220 None => {
221 let cmd = remaining;
222 remaining = "";
223 cmd
224 }
225 };
226
227 if let Some(cmd) = parse_single_command(cmd_str.trim()) {
229 commands.push(cmd);
230 }
231
232 remaining = remaining.trim_start();
233 }
234
235 commands
236}
237
238fn parse_single_command(cmd_str: &str) -> Option<Command> {
240 let cmd_str = cmd_str.trim();
241 if cmd_str.is_empty() {
242 return None;
243 }
244
245 let cmd_str = cmd_str
247 .trim_start_matches('(')
248 .trim_end_matches(')')
249 .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!["install".to_string(), "-y".to_string(), "nginx".to_string(), "curl".to_string()],
433 flags: vec!["y".to_string()],
434 };
435
436 let args = cmd.args_no_flags();
437 assert_eq!(args, vec!["install", "nginx", "curl"]);
438 }
439
440 #[test]
441 fn test_using_program() {
442 let shell = ParsedShell::parse("apt-get update && curl -O http://example.com/file");
443 assert!(shell.using_program("apt-get"));
444 assert!(shell.using_program("curl"));
445 assert!(!shell.using_program("wget"));
446 }
447}