Skip to main content

dockerfile_roast/
hadolint.rs

1//! Conversion of compatible Hadolint YAML settings to droast TOML.
2
3use anyhow::{bail, Context};
4use serde_yaml::{Mapping, Value};
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7
8pub struct ImportResult {
9    pub config: String,
10    pub imported_rules: usize,
11    pub unmapped_rules: Vec<String>,
12    pub unmapped_settings: Vec<String>,
13}
14
15pub fn import_config(path: &Path) -> anyhow::Result<ImportResult> {
16    let source = std::fs::read_to_string(path)
17        .with_context(|| format!("Failed to read Hadolint config '{}'", path.display()))?;
18    let document: Value = serde_yaml::from_str(&source)
19        .with_context(|| format!("Invalid Hadolint YAML config '{}'", path.display()))?;
20    let settings = document.as_mapping().ok_or_else(|| {
21        anyhow::anyhow!(
22            "Hadolint config '{}' must be a YAML mapping",
23            path.display()
24        )
25    })?;
26
27    let mut skip = BTreeSet::new();
28    let mut overrides = BTreeMap::new();
29    let mut imported_rules = 0;
30    let mut unmapped_rules = BTreeSet::new();
31    let mut unmapped_settings = BTreeSet::new();
32    for rule in strings(settings, "ignored")? {
33        apply_rule(
34            &rule,
35            None,
36            &mut skip,
37            &mut overrides,
38            &mut imported_rules,
39            &mut unmapped_rules,
40        );
41    }
42    if let Some(value) = get(settings, "override") {
43        let value = value
44            .as_mapping()
45            .ok_or_else(|| anyhow::anyhow!("Hadolint 'override' must be a mapping"))?;
46        for (hadolint_severity, droast_severity) in [
47            ("error", "error"),
48            ("warning", "warning"),
49            ("info", "info"),
50            ("style", "info"),
51        ] {
52            for rule in strings(value, hadolint_severity)? {
53                apply_rule(
54                    &rule,
55                    Some(droast_severity),
56                    &mut skip,
57                    &mut overrides,
58                    &mut imported_rules,
59                    &mut unmapped_rules,
60                );
61            }
62        }
63    }
64
65    let registries = strings(settings, "trustedRegistries")?;
66    let labels = map(settings, "label-schema")?;
67    let strict_labels = boolean(settings, "strict-labels")?;
68    let mut no_fail = boolean(settings, "no-fail")?;
69    let inline_suppressions = boolean(settings, "disable-ignore-pragma")?.map(|value| !value);
70    let mut min_severity = get(settings, "failure-threshold")
71        .and_then(Value::as_str)
72        .map(str::to_ascii_lowercase);
73    match min_severity.as_deref() {
74        Some("style") | Some("ignore") => min_severity = Some("info".into()),
75        Some("none") => {
76            min_severity = None;
77            no_fail = Some(true);
78        }
79        Some("error") | Some("warning") | Some("info") | None => {}
80        Some(value) => {
81            unmapped_settings.insert(format!("failure-threshold={value}"));
82            min_severity = None;
83        }
84    }
85    let format = get(settings, "format")
86        .and_then(Value::as_str)
87        .and_then(|value| match value {
88            "tty" => Some("terminal"),
89            "json" | "sarif" => Some(value),
90            _ => {
91                unmapped_settings.insert(format!("format={value}"));
92                None
93            }
94        });
95    for key in settings.keys().filter_map(Value::as_str) {
96        if !matches!(
97            key,
98            "ignored"
99                | "override"
100                | "trustedRegistries"
101                | "label-schema"
102                | "strict-labels"
103                | "no-fail"
104                | "disable-ignore-pragma"
105                | "failure-threshold"
106                | "format"
107        ) {
108            unmapped_settings.insert(key.into());
109        }
110    }
111
112    Ok(ImportResult {
113        config: render(
114            &skip,
115            &overrides,
116            &registries,
117            &labels,
118            strict_labels,
119            no_fail,
120            inline_suppressions,
121            min_severity.as_deref(),
122            format,
123        ),
124        imported_rules,
125        unmapped_rules: unmapped_rules.into_iter().collect(),
126        unmapped_settings: unmapped_settings.into_iter().collect(),
127    })
128}
129
130fn apply_rule(
131    rule: &str,
132    severity: Option<&str>,
133    skip: &mut BTreeSet<String>,
134    overrides: &mut BTreeMap<String, String>,
135    imported: &mut usize,
136    unmapped: &mut BTreeSet<String>,
137) {
138    let Some(aliases) = aliases(rule) else {
139        unmapped.insert(rule.to_ascii_uppercase());
140        return;
141    };
142    *imported += 1;
143    for alias in aliases {
144        match severity {
145            Some(severity) => {
146                overrides.insert((*alias).into(), severity.into());
147            }
148            None => {
149                skip.insert((*alias).into());
150            }
151        }
152    }
153}
154
155/// Hadolint rules with equivalent droast checks. One Hadolint rule may map to multiple checks.
156fn aliases(rule: &str) -> Option<&'static [&'static str]> {
157    match rule.to_ascii_uppercase().as_str() {
158        "DL3000" => Some(&["DF009"]),
159        "DL3001" => Some(&["DF060"]),
160        "DL3002" => Some(&["DF002"]),
161        "DL3003" => Some(&["DF008"]),
162        "DL3004" => Some(&["DF010"]),
163        "DL3006" | "DL3007" => Some(&["DF001"]),
164        "DL3008" | "DL3033" | "DL3037" | "DL3041" => Some(&["DF005"]),
165        "DL3009" => Some(&["DF004"]),
166        "DL3012" => Some(&["DF041"]),
167        "DL3013" => Some(&["DF051"]),
168        "DL3014" => Some(&["DF015"]),
169        "DL3015" => Some(&["DF016"]),
170        "DL3016" => Some(&["DF031"]),
171        "DL3018" => Some(&["DF052"]),
172        "DL3019" => Some(&["DF029"]),
173        "DL3021" => Some(&["DF048"]),
174        "DL3022" => Some(&["DF049"]),
175        "DL3023" => Some(&["DF050"]),
176        "DL3024" => Some(&["DF042"]),
177        "DL3025" => Some(&["DF018", "DF025"]),
178        "DL3026" => Some(&["DF065"]),
179        "DL3028" => Some(&["DF053"]),
180        "DL3029" => Some(&["DF061"]),
181        "DL3030" => Some(&["DF027"]),
182        "DL3032" => Some(&["DF047"]),
183        "DL3034" => Some(&["DF043"]),
184        "DL3035" => Some(&["DF044"]),
185        "DL3036" => Some(&["DF045"]),
186        "DL3040" => Some(&["DF046"]),
187        "DL3042" => Some(&["DF030"]),
188        "DL3043" => Some(&["DF068"]),
189        "DL3044" => Some(&["DF062"]),
190        "DL3045" => Some(&["DF063"]),
191        "DL3046" => Some(&["DF064"]),
192        "DL3047" => Some(&["DF056"]),
193        "DL3059" => Some(&["DF003"]),
194        "DL3060" => Some(&["DF055"]),
195        "DL3061" => Some(&["DF037"]),
196        "DL3062" => Some(&["DF054"]),
197        "DL4000" => Some(&["DF019"]),
198        "DL4001" => Some(&["DF058"]),
199        "DL4003" => Some(&["DF038"]),
200        "DL4004" => Some(&["DF039"]),
201        "DL4006" => Some(&["DF057"]),
202        _ => None,
203    }
204}
205
206fn get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
207    mapping.get(Value::String(key.into()))
208}
209fn strings(mapping: &Mapping, key: &str) -> anyhow::Result<Vec<String>> {
210    let Some(value) = get(mapping, key) else {
211        return Ok(Vec::new());
212    };
213    match value {
214        Value::String(value) => Ok(vec![value.clone()]),
215        Value::Sequence(values) => values
216            .iter()
217            .map(|value| {
218                value
219                    .as_str()
220                    .map(str::to_owned)
221                    .ok_or_else(|| anyhow::anyhow!("Hadolint '{key}' must contain strings"))
222            })
223            .collect(),
224        _ => bail!("Hadolint '{key}' must be a string or list of strings"),
225    }
226}
227fn map(mapping: &Mapping, field: &str) -> anyhow::Result<BTreeMap<String, String>> {
228    let Some(value) = get(mapping, field) else {
229        return Ok(BTreeMap::new());
230    };
231    value
232        .as_mapping()
233        .ok_or_else(|| anyhow::anyhow!("Hadolint '{field}' must be a mapping"))?
234        .iter()
235        .map(|(key, value)| {
236            Ok((
237                key.as_str()
238                    .ok_or_else(|| anyhow::anyhow!("Hadolint '{field}' keys must be strings"))?
239                    .into(),
240                value
241                    .as_str()
242                    .ok_or_else(|| anyhow::anyhow!("Hadolint '{field}' values must be strings"))?
243                    .into(),
244            ))
245        })
246        .collect()
247}
248fn boolean(mapping: &Mapping, key: &str) -> anyhow::Result<Option<bool>> {
249    get(mapping, key)
250        .map(|value| {
251            value
252                .as_bool()
253                .ok_or_else(|| anyhow::anyhow!("Hadolint '{key}' must be true or false"))
254        })
255        .transpose()
256}
257
258fn render(
259    skip: &BTreeSet<String>,
260    overrides: &BTreeMap<String, String>,
261    registries: &[String],
262    labels: &BTreeMap<String, String>,
263    strict: Option<bool>,
264    no_fail: Option<bool>,
265    inline: Option<bool>,
266    severity: Option<&str>,
267    format: Option<&str>,
268) -> String {
269    let mut output = String::from(
270        "# Generated from a Hadolint configuration. Review reported unmapped items.\n\n",
271    );
272    if !skip.is_empty() {
273        output.push_str(&format!(
274            "skip = {}\n",
275            list(skip.iter().map(String::as_str))
276        ));
277    }
278    if !registries.is_empty() {
279        output.push_str(&format!(
280            "approved-registries = {}\n",
281            list(registries.iter().map(String::as_str))
282        ));
283    }
284    for (key, value) in [
285        ("strict-labels", strict.map(|value| value.to_string())),
286        ("no-fail", no_fail.map(|value| value.to_string())),
287        ("inline-suppressions", inline.map(|value| value.to_string())),
288        ("min-severity", severity.map(quote)),
289        ("format", format.map(quote)),
290    ] {
291        if let Some(value) = value {
292            output.push_str(&format!("{key} = {value}\n"));
293        }
294    }
295    if !overrides.is_empty() {
296        output.push_str("\n[severity-overrides]\n");
297        for (rule, value) in overrides {
298            output.push_str(&format!("{rule} = {}\n", quote(value)));
299        }
300    }
301    if !labels.is_empty() {
302        output.push_str("\n[required-labels]\n");
303        for (label, value) in labels {
304            output.push_str(&format!("{} = {}\n", quote(label), quote(value)));
305        }
306    }
307    output
308}
309fn list<'a>(values: impl Iterator<Item = &'a str>) -> String {
310    format!("[{}]", values.map(quote).collect::<Vec<_>>().join(", "))
311}
312fn quote(value: &str) -> String {
313    serde_json::to_string(value).expect("strings are serializable")
314}
315
316#[cfg(test)]
317mod tests {
318    use super::import_config;
319    #[test]
320    fn imports_supported_settings_and_reports_unmapped_items() {
321        let root =
322            std::env::temp_dir().join(format!("droast-hadolint-import-{}", std::process::id()));
323        let _ = std::fs::remove_dir_all(&root);
324        std::fs::create_dir_all(&root).unwrap();
325        let source = root.join(".hadolint.yaml");
326        std::fs::write(&source, "ignored: [DL3003, DL3025, SC2086]\ntrustedRegistries: [docker.io, registry.example.com]\nfailure-threshold: warning\nno-fail: true\ndisable-ignore-pragma: true\nstrict-labels: true\nlabel-schema:\n  org.opencontainers.image.version: semver\noverride:\n  error: [DL3002]\n  style: [DL3042]\nformat: sarif\nno-color: true\n").unwrap();
327        let result = import_config(&source).unwrap();
328        assert!(result
329            .config
330            .contains("skip = [\"DF008\", \"DF018\", \"DF025\"]"));
331        assert!(result
332            .config
333            .contains("approved-registries = [\"docker.io\", \"registry.example.com\"]"));
334        assert!(result.config.contains("DF002 = \"error\""));
335        assert!(result.config.contains("DF030 = \"info\""));
336        assert_eq!(result.unmapped_rules, ["SC2086"]);
337        assert_eq!(result.unmapped_settings, ["no-color"]);
338        let config = root.join("droast.toml");
339        std::fs::write(&config, result.config).unwrap();
340        crate::config::DroastConfig::load_from(&config).unwrap();
341        std::fs::remove_dir_all(root).unwrap();
342    }
343}