syncable_cli/analyzer/hadolint/
lint.rs1use 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#[derive(Debug, Clone)]
18pub struct LintResult {
19 pub failures: Vec<CheckFailure>,
21 pub parse_errors: Vec<String>,
23}
24
25impl LintResult {
26 pub fn new() -> Self {
28 Self {
29 failures: Vec::new(),
30 parse_errors: Vec::new(),
31 }
32 }
33
34 pub fn has_failures(&self) -> bool {
36 !self.failures.is_empty()
37 }
38
39 pub fn has_errors(&self) -> bool {
41 self.failures.iter().any(|f| f.severity == Severity::Error)
42 }
43
44 pub fn has_warnings(&self) -> bool {
46 self.failures.iter().any(|f| f.severity == Severity::Warning)
47 }
48
49 pub fn max_severity(&self) -> Option<Severity> {
51 self.failures.iter().map(|f| f.severity).max()
52 }
53
54 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 pub fn filter_by_threshold(&mut self, threshold: Severity) {
69 self.failures.retain(|f| f.severity >= threshold);
70 }
71
72 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
84pub fn lint(content: &str, config: &HadolintConfig) -> LintResult {
86 let mut result = LintResult::new();
87
88 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 let pragmas = if config.disable_ignore_pragma {
99 PragmaState::new()
100 } else {
101 extract_pragmas(&instructions)
102 };
103
104 let failures = run_rules(&instructions, config, &pragmas);
106
107 result.failures = failures
109 .into_iter()
110 .filter(|f| {
111 let effective_severity = config.effective_severity(&f.code, f.severity);
113
114 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 f.severity = config.effective_severity(&f.code, f.severity);
122 f
123 })
124 .collect();
125
126 result.sort();
128
129 result
130}
131
132pub 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
144fn 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 if config.is_rule_ignored(rule.code()) {
156 continue;
157 }
158
159 let mut state = RuleState::new();
160
161 for instr in instructions {
163 let shell = match &instr.instruction {
165 Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
166 _ => None,
167 };
168
169 rule.check(&mut state, instr.line_number, &instr.instruction, shell.as_ref());
171
172 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 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 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 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 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 assert!(result.should_fail(&config));
289
290 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 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 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 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 assert!(triggered_rules.len() >= 30, "Expected at least 30 different rules, got {}", triggered_rules.len());
438
439 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}