syncable_cli/analyzer/hadolint/shell/
mod.rs

1//! Shell parsing module for hadolint-rs.
2//!
3//! Provides:
4//! - Shell command extraction from RUN instructions
5//! - ShellCheck integration for deeper analysis
6//!
7//! This module handles parsing shell commands from Dockerfile RUN instructions
8//! and provides utilities for rule implementations to analyze them.
9
10pub mod shellcheck;
11
12use crate::analyzer::hadolint::parser::instruction::{Arguments, RunArgs};
13
14/// Parsed shell command information.
15#[derive(Debug, Clone, Default)]
16pub struct ParsedShell {
17    /// Original shell script text.
18    pub original: String,
19    /// Extracted commands.
20    pub commands: Vec<Command>,
21    /// Whether the shell has pipes.
22    pub has_pipes: bool,
23}
24
25impl ParsedShell {
26    /// Parse a shell command string.
27    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    /// Parse from RUN instruction arguments.
40    pub fn from_run_args(args: &RunArgs) -> Self {
41        match &args.arguments {
42            Arguments::Text(text) => Self::parse(text),
43            Arguments::List(list) => {
44                // Exec form - join for analysis
45                let script = list.join(" ");
46                Self::parse(&script)
47            }
48        }
49    }
50
51    /// Check if any command matches the predicate.
52    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    /// Check if all commands match the predicate.
60    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    /// Check if no commands match the predicate.
68    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    /// Find command names in the script.
76    pub fn find_command_names(&self) -> Vec<&str> {
77        self.commands.iter().map(|c| c.name.as_str()).collect()
78    }
79
80    /// Check if using a specific program.
81    pub fn using_program(&self, prog: &str) -> bool {
82        self.commands.iter().any(|c| c.name == prog)
83    }
84
85    /// Check if any command is a pip install.
86    pub fn is_pip_install(&self, cmd: &Command) -> bool {
87        cmd.is_pip_install()
88    }
89}
90
91/// A single command extracted from a shell script.
92#[derive(Debug, Clone)]
93pub struct Command {
94    /// Command name (e.g., "apt-get", "pip").
95    pub name: String,
96    /// All arguments including flags.
97    pub arguments: Vec<String>,
98    /// Extracted flags (e.g., ["-y", "--no-cache"]).
99    pub flags: Vec<String>,
100}
101
102impl Command {
103    /// Create a new command.
104    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    /// Check if the command has specific arguments.
113    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    /// Check if the command has any of the specified arguments.
121    pub fn has_any_arg(&self, args: &[&str]) -> bool {
122        args.iter().any(|arg| self.arguments.iter().any(|a| a == *arg))
123    }
124
125    /// Check if the command has a specific flag.
126    pub fn has_flag(&self, flag: &str) -> bool {
127        self.flags.iter().any(|f| f == flag)
128    }
129
130    /// Check if the command has any of the specified flags.
131    pub fn has_any_flag(&self, flags: &[&str]) -> bool {
132        flags.iter().any(|f| self.has_flag(f))
133    }
134
135    /// Get arguments without flags.
136    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    /// Get the value for a flag (e.g., "-t" returns "release" for "-t=release").
145    pub fn get_flag_value(&self, flag: &str) -> Option<&str> {
146        // Check for --flag=value format
147        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        // Check for --flag value format
157        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    /// Check if this is a pip install command.
168    pub fn is_pip_install(&self) -> bool {
169        // Standard pip install
170        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        // python -m pip install
177        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    /// Check if this is an apt-get install command.
188    pub fn is_apt_get_install(&self) -> bool {
189        self.name == "apt-get" && self.arguments.iter().any(|a| a == "install")
190    }
191
192    /// Check if this is an apk add command.
193    pub fn is_apk_add(&self) -> bool {
194        self.name == "apk" && self.arguments.iter().any(|a| a == "add")
195    }
196}
197
198/// Extract commands from a shell script.
199fn extract_commands(script: &str) -> Vec<Command> {
200    let mut commands = Vec::new();
201
202    // Simple tokenization: split by command separators
203    let separators = ["&&", "||", ";", "|", "\n"];
204
205    let mut remaining = script.trim();
206
207    while !remaining.is_empty() {
208        // Find the next separator
209        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        // Parse the command
228        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
238/// Parse a single command string into a Command.
239fn 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    // Handle subshells and command substitution
246    let cmd_str = cmd_str
247        .trim_start_matches('(')
248        .trim_end_matches(')')
249        .trim();
250
251    // Simple word splitting
252    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
269/// Simple shell word splitting.
270fn 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    // Don't forget the last word
322    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
333/// Extract flags from arguments.
334fn 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            // Long flag
344            let flag = stripped.split('=').next().unwrap_or(stripped);
345            flags.push(flag.to_string());
346        } else if let Some(stripped) = arg.strip_prefix('-') {
347            // Short flag(s)
348            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}