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
118            .iter()
119            .all(|arg| self.arguments.iter().any(|a| a == *arg))
120    }
121
122    /// Check if the command has any of the specified arguments.
123    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    /// Check if the command has a specific flag.
129    pub fn has_flag(&self, flag: &str) -> bool {
130        self.flags.iter().any(|f| f == flag)
131    }
132
133    /// Check if the command has any of the specified flags.
134    pub fn has_any_flag(&self, flags: &[&str]) -> bool {
135        flags.iter().any(|f| self.has_flag(f))
136    }
137
138    /// Get arguments without flags.
139    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    /// Get the value for a flag (e.g., "-t" returns "release" for "-t=release").
148    pub fn get_flag_value(&self, flag: &str) -> Option<&str> {
149        // Check for --flag=value format
150        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        // Check for --flag value format
160        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    /// Check if this is a pip install command.
171    pub fn is_pip_install(&self) -> bool {
172        // Standard pip install
173        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        // python -m pip install
180        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    /// Check if this is an apt-get install command.
191    pub fn is_apt_get_install(&self) -> bool {
192        self.name == "apt-get" && self.arguments.iter().any(|a| a == "install")
193    }
194
195    /// Check if this is an apk add command.
196    pub fn is_apk_add(&self) -> bool {
197        self.name == "apk" && self.arguments.iter().any(|a| a == "add")
198    }
199}
200
201/// Extract commands from a shell script.
202fn extract_commands(script: &str) -> Vec<Command> {
203    let mut commands = Vec::new();
204
205    // Simple tokenization: split by command separators
206    let separators = ["&&", "||", ";", "|", "\n"];
207
208    let mut remaining = script.trim();
209
210    while !remaining.is_empty() {
211        // Find the next separator
212        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        // Parse the command
231        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
241/// Parse a single command string into a Command.
242fn 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    // Handle subshells and command substitution
249    let cmd_str = cmd_str.trim_start_matches('(').trim_end_matches(')').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![
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}