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