syncable_cli/analyzer/hadolint/
lint.rs

1//! Main linting orchestration for hadolint-rs.
2//!
3//! This module ties together parsing, rules, and pragmas to provide
4//! the main linting API.
5
6use crate::analyzer::hadolint::config::HadolintConfig;
7use crate::analyzer::hadolint::parser::{parse_dockerfile, InstructionPos};
8use crate::analyzer::hadolint::pragma::{extract_pragmas, PragmaState};
9use crate::analyzer::hadolint::rules::{all_rules, RuleState};
10use crate::analyzer::hadolint::shell::ParsedShell;
11use crate::analyzer::hadolint::types::{CheckFailure, Severity};
12use crate::analyzer::hadolint::parser::instruction::Instruction;
13
14use std::path::Path;
15
16/// Result of linting a Dockerfile.
17#[derive(Debug, Clone)]
18pub struct LintResult {
19    /// Rule violations found.
20    pub failures: Vec<CheckFailure>,
21    /// Parse errors (if any).
22    pub parse_errors: Vec<String>,
23}
24
25impl LintResult {
26    /// Create a new empty result.
27    pub fn new() -> Self {
28        Self {
29            failures: Vec::new(),
30            parse_errors: Vec::new(),
31        }
32    }
33
34    /// Check if there are any failures.
35    pub fn has_failures(&self) -> bool {
36        !self.failures.is_empty()
37    }
38
39    /// Check if there are any errors (failure with Error severity).
40    pub fn has_errors(&self) -> bool {
41        self.failures.iter().any(|f| f.severity == Severity::Error)
42    }
43
44    /// Check if there are any warnings (failure with Warning severity).
45    pub fn has_warnings(&self) -> bool {
46        self.failures.iter().any(|f| f.severity == Severity::Warning)
47    }
48
49    /// Get the maximum severity in the results.
50    pub fn max_severity(&self) -> Option<Severity> {
51        self.failures.iter().map(|f| f.severity).max()
52    }
53
54    /// Check if the results should cause a non-zero exit.
55    pub fn should_fail(&self, config: &HadolintConfig) -> bool {
56        if config.no_fail {
57            return false;
58        }
59
60        if let Some(max) = self.max_severity() {
61            max >= config.failure_threshold
62        } else {
63            false
64        }
65    }
66
67    /// Filter failures by severity threshold.
68    pub fn filter_by_threshold(&mut self, threshold: Severity) {
69        self.failures.retain(|f| f.severity >= threshold);
70    }
71
72    /// Sort failures by line number.
73    pub fn sort(&mut self) {
74        self.failures.sort();
75    }
76}
77
78impl Default for LintResult {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84/// Lint a Dockerfile string.
85pub fn lint(content: &str, config: &HadolintConfig) -> LintResult {
86    let mut result = LintResult::new();
87
88    // Parse Dockerfile
89    let instructions = match parse_dockerfile(content) {
90        Ok(instrs) => instrs,
91        Err(err) => {
92            result.parse_errors.push(err.to_string());
93            return result;
94        }
95    };
96
97    // Extract pragmas
98    let pragmas = if config.disable_ignore_pragma {
99        PragmaState::new()
100    } else {
101        extract_pragmas(&instructions)
102    };
103
104    // Run rules
105    let failures = run_rules(&instructions, config, &pragmas);
106
107    // Filter by config
108    result.failures = failures
109        .into_iter()
110        .filter(|f| {
111            // Apply config severity overrides
112            let effective_severity = config.effective_severity(&f.code, f.severity);
113
114            // Filter by threshold
115            effective_severity >= config.failure_threshold
116        })
117        .filter(|f| !config.is_rule_ignored(&f.code))
118        .filter(|f| !pragmas.is_ignored(&f.code, f.line))
119        .map(|mut f| {
120            // Apply severity overrides
121            f.severity = config.effective_severity(&f.code, f.severity);
122            f
123        })
124        .collect();
125
126    // Sort by line number
127    result.sort();
128
129    result
130}
131
132/// Lint a Dockerfile from a file path.
133pub fn lint_file(path: &Path, config: &HadolintConfig) -> LintResult {
134    match std::fs::read_to_string(path) {
135        Ok(content) => lint(&content, config),
136        Err(err) => {
137            let mut result = LintResult::new();
138            result.parse_errors.push(format!("Failed to read file: {}", err));
139            result
140        }
141    }
142}
143
144/// Run all enabled rules on the instructions.
145fn run_rules(
146    instructions: &[InstructionPos],
147    config: &HadolintConfig,
148    pragmas: &PragmaState,
149) -> Vec<CheckFailure> {
150    let rules = all_rules();
151    let mut all_failures = Vec::new();
152
153    for rule in rules {
154        // Skip ignored rules
155        if config.is_rule_ignored(rule.code()) {
156            continue;
157        }
158
159        let mut state = RuleState::new();
160
161        // Process each instruction
162        for instr in instructions {
163            // Parse shell if this is a RUN instruction
164            let shell = match &instr.instruction {
165                Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
166                _ => None,
167            };
168
169            // Check the instruction
170            rule.check(&mut state, instr.line_number, &instr.instruction, shell.as_ref());
171
172            // Also check ONBUILD contents
173            if let Instruction::OnBuild(inner) = &instr.instruction {
174                let inner_shell = match inner.as_ref() {
175                    Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
176                    _ => None,
177                };
178                rule.check(&mut state, instr.line_number, inner.as_ref(), inner_shell.as_ref());
179            }
180        }
181
182        // Finalize the rule
183        let failures = rule.finalize(state);
184        all_failures.extend(failures);
185    }
186
187    all_failures
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_lint_empty() {
196        let result = lint("", &HadolintConfig::default());
197        assert!(result.failures.is_empty());
198    }
199
200    #[test]
201    fn test_lint_valid_dockerfile() {
202        let dockerfile = r#"
203FROM ubuntu:20.04
204WORKDIR /app
205COPY . .
206CMD ["./app"]
207"#;
208        let result = lint(dockerfile, &HadolintConfig::default());
209        // Should have no DL3000 (WORKDIR is absolute)
210        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3000"));
211    }
212
213    #[test]
214    fn test_lint_relative_workdir() {
215        let dockerfile = r#"
216FROM ubuntu:20.04
217WORKDIR app
218"#;
219        let result = lint(dockerfile, &HadolintConfig::default());
220        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3000"));
221    }
222
223    #[test]
224    fn test_lint_maintainer() {
225        let dockerfile = r#"
226FROM ubuntu:20.04
227MAINTAINER John Doe <john@example.com>
228"#;
229        let result = lint(dockerfile, &HadolintConfig::default());
230        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000"));
231    }
232
233    #[test]
234    fn test_lint_untagged_image() {
235        let dockerfile = "FROM ubuntu\n";
236        let result = lint(dockerfile, &HadolintConfig::default());
237        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
238    }
239
240    #[test]
241    fn test_lint_latest_tag() {
242        let dockerfile = "FROM ubuntu:latest\n";
243        let result = lint(dockerfile, &HadolintConfig::default());
244        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3007"));
245    }
246
247    #[test]
248    fn test_lint_ignore_pragma() {
249        let dockerfile = r#"
250# hadolint ignore=DL3006
251FROM ubuntu
252"#;
253        let result = lint(dockerfile, &HadolintConfig::default());
254        // DL3006 should be ignored
255        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
256    }
257
258    #[test]
259    fn test_lint_config_ignore() {
260        let dockerfile = "FROM ubuntu\n";
261        let config = HadolintConfig::default().ignore("DL3006");
262        let result = lint(dockerfile, &config);
263        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
264    }
265
266    #[test]
267    fn test_lint_threshold() {
268        let dockerfile = r#"
269FROM ubuntu
270MAINTAINER John
271"#;
272        let mut config = HadolintConfig::default();
273        config.failure_threshold = Severity::Error;
274        let result = lint(dockerfile, &config);
275        // DL3006 (warning) should be filtered out
276        // DL4000 (error) should remain
277        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
278        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000"));
279    }
280
281    #[test]
282    fn test_should_fail() {
283        let dockerfile = "FROM ubuntu:latest\n";
284        let config = HadolintConfig::default().with_threshold(Severity::Warning);
285        let result = lint(dockerfile, &config);
286
287        // DL3007 is a warning, should trigger failure with Warning threshold
288        assert!(result.should_fail(&config));
289
290        // With no_fail, should not fail
291        let mut no_fail_config = config.clone();
292        no_fail_config.no_fail = true;
293        assert!(!result.should_fail(&no_fail_config));
294    }
295
296    #[test]
297    fn test_lint_sudo() {
298        let dockerfile = r#"
299FROM ubuntu:20.04
300RUN sudo apt-get update
301"#;
302        let result = lint(dockerfile, &HadolintConfig::default());
303        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3004"));
304    }
305
306    #[test]
307    fn test_lint_cd() {
308        let dockerfile = r#"
309FROM ubuntu:20.04
310RUN cd /app && npm install
311"#;
312        let result = lint(dockerfile, &HadolintConfig::default());
313        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3003"));
314    }
315
316    #[test]
317    fn test_lint_shell_form_cmd() {
318        let dockerfile = r#"
319FROM ubuntu:20.04
320CMD node app.js
321"#;
322        let result = lint(dockerfile, &HadolintConfig::default());
323        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3025"));
324    }
325
326    #[test]
327    fn test_lint_exec_form_cmd() {
328        let dockerfile = r#"
329FROM ubuntu:20.04
330CMD ["node", "app.js"]
331"#;
332        let result = lint(dockerfile, &HadolintConfig::default());
333        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3025"));
334    }
335
336    #[test]
337    fn test_lint_error_dockerfile() {
338        // Comprehensive test Dockerfile with many intentional errors
339        let dockerfile = r#"
340# Test Dockerfile with maximum hadolint errors
341MAINTAINER bad@example.com
342
343FROM ubuntu:latest
344
345LABEL maintainer="test@test.com" \
346      description="" \
347      org.opencontainers.image.created="not-a-date" \
348      org.opencontainers.image.licenses="INVALID" \
349      org.opencontainers.image.title="" \
350      org.opencontainers.image.description="" \
351      org.opencontainers.image.documentation="not-url" \
352      org.opencontainers.image.source="not-url" \
353      org.opencontainers.image.url="not-url"
354
355ENV FOO=bar BAR=$FOO
356
357COPY package.json app/
358
359WORKDIR relative/path
360
361RUN apt update
362RUN apt-get upgrade
363RUN apt-get install curl wget nginx
364
365RUN sudo useradd -m testuser
366
367RUN cd /app && echo "hello"
368
369RUN pip install flask requests
370
371RUN npm install -g express
372
373RUN gem install rails
374
375FROM alpine:latest AS alpine-stage
376RUN apk upgrade
377RUN apk add nginx
378
379FROM centos:latest AS centos-stage
380RUN yum update -y
381RUN yum install -y httpd
382
383FROM fedora:latest AS fedora-stage
384RUN dnf update
385RUN dnf install nginx
386
387FROM ubuntu:latest AS builder
388FROM debian:latest AS builder
389
390ADD https://example.com/file.txt /app/
391ADD localfile.txt /app/
392
393COPY --from=nonexistent /app /app
394
395EXPOSE 99999
396
397RUN ln -s /bin/bash /bin/sh
398
399RUN curl http://example.com | grep pattern
400
401RUN wget http://example.com/file1
402RUN curl http://example.com/file2
403
404ENTRYPOINT /bin/bash start.sh
405
406CMD echo "first"
407CMD echo "second"
408
409ENTRYPOINT ["python"]
410ENTRYPOINT ["node"]
411
412HEALTHCHECK CMD curl localhost
413HEALTHCHECK CMD wget localhost
414
415USER root
416"#;
417        let result = lint(dockerfile, &HadolintConfig::default());
418
419        // Collect unique rule codes triggered
420        let mut triggered_rules: Vec<&str> = result.failures.iter()
421            .map(|f| f.code.as_str())
422            .collect();
423        triggered_rules.sort();
424        triggered_rules.dedup();
425
426        // Print summary for debugging
427        println!("\n=== HADOLINT ERROR DOCKERFILE TEST ===");
428        println!("Total violations: {}", result.failures.len());
429        println!("Unique rules triggered: {}", triggered_rules.len());
430        println!("\nRules triggered:");
431        for rule in &triggered_rules {
432            let count = result.failures.iter().filter(|f| f.code.as_str() == *rule).count();
433            println!("  {} ({}x)", rule, count);
434        }
435
436        // Verify we catch many rules
437        assert!(triggered_rules.len() >= 30, "Expected at least 30 different rules, got {}", triggered_rules.len());
438
439        // Verify some key rules are triggered
440        assert!(triggered_rules.contains(&"DL3000"), "DL3000 not triggered");
441        assert!(triggered_rules.contains(&"DL3004"), "DL3004 not triggered");
442        assert!(triggered_rules.contains(&"DL3007"), "DL3007 not triggered");
443        assert!(triggered_rules.contains(&"DL3027"), "DL3027 not triggered");
444        assert!(triggered_rules.contains(&"DL4000"), "DL4000 not triggered");
445        assert!(triggered_rules.contains(&"DL4003"), "DL4003 not triggered");
446        assert!(triggered_rules.contains(&"DL4004"), "DL4004 not triggered");
447    }
448}