syncable_cli/analyzer/hadolint/
lint.rs1use 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#[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
47 .iter()
48 .any(|f| f.severity == Severity::Warning)
49 }
50
51 pub fn max_severity(&self) -> Option<Severity> {
53 self.failures.iter().map(|f| f.severity).max()
54 }
55
56 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 pub fn filter_by_threshold(&mut self, threshold: Severity) {
71 self.failures.retain(|f| f.severity >= threshold);
72 }
73
74 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
86pub fn lint(content: &str, config: &HadolintConfig) -> LintResult {
88 let mut result = LintResult::new();
89
90 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 let pragmas = if config.disable_ignore_pragma {
101 PragmaState::new()
102 } else {
103 extract_pragmas(&instructions)
104 };
105
106 let failures = run_rules(&instructions, config, &pragmas);
108
109 result.failures = failures
111 .into_iter()
112 .filter(|f| {
113 let effective_severity = config.effective_severity(&f.code, f.severity);
115
116 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 f.severity = config.effective_severity(&f.code, f.severity);
124 f
125 })
126 .collect();
127
128 result.sort();
130
131 result
132}
133
134pub 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
148fn 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 if config.is_rule_ignored(rule.code()) {
160 continue;
161 }
162
163 let mut state = RuleState::new();
164
165 for instr in instructions {
167 let shell = match &instr.instruction {
169 Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
170 _ => None,
171 };
172
173 rule.check(
175 &mut state,
176 instr.line_number,
177 &instr.instruction,
178 shell.as_ref(),
179 );
180
181 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 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 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 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 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 assert!(result.should_fail(&config));
303
304 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 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 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 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 assert!(
455 triggered_rules.len() >= 30,
456 "Expected at least 30 different rules, got {}",
457 triggered_rules.len()
458 );
459
460 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}