1use anyhow::{Context, Result};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7use crate::parser;
8use crate::repository::{self, ContainerEngine, DockerignoreProblem};
9use crate::rules::{self, Finding, Severity};
10use crate::shellcheck;
11
12pub struct LintOptions {
13 pub skip_rules: Vec<String>,
14 pub only_rules: Vec<String>,
16 pub min_severity: Severity,
17 pub check_dockerignore: bool,
18 pub engine: ContainerEngine,
19 pub severity_overrides: BTreeMap<String, Severity>,
20 pub categories: Vec<String>,
21 pub skip_categories: Vec<String>,
22 pub inline_suppressions: bool,
23 pub require_suppression_reason: bool,
24 pub suppression_reason_pattern: Option<String>,
25 pub require_suppression_expiration: bool,
26 pub max_suppression_days: Option<u64>,
27 pub report_unused_suppressions: bool,
28 pub approved_registries: Option<Vec<String>>,
29 pub approved_base_images: Option<Vec<String>>,
30 pub required_labels: BTreeMap<String, String>,
31 pub strict_labels: bool,
32 pub shellcheck_mode: shellcheck::Mode,
33 pub shellcheck_exclude: Vec<String>,
34}
35
36impl Default for LintOptions {
37 fn default() -> Self {
38 Self {
39 skip_rules: Vec::new(),
40 only_rules: Vec::new(),
41 min_severity: Severity::Info,
42 check_dockerignore: true,
43 engine: ContainerEngine::Docker,
44 severity_overrides: BTreeMap::new(),
45 categories: Vec::new(),
46 skip_categories: Vec::new(),
47 inline_suppressions: true,
48 require_suppression_reason: false,
49 suppression_reason_pattern: None,
50 require_suppression_expiration: false,
51 max_suppression_days: None,
52 report_unused_suppressions: false,
53 approved_registries: None,
54 approved_base_images: None,
55 required_labels: BTreeMap::new(),
56 strict_labels: false,
57 shellcheck_mode: shellcheck::Mode::Off,
58 shellcheck_exclude: Vec::new(),
59 }
60 }
61}
62
63pub struct LintResult {
64 pub file: String,
65 pub findings: Vec<Finding>,
66}
67
68pub fn lint_content(content: &str, filename: &str, opts: &LintOptions) -> LintResult {
73 let mut result = lint_content_without_context(content, filename, opts);
74 if opts.check_dockerignore && rule_id_enabled(opts, "DF033") {
75 let context = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
76 let stdin_marker = context.join(".droast-stdin");
77 add_ignorefile_finding(&mut result, &stdin_marker, &context, opts);
78 }
79 finalize_findings(content, &mut result.findings, opts);
80 result
81}
82
83fn lint_content_without_context(content: &str, filename: &str, opts: &LintOptions) -> LintResult {
84 let instructions = parser::parse(content);
85 let mut findings: Vec<Finding> = Vec::new();
86
87 for rule in rules::all_rules() {
88 if !rule_enabled(opts, &rule) {
89 continue;
90 }
91 if rule.id == "DF065" && opts.approved_registries.is_some() {
92 continue;
93 }
94 let rule_findings = (rule.func)(&instructions, content);
95 findings.extend(rule_findings);
96 }
97
98 findings.extend(crate::policy::configured_findings(&instructions, opts));
99 findings.extend(podman_workflow_findings(filename, opts));
100 if opts.only_rules.is_empty() {
101 findings.extend(shellcheck::lint(
102 content,
103 &instructions,
104 opts.shellcheck_mode,
105 &opts.shellcheck_exclude,
106 ));
107 }
108
109 LintResult {
110 file: filename.to_string(),
111 findings,
112 }
113}
114
115fn podman_workflow_findings(filename: &str, opts: &LintOptions) -> Vec<Finding> {
116 if opts.engine != ContainerEngine::Podman || !rule_id_enabled(opts, "DF075") {
117 return Vec::new();
118 }
119 let is_preprocessed = Path::new(filename)
120 .file_name()
121 .is_some_and(|name| name == "Containerfile.in");
122 if !is_preprocessed {
123 return Vec::new();
124 }
125 vec![Finding {
126 rule: "DF075".into(),
127 severity: Severity::Info,
128 line: 1,
129 column: 1,
130 end_line: 1,
131 end_column: 1,
132 message: "Containerfile.in is preprocessed by Podman with CPP; lint the generated Containerfile too".into(),
133 roast: "CPP gets the last word here. Make sure the file Podman actually builds gets roasted too.".into(),
134 }]
135}
136
137pub fn lint_file(path: &Path, opts: &LintOptions) -> Result<LintResult> {
139 let context = path.parent().unwrap_or_else(|| Path::new("."));
140 lint_file_with_context(path, context, opts)
141}
142
143pub fn lint_file_with_context(
146 path: &Path,
147 context: &Path,
148 opts: &LintOptions,
149) -> Result<LintResult> {
150 let content = std::fs::read_to_string(path)
151 .with_context(|| format!("Failed to read '{}'", path.display()))?;
152 let mut result = lint_content_without_context(&content, &path.display().to_string(), opts);
153 if opts.check_dockerignore && rule_id_enabled(opts, "DF033") {
154 add_ignorefile_finding(&mut result, path, context, opts);
155 }
156 if rule_id_enabled(opts, "DF077") {
157 add_copy_ignored_findings(&mut result, path, context, opts);
158 }
159 if rule_id_enabled(opts, "DF007") {
160 contextualize_copy_all_findings(&mut result, path, context, opts);
161 }
162 finalize_findings(&content, &mut result.findings, opts);
163 Ok(result)
164}
165
166fn rule_enabled(opts: &LintOptions, rule: &rules::Rule) -> bool {
167 let selected_by_id = opts.only_rules.is_empty()
168 || opts
169 .only_rules
170 .iter()
171 .any(|selected| selected.eq_ignore_ascii_case(rule.id));
172 let selected_by_category = !opts.only_rules.is_empty()
173 || opts.categories.is_empty()
174 || matches!(rule.id, "DF071" | "DF072")
175 || rule.categories().iter().any(|category| {
176 opts.categories
177 .iter()
178 .any(|selected| selected.eq_ignore_ascii_case(category))
179 });
180 let skipped_by_category = opts.only_rules.is_empty()
181 && rule.categories().iter().any(|category| {
182 opts.skip_categories
183 .iter()
184 .any(|skipped| skipped.eq_ignore_ascii_case(category))
185 });
186 selected_by_id
187 && selected_by_category
188 && !skipped_by_category
189 && !opts
190 .skip_rules
191 .iter()
192 .any(|skipped| skipped.eq_ignore_ascii_case(rule.id))
193}
194
195pub(crate) fn rule_id_enabled(opts: &LintOptions, id: &str) -> bool {
196 rules::all_rules()
197 .iter()
198 .find(|rule| rule.id.eq_ignore_ascii_case(id))
199 .is_some_and(|rule| rule_enabled(opts, rule))
200}
201
202fn finalize_findings(content: &str, findings: &mut Vec<Finding>, opts: &LintOptions) {
203 let document = parser::parse_document(content);
204 for finding in findings.iter_mut().filter(|finding| finding.line > 0 && finding.column == 0) {
205 if let Some(instruction) = document.instructions.iter().find(|instruction| instruction.line == finding.line) {
206 finding.column = instruction.span.start.column;
207 finding.end_line = instruction.span.end.line;
208 finding.end_column = instruction.span.end.column;
209 }
210 }
211 crate::policy::apply_inline_suppressions(content, findings, opts);
212 for finding in findings.iter_mut() {
213 if let Some(severity) = opts.severity_overrides.get(&finding.rule) {
214 finding.severity = *severity;
215 }
216 }
217 findings.retain(|finding| finding.severity >= opts.min_severity);
218 findings.sort_by(|a, b| a.line.cmp(&b.line).then(b.severity.cmp(&a.severity)));
219}
220
221fn add_ignorefile_finding(
222 result: &mut LintResult,
223 dockerfile: &Path,
224 context: &Path,
225 opts: &LintOptions,
226) {
227 if !rule_id_enabled(opts, "DF033") {
228 return;
229 }
230 let problem = match repository::ignorefile_problem_for_engine(dockerfile, context, opts.engine) {
231 Ok(problem) => problem,
232 Err(error) => {
233 result.findings.push(Finding {
234 column: 0,
235 end_line: 0,
236 end_column: 0,
237 rule: "DF033".into(),
238 severity: Severity::Info,
239 line: 0,
240 message: format!("Cannot read the effective build-context ignore file: {error}"),
241 roast: "The build-context filter is unreadable, so nobody can tell what the image build will receive.".to_string(),
242 });
243 return;
244 }
245 };
246 let Some(problem) = problem else {
247 return;
248 };
249 let message = match problem {
250 DockerignoreProblem::Missing { expected } => format!(
251 "No effective build-context ignore file for '{}' (expected '{}')",
252 context.display(),
253 expected.display()
254 ),
255 DockerignoreProblem::Empty { path } => format!(
256 "Effective build-context ignore file '{}' has no exclusion patterns",
257 path.display()
258 ),
259 };
260 result.findings.push(Finding {
261 column: 0,
262 end_line: 0,
263 end_column: 0,
264 rule: "DF033".into(),
265 severity: Severity::Info,
266 line: 0,
267 message,
268 roast: "This build context has no effective exclusions, so caches, repositories, dependencies, and secrets can all join the image-build road trip.".to_string(),
269 });
270}
271
272fn add_copy_ignored_findings(result: &mut LintResult, dockerfile: &Path, context: &Path, opts: &LintOptions) {
273 let ignored = match repository::ignored_copy_sources(dockerfile, context, opts.engine) {
274 Ok(ignored) => ignored,
275 Err(_) => return,
276 };
277 for (line, source) in ignored {
278 result.findings.push(Finding {
279 rule: "DF077".into(), severity: Severity::Error, line, column: 0, end_line: 0, end_column: 0,
280 message: format!("{} source '{}' is excluded by the effective build-context ignore file", "COPY/ADD", source),
281 roast: "That source is ignored before the build starts. Docker cannot copy a file it never received.".into(),
282 });
283 }
284}
285
286fn contextualize_copy_all_findings(
287 result: &mut LintResult,
288 dockerfile: &Path,
289 context: &Path,
290 opts: &LintOptions,
291) {
292 if !repository::ignores_common_copy_all_hazards(dockerfile, context, opts.engine).unwrap_or(false) {
293 return;
294 }
295 for finding in result.findings.iter_mut().filter(|finding| finding.rule == "DF007") {
296 finding.message = "COPY . uses a protected build context but still broadens cache invalidation".into();
297 finding.roast = "Your ignore file keeps .git, node_modules, .env, and dist out of the build context. Nice. COPY . still makes every included file part of this layer's cache key, so prefer explicit copies when practical.".into();
298 }
299}
300
301pub fn has_errors(findings: &[Finding]) -> bool {
302 findings.iter().any(|f| f.severity == Severity::Error)
303}