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, DockerignoreProblem};
9use crate::rules::{self, Finding, Severity};
10
11pub struct LintOptions {
12    pub skip_rules: Vec<String>,
13    /// When non-empty, only rules whose IDs appear in this list are run.
14    pub only_rules: Vec<String>,
15    pub min_severity: Severity,
16    pub check_dockerignore: bool,
17    pub severity_overrides: BTreeMap<String, Severity>,
18    pub categories: Vec<String>,
19    pub skip_categories: Vec<String>,
20    pub inline_suppressions: bool,
21    pub require_suppression_reason: bool,
22    pub suppression_reason_pattern: Option<String>,
23    pub require_suppression_expiration: bool,
24    pub max_suppression_days: Option<u64>,
25    pub report_unused_suppressions: bool,
26    pub approved_registries: Option<Vec<String>>,
27    pub approved_base_images: Option<Vec<String>>,
28    pub required_labels: BTreeMap<String, String>,
29    pub strict_labels: bool,
30}
31
32impl Default for LintOptions {
33    fn default() -> Self {
34        Self {
35            skip_rules: Vec::new(),
36            only_rules: Vec::new(),
37            min_severity: Severity::Info,
38            check_dockerignore: true,
39            severity_overrides: BTreeMap::new(),
40            categories: Vec::new(),
41            skip_categories: Vec::new(),
42            inline_suppressions: true,
43            require_suppression_reason: false,
44            suppression_reason_pattern: None,
45            require_suppression_expiration: false,
46            max_suppression_days: None,
47            report_unused_suppressions: false,
48            approved_registries: None,
49            approved_base_images: None,
50            required_labels: BTreeMap::new(),
51            strict_labels: false,
52        }
53    }
54}
55
56pub struct LintResult {
57    pub file: String,
58    pub findings: Vec<Finding>,
59}
60
61/// Lint Dockerfile content that has already been read into a string.
62///
63/// `filename` is used only for display and for locating `.dockerignore`.
64/// Pass `"<stdin>"` when linting content read from standard input.
65pub fn lint_content(content: &str, filename: &str, opts: &LintOptions) -> LintResult {
66    let mut result = lint_content_without_context(content, filename, opts);
67    if opts.check_dockerignore && rule_id_enabled(opts, "DF033") {
68        let context = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
69        let stdin_marker = context.join(".droast-stdin");
70        add_dockerignore_finding(&mut result, &stdin_marker, &context, opts);
71    }
72    finalize_findings(content, &mut result.findings, opts);
73    result
74}
75
76fn lint_content_without_context(content: &str, filename: &str, opts: &LintOptions) -> LintResult {
77    let instructions = parser::parse(content);
78    let mut findings: Vec<Finding> = Vec::new();
79
80    for rule in rules::all_rules() {
81        if !rule_enabled(opts, &rule) {
82            continue;
83        }
84        if rule.id == "DF065" && opts.approved_registries.is_some() {
85            continue;
86        }
87        let rule_findings = (rule.func)(&instructions, content);
88        findings.extend(rule_findings);
89    }
90
91    findings.extend(crate::policy::configured_findings(&instructions, opts));
92
93    LintResult {
94        file: filename.to_string(),
95        findings,
96    }
97}
98
99/// Read `path` from disk and lint it. Thin wrapper around `lint_content`.
100pub fn lint_file(path: &Path, opts: &LintOptions) -> Result<LintResult> {
101    let context = path.parent().unwrap_or_else(|| Path::new("."));
102    lint_file_with_context(path, context, opts)
103}
104
105/// Read and lint a Dockerfile using the context selected by Compose, Bake, or
106/// repository discovery when resolving its effective `.dockerignore`.
107pub fn lint_file_with_context(
108    path: &Path,
109    context: &Path,
110    opts: &LintOptions,
111) -> Result<LintResult> {
112    let content = std::fs::read_to_string(path)
113        .with_context(|| format!("Failed to read '{}'", path.display()))?;
114    let mut result = lint_content_without_context(&content, &path.display().to_string(), opts);
115    if opts.check_dockerignore && rule_id_enabled(opts, "DF033") {
116        add_dockerignore_finding(&mut result, path, context, opts);
117    }
118    finalize_findings(&content, &mut result.findings, opts);
119    Ok(result)
120}
121
122fn rule_enabled(opts: &LintOptions, rule: &rules::Rule) -> bool {
123    let selected_by_id = opts.only_rules.is_empty()
124        || opts
125            .only_rules
126            .iter()
127            .any(|selected| selected.eq_ignore_ascii_case(rule.id));
128    let selected_by_category = !opts.only_rules.is_empty()
129        || opts.categories.is_empty()
130        || matches!(rule.id, "DF071" | "DF072")
131        || rule.categories().iter().any(|category| {
132            opts.categories
133                .iter()
134                .any(|selected| selected.eq_ignore_ascii_case(category))
135        });
136    let skipped_by_category = opts.only_rules.is_empty()
137        && rule.categories().iter().any(|category| {
138            opts.skip_categories
139                .iter()
140                .any(|skipped| skipped.eq_ignore_ascii_case(category))
141        });
142    selected_by_id
143        && selected_by_category
144        && !skipped_by_category
145        && !opts
146            .skip_rules
147            .iter()
148            .any(|skipped| skipped.eq_ignore_ascii_case(rule.id))
149}
150
151pub(crate) fn rule_id_enabled(opts: &LintOptions, id: &str) -> bool {
152    rules::all_rules()
153        .iter()
154        .find(|rule| rule.id.eq_ignore_ascii_case(id))
155        .is_some_and(|rule| rule_enabled(opts, rule))
156}
157
158fn finalize_findings(content: &str, findings: &mut Vec<Finding>, opts: &LintOptions) {
159    crate::policy::apply_inline_suppressions(content, findings, opts);
160    for finding in findings.iter_mut() {
161        if let Some(severity) = opts.severity_overrides.get(finding.rule) {
162            finding.severity = *severity;
163        }
164    }
165    findings.retain(|finding| finding.severity >= opts.min_severity);
166    findings.sort_by(|a, b| a.line.cmp(&b.line).then(b.severity.cmp(&a.severity)));
167}
168
169fn add_dockerignore_finding(
170    result: &mut LintResult,
171    dockerfile: &Path,
172    context: &Path,
173    opts: &LintOptions,
174) {
175    if !rule_id_enabled(opts, "DF033") {
176        return;
177    }
178    let problem = match repository::dockerignore_problem(dockerfile, context) {
179        Ok(problem) => problem,
180        Err(error) => {
181            result.findings.push(Finding {
182                rule: "DF033",
183                severity: Severity::Info,
184                line: 0,
185                message: format!("Cannot read the effective .dockerignore: {error}"),
186                roast: "The build-context filter is unreadable, so nobody can tell what Docker will receive.".to_string(),
187            });
188            return;
189        }
190    };
191    let Some(problem) = problem else {
192        return;
193    };
194    let message = match problem {
195        DockerignoreProblem::Missing { expected } => format!(
196            "No effective .dockerignore for build context '{}' (expected '{}')",
197            context.display(),
198            expected.display()
199        ),
200        DockerignoreProblem::Empty { path } => format!(
201            "Effective .dockerignore '{}' has no exclusion patterns",
202            path.display()
203        ),
204    };
205    result.findings.push(Finding {
206        rule: "DF033",
207        severity: Severity::Info,
208        line: 0,
209        message,
210        roast: "This build context has no effective exclusions, so caches, repositories, dependencies, and secrets can all join the image-build road trip.".to_string(),
211    });
212}
213
214pub fn has_errors(findings: &[Finding]) -> bool {
215    findings.iter().any(|f| f.severity == Severity::Error)
216}