Skip to main content

cc_audit/parser/
dockerfile.rs

1//! Dockerfile content parser.
2
3use super::traits::{ContentParser, ContentType, ParsedContent};
4use crate::error::Result;
5use serde_json::json;
6
7/// Parser for Dockerfile files.
8pub struct DockerfileParser;
9
10impl DockerfileParser {
11    /// Create a new Dockerfile parser.
12    pub fn new() -> Self {
13        Self
14    }
15
16    /// Extract base images from FROM instructions.
17    pub fn extract_base_images(content: &str) -> Vec<String> {
18        content
19            .lines()
20            .filter_map(|line| {
21                let trimmed = line.trim();
22                if trimmed.to_uppercase().starts_with("FROM ") {
23                    let parts: Vec<&str> = trimmed[5..].split_whitespace().collect();
24                    parts.first().map(|s| s.to_string())
25                } else {
26                    None
27                }
28            })
29            .collect()
30    }
31
32    /// Extract RUN commands.
33    pub fn extract_run_commands(content: &str) -> Vec<String> {
34        let mut commands = Vec::new();
35        let mut in_run = false;
36        let mut current_command = String::new();
37
38        for line in content.lines() {
39            let trimmed = line.trim();
40
41            if in_run {
42                // Continuation of previous RUN command
43                if let Some(stripped) = trimmed.strip_suffix('\\') {
44                    current_command.push_str(stripped);
45                    current_command.push(' ');
46                } else {
47                    current_command.push_str(trimmed);
48                    commands.push(current_command.clone());
49                    current_command.clear();
50                    in_run = false;
51                }
52            } else if trimmed.to_uppercase().starts_with("RUN ") {
53                let cmd = &trimmed[4..];
54                if let Some(stripped) = cmd.strip_suffix('\\') {
55                    current_command = stripped.to_string();
56                    current_command.push(' ');
57                    in_run = true;
58                } else {
59                    commands.push(cmd.to_string());
60                }
61            }
62        }
63
64        // Handle incomplete command at end
65        if in_run && !current_command.is_empty() {
66            commands.push(current_command);
67        }
68
69        commands
70    }
71
72    /// Extract environment variables.
73    pub fn extract_env_vars(content: &str) -> Vec<(String, String)> {
74        content
75            .lines()
76            .filter_map(|line| {
77                let trimmed = line.trim();
78                if trimmed.to_uppercase().starts_with("ENV ") {
79                    let rest = trimmed[4..].trim();
80                    // Handle both "KEY=value" and "KEY value" formats
81                    if let Some(eq_idx) = rest.find('=') {
82                        let key = rest[..eq_idx].trim().to_string();
83                        let value = rest[eq_idx + 1..].trim().to_string();
84                        Some((key, value))
85                    } else {
86                        let parts: Vec<&str> = rest.splitn(2, ' ').collect();
87                        if parts.len() == 2 {
88                            Some((parts[0].to_string(), parts[1].to_string()))
89                        } else {
90                            None
91                        }
92                    }
93                } else {
94                    None
95                }
96            })
97            .collect()
98    }
99}
100
101impl Default for DockerfileParser {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl ContentParser for DockerfileParser {
108    fn parse(&self, content: &str, path: &str) -> Result<ParsedContent> {
109        let base_images = Self::extract_base_images(content);
110        let run_commands = Self::extract_run_commands(content);
111        let env_vars = Self::extract_env_vars(content);
112
113        let structured = json!({
114            "base_images": base_images,
115            "run_commands": run_commands,
116            "env_vars": env_vars.iter().map(|(k, v)| json!({k: v})).collect::<Vec<_>>(),
117        });
118
119        let parsed = ParsedContent::new(
120            ContentType::Dockerfile,
121            content.to_string(),
122            path.to_string(),
123        )
124        .with_structured_data(structured);
125
126        Ok(parsed)
127    }
128
129    fn supported_extensions(&self) -> &[&str] {
130        &["dockerfile"]
131    }
132
133    fn can_parse(&self, path: &str) -> bool {
134        let filename = std::path::Path::new(path)
135            .file_name()
136            .and_then(|n| n.to_str())
137            .unwrap_or("");
138
139        let lower = filename.to_lowercase();
140        lower == "dockerfile" || lower.starts_with("dockerfile.")
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_extract_base_images() {
150        let content = r#"
151FROM node:18-alpine AS builder
152RUN npm install
153FROM nginx:latest
154COPY --from=builder /app/dist /usr/share/nginx/html
155"#;
156        let images = DockerfileParser::extract_base_images(content);
157        assert_eq!(images, vec!["node:18-alpine", "nginx:latest"]);
158    }
159
160    #[test]
161    fn test_extract_run_commands() {
162        let content = r#"
163FROM alpine
164RUN apk add --no-cache curl
165RUN npm install && \
166    npm run build
167"#;
168        let commands = DockerfileParser::extract_run_commands(content);
169        assert_eq!(commands.len(), 2);
170        assert!(commands[0].contains("apk add"));
171        assert!(commands[1].contains("npm install") && commands[1].contains("npm run build"));
172    }
173
174    #[test]
175    fn test_extract_env_vars() {
176        let content = r#"
177FROM alpine
178ENV NODE_ENV=production
179ENV APP_PORT 3000
180"#;
181        let vars = DockerfileParser::extract_env_vars(content);
182        assert_eq!(vars.len(), 2);
183        assert!(vars.contains(&("NODE_ENV".to_string(), "production".to_string())));
184        assert!(vars.contains(&("APP_PORT".to_string(), "3000".to_string())));
185    }
186
187    #[test]
188    fn test_parse_dockerfile() {
189        let parser = DockerfileParser::new();
190        let content = r#"
191FROM node:18-alpine
192ENV NODE_ENV=production
193RUN npm install
194"#;
195        let result = parser.parse(content, "Dockerfile").unwrap();
196
197        assert_eq!(result.content_type, ContentType::Dockerfile);
198        assert!(result.structured_data.is_some());
199        let data = result.structured_data.unwrap();
200        assert!(!data["base_images"].as_array().unwrap().is_empty());
201    }
202
203    #[test]
204    fn test_can_parse() {
205        let parser = DockerfileParser::new();
206        assert!(parser.can_parse("Dockerfile"));
207        assert!(parser.can_parse("dockerfile"));
208        assert!(parser.can_parse("Dockerfile.prod"));
209        assert!(!parser.can_parse("docker-compose.yml"));
210    }
211}