Skip to main content

dockerfile_roast/
linter.rs

1//! Top-level linting orchestration.
2
3use 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    /// When non-empty, only rules whose IDs appear in this list are run.
15    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
68/// Lint Dockerfile content that has already been read into a string.
69///
70/// `filename` is used only for display and for locating `.dockerignore`.
71/// Pass `"<stdin>"` when linting content read from standard input.
72pub 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
137/// Read `path` from disk and lint it. Thin wrapper around `lint_content`.
138pub 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
143/// Read and lint a Dockerfile using the context selected by Compose, Bake, or
144/// repository discovery when resolving its effective `.dockerignore`.
145pub 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}