1use 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 ®istries,
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
155fn 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}