Skip to main content

dockerfile_roast/
config.rs

1//! Optional project and organization policy configuration.
2
3use anyhow::{bail, Context};
4use glob::Pattern;
5use serde::{Deserialize, Deserializer};
6use std::collections::{BTreeMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use crate::rules::{all_rules, ALL_CATEGORIES};
10
11#[derive(Debug, Clone, Default, Deserialize)]
12#[serde(rename_all = "kebab-case", deny_unknown_fields)]
13pub struct PolicySettings {
14    pub skip: Option<Vec<String>>,
15    pub min_severity: Option<String>,
16    pub no_roast: Option<bool>,
17    pub no_fail: Option<bool>,
18    pub format: Option<String>,
19    pub categories: Option<Vec<String>>,
20    pub skip_categories: Option<Vec<String>>,
21    #[serde(default)]
22    pub severity_overrides: BTreeMap<String, String>,
23    pub inline_suppressions: Option<bool>,
24    pub require_suppression_reason: Option<bool>,
25    pub suppression_reason_pattern: Option<String>,
26    pub require_suppression_expiration: Option<bool>,
27    pub max_suppression_days: Option<u64>,
28    pub report_unused_suppressions: Option<bool>,
29    pub approved_registries: Option<Vec<String>>,
30    pub extend_approved_registries: Option<Vec<String>>,
31    pub approved_base_images: Option<Vec<String>>,
32    pub extend_approved_base_images: Option<Vec<String>>,
33    #[serde(default)]
34    pub required_labels: BTreeMap<String, String>,
35    pub strict_labels: Option<bool>,
36}
37
38/// Optional external ShellCheck integration. It is deliberately separate from
39/// policy settings because it applies to the complete Dockerfile scan.
40#[derive(Debug, Clone, Default, Deserialize)]
41#[serde(rename_all = "kebab-case", deny_unknown_fields)]
42pub struct ShellcheckSettings {
43    pub mode: Option<String>,
44    #[serde(default)]
45    pub exclude: Vec<String>,
46}
47
48/// Container-engine workflow settings that affect repository context handling.
49#[derive(Debug, Clone, Default, Deserialize)]
50#[serde(rename_all = "kebab-case", deny_unknown_fields)]
51pub struct WorkflowSettings {
52    pub engine: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Deserialize)]
56#[serde(rename_all = "kebab-case", deny_unknown_fields)]
57pub struct PathOverride {
58    pub paths: Vec<String>,
59    pub preset: Option<String>,
60    #[serde(flatten)]
61    pub settings: PolicySettings,
62    #[serde(skip)]
63    base_dir: PathBuf,
64}
65
66#[derive(Debug, Clone, Default, Deserialize)]
67#[serde(rename_all = "kebab-case", deny_unknown_fields)]
68pub struct DroastConfig {
69    #[serde(default, deserialize_with = "deserialize_one_or_many")]
70    pub extends: Vec<String>,
71    pub preset: Option<String>,
72    #[serde(flatten)]
73    pub settings: PolicySettings,
74    #[serde(default)]
75    pub shellcheck: ShellcheckSettings,
76    #[serde(default)]
77    pub workflow: WorkflowSettings,
78    #[serde(default)]
79    pub overrides: Vec<PathOverride>,
80    #[serde(skip)]
81    source_path: Option<PathBuf>,
82}
83
84#[derive(Deserialize)]
85#[serde(untagged)]
86enum OneOrMany {
87    One(String),
88    Many(Vec<String>),
89}
90
91fn deserialize_one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
92where
93    D: Deserializer<'de>,
94{
95    Ok(match OneOrMany::deserialize(deserializer)? {
96        OneOrMany::One(value) => vec![value],
97        OneOrMany::Many(values) => values,
98    })
99}
100
101impl PolicySettings {
102    pub fn validate(&self, scope: &str) -> anyhow::Result<()> {
103        validate_settings(self, scope)
104    }
105
106    pub fn merge(&mut self, child: PolicySettings) {
107        merge_list(&mut self.skip, child.skip);
108        replace(&mut self.min_severity, child.min_severity);
109        replace(&mut self.no_roast, child.no_roast);
110        replace(&mut self.no_fail, child.no_fail);
111        replace(&mut self.format, child.format);
112        replace(&mut self.categories, child.categories);
113        merge_list(&mut self.skip_categories, child.skip_categories);
114        self.severity_overrides.extend(child.severity_overrides);
115        merge_restrictive_switch(
116            &mut self.inline_suppressions,
117            child.inline_suppressions,
118            false,
119        );
120        merge_restrictive_switch(
121            &mut self.require_suppression_reason,
122            child.require_suppression_reason,
123            true,
124        );
125        if self.suppression_reason_pattern.is_none() {
126            self.suppression_reason_pattern = child.suppression_reason_pattern;
127        }
128        merge_restrictive_switch(
129            &mut self.require_suppression_expiration,
130            child.require_suppression_expiration,
131            true,
132        );
133        if let Some(child_days) = child.max_suppression_days {
134            self.max_suppression_days = Some(
135                self.max_suppression_days
136                    .map_or(child_days, |current| current.min(child_days)),
137            );
138        }
139        merge_restrictive_switch(
140            &mut self.report_unused_suppressions,
141            child.report_unused_suppressions,
142            true,
143        );
144        replace(&mut self.approved_registries, child.approved_registries);
145        merge_list(
146            &mut self.approved_registries,
147            child.extend_approved_registries,
148        );
149        replace(&mut self.approved_base_images, child.approved_base_images);
150        merge_list(
151            &mut self.approved_base_images,
152            child.extend_approved_base_images,
153        );
154        for (name, format) in child.required_labels {
155            self.required_labels.entry(name).or_insert(format);
156        }
157        merge_restrictive_switch(&mut self.strict_labels, child.strict_labels, true);
158    }
159
160    /// Overlay explicit settings on preset defaults without applying inherited
161    /// policy restrictions. Extension lists stay separate until this layer is
162    /// merged into its parent.
163    fn overlay_preset(&mut self, explicit: PolicySettings) {
164        merge_list(&mut self.skip, explicit.skip);
165        replace(&mut self.min_severity, explicit.min_severity);
166        replace(&mut self.no_roast, explicit.no_roast);
167        replace(&mut self.no_fail, explicit.no_fail);
168        replace(&mut self.format, explicit.format);
169        replace(&mut self.categories, explicit.categories);
170        merge_list(&mut self.skip_categories, explicit.skip_categories);
171        self.severity_overrides.extend(explicit.severity_overrides);
172        replace(&mut self.inline_suppressions, explicit.inline_suppressions);
173        replace(
174            &mut self.require_suppression_reason,
175            explicit.require_suppression_reason,
176        );
177        replace(
178            &mut self.suppression_reason_pattern,
179            explicit.suppression_reason_pattern,
180        );
181        replace(
182            &mut self.require_suppression_expiration,
183            explicit.require_suppression_expiration,
184        );
185        replace(
186            &mut self.max_suppression_days,
187            explicit.max_suppression_days,
188        );
189        replace(
190            &mut self.report_unused_suppressions,
191            explicit.report_unused_suppressions,
192        );
193        replace(&mut self.approved_registries, explicit.approved_registries);
194        merge_list(
195            &mut self.extend_approved_registries,
196            explicit.extend_approved_registries,
197        );
198        replace(
199            &mut self.approved_base_images,
200            explicit.approved_base_images,
201        );
202        merge_list(
203            &mut self.extend_approved_base_images,
204            explicit.extend_approved_base_images,
205        );
206        self.required_labels.extend(explicit.required_labels);
207        replace(&mut self.strict_labels, explicit.strict_labels);
208    }
209}
210
211impl DroastConfig {
212    /// Backward-compatible best-effort discovery for library callers.
213    pub fn load() -> Self {
214        Self::try_load().unwrap_or_default()
215    }
216
217    /// Discover and strictly validate the nearest `droast.toml`.
218    pub fn try_load() -> anyhow::Result<Self> {
219        match Self::find() {
220            Some(path) => Self::load_from(&path),
221            None => Ok(Self::default()),
222        }
223    }
224
225    pub fn load_from(path: &Path) -> anyhow::Result<Self> {
226        let mut stack = Vec::new();
227        let config = Self::load_recursive(path, &mut stack)?;
228        config.validate()?;
229        Ok(config)
230    }
231
232    fn load_recursive(path: &Path, stack: &mut Vec<PathBuf>) -> anyhow::Result<Self> {
233        let path = absolute_path(path)?
234            .canonicalize()
235            .with_context(|| format!("Cannot resolve config file '{}'", path.display()))?;
236        if let Some(position) = stack.iter().position(|candidate| candidate == &path) {
237            let mut cycle = stack[position..]
238                .iter()
239                .map(|item| item.display().to_string())
240                .collect::<Vec<_>>();
241            cycle.push(path.display().to_string());
242            bail!("Configuration inheritance cycle: {}", cycle.join(" -> "));
243        }
244        stack.push(path.clone());
245
246        let content = std::fs::read_to_string(&path)
247            .with_context(|| format!("Failed to read config file '{}'", path.display()))?;
248        let mut local: DroastConfig = toml::from_str(&content).map_err(|error| {
249            anyhow::anyhow!("Invalid config file '{}': {error}", path.display())
250        })?;
251        local.source_path = Some(path.clone());
252        let base_dir = path
253            .parent()
254            .unwrap_or_else(|| Path::new("."))
255            .to_path_buf();
256        for item in &mut local.overrides {
257            item.base_dir = base_dir.clone();
258        }
259
260        let mut merged = DroastConfig::default();
261        for inherited in &local.extends {
262            if inherited.starts_with("http://") || inherited.starts_with("https://") {
263                bail!(
264                    "Remote configuration '{}' is not fetched automatically; download a pinned file in CI and extend its local path",
265                    inherited
266                );
267            }
268            let inherited_path = if Path::new(inherited).is_absolute() {
269                PathBuf::from(inherited)
270            } else {
271                base_dir.join(inherited)
272            };
273            merged.merge(Self::load_recursive(&inherited_path, stack)?);
274        }
275
276        let mut layer = preset_settings(local.preset.as_deref())?;
277        layer.overlay_preset(local.settings);
278        merged.settings.merge(layer);
279        replace(&mut merged.shellcheck.mode, local.shellcheck.mode);
280        replace(&mut merged.workflow.engine, local.workflow.engine);
281        for code in local.shellcheck.exclude {
282            if !merged
283                .shellcheck
284                .exclude
285                .iter()
286                .any(|current| current.eq_ignore_ascii_case(&code))
287            {
288                merged.shellcheck.exclude.push(code);
289            }
290        }
291        merged.overrides.extend(local.overrides);
292        merged.source_path = Some(path);
293        stack.pop();
294        Ok(merged)
295    }
296
297    fn merge(&mut self, child: DroastConfig) {
298        self.settings.merge(child.settings);
299        replace(&mut self.shellcheck.mode, child.shellcheck.mode);
300        replace(&mut self.workflow.engine, child.workflow.engine);
301        for code in child.shellcheck.exclude {
302            if !self
303                .shellcheck
304                .exclude
305                .iter()
306                .any(|current| current.eq_ignore_ascii_case(&code))
307            {
308                self.shellcheck.exclude.push(code);
309            }
310        }
311        self.overrides.extend(child.overrides);
312        if child.source_path.is_some() {
313            self.source_path = child.source_path;
314        }
315    }
316
317    pub fn effective_for(&self, path: &Path) -> anyhow::Result<PolicySettings> {
318        let mut effective = self.settings.clone();
319        for path_override in &self.overrides {
320            if path_override.matches(path)? {
321                let mut layer = preset_settings(path_override.preset.as_deref())?;
322                layer.overlay_preset(path_override.settings.clone());
323                effective.merge(layer);
324            }
325        }
326        validate_settings(&effective, "effective configuration")?;
327        Ok(effective)
328    }
329
330    pub fn validate(&self) -> anyhow::Result<()> {
331        preset_settings(self.preset.as_deref())?;
332        validate_settings(&self.settings, "configuration")?;
333        if let Some(mode) = &self.shellcheck.mode {
334            if !matches!(
335                mode.to_ascii_lowercase().as_str(),
336                "off" | "auto" | "required"
337            ) {
338                bail!("Unknown ShellCheck mode '{mode}'; expected off, auto, or required");
339            }
340        }
341        crate::repository::ContainerEngine::parse(self.workflow.engine.as_deref())?;
342        for code in &self.shellcheck.exclude {
343            if !regex::Regex::new(r"^SC[0-9]{4}$")
344                .expect("valid static expression")
345                .is_match(&code.to_ascii_uppercase())
346            {
347                bail!("Invalid ShellCheck exclusion '{code}'; expected an SC#### rule ID");
348            }
349        }
350        for path_override in &self.overrides {
351            if path_override.paths.is_empty() {
352                bail!("Every [[overrides]] block must contain at least one path pattern");
353            }
354            for pattern in &path_override.paths {
355                Pattern::new(pattern)
356                    .with_context(|| format!("Invalid path override glob pattern '{pattern}'"))?;
357            }
358            preset_settings(path_override.preset.as_deref())?;
359            validate_settings(&path_override.settings, "path override")?;
360        }
361        Ok(())
362    }
363
364    fn find() -> Option<PathBuf> {
365        let cwd = std::env::current_dir().ok()?;
366        let mut dir: &Path = &cwd;
367        loop {
368            let candidate = dir.join("droast.toml");
369            if candidate.is_file() {
370                return Some(candidate);
371            }
372            if dir.join(".git").exists() {
373                break;
374            }
375            dir = dir.parent()?;
376        }
377        None
378    }
379}
380
381impl PathOverride {
382    fn matches(&self, path: &Path) -> anyhow::Result<bool> {
383        let absolute = absolute_path(path)?;
384        let absolute = absolute.canonicalize().unwrap_or(absolute);
385        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
386        let candidates = [
387            Some(normalized_path(&absolute)),
388            absolute
389                .strip_prefix(&self.base_dir)
390                .ok()
391                .map(normalized_path),
392            absolute.strip_prefix(&cwd).ok().map(normalized_path),
393        ];
394        for pattern in &self.paths {
395            let pattern = Pattern::new(pattern)
396                .with_context(|| format!("Invalid path override glob pattern '{pattern}'"))?;
397            if candidates
398                .iter()
399                .flatten()
400                .any(|path| pattern.matches(path))
401            {
402                return Ok(true);
403            }
404        }
405        Ok(false)
406    }
407}
408
409pub fn preset_settings(name: Option<&str>) -> anyhow::Result<PolicySettings> {
410    let Some(name) = name else {
411        return Ok(PolicySettings::default());
412    };
413    let mut settings = PolicySettings::default();
414    match name.to_ascii_lowercase().as_str() {
415        "minimal" => {
416            settings.min_severity = Some("error".into());
417            settings.no_roast = Some(true);
418        }
419        "security" => {
420            settings.categories = Some(vec!["security".into(), "supply-chain".into()]);
421            settings.min_severity = Some("warning".into());
422            settings.no_roast = Some(true);
423        }
424        "performance" => {
425            settings.categories = Some(vec!["performance".into()]);
426            settings.min_severity = Some("info".into());
427            settings.no_roast = Some(true);
428        }
429        "production" => {
430            settings.min_severity = Some("warning".into());
431            settings.no_roast = Some(true);
432        }
433        "strict" => {
434            settings.min_severity = Some("info".into());
435            settings.no_roast = Some(true);
436            settings.require_suppression_reason = Some(true);
437            settings.require_suppression_expiration = Some(true);
438            settings.report_unused_suppressions = Some(true);
439        }
440        _ => bail!(
441            "Unknown preset '{name}'; expected minimal, security, performance, production, or strict"
442        ),
443    }
444    Ok(settings)
445}
446
447fn validate_settings(settings: &PolicySettings, scope: &str) -> anyhow::Result<()> {
448    let known_rules = all_rules()
449        .into_iter()
450        .map(|rule| rule.id.to_string())
451        .collect::<HashSet<_>>();
452    for id in settings
453        .skip
454        .iter()
455        .flatten()
456        .chain(settings.severity_overrides.keys())
457    {
458        if !known_rules.contains(&id.to_ascii_uppercase()) {
459            bail!("Unknown rule ID '{id}' in {scope}");
460        }
461    }
462    for severity in settings
463        .min_severity
464        .iter()
465        .chain(settings.severity_overrides.values())
466    {
467        if !matches!(
468            severity.to_ascii_lowercase().as_str(),
469            "info" | "warning" | "error"
470        ) {
471            bail!("Unknown severity '{severity}' in {scope}");
472        }
473    }
474    if let Some(format) = &settings.format {
475        if !matches!(
476            format.to_ascii_lowercase().as_str(),
477            "terminal" | "json" | "github" | "compact" | "sarif"
478        ) {
479            bail!("Unknown output format '{format}' in {scope}");
480        }
481    }
482    for category in settings
483        .categories
484        .iter()
485        .flatten()
486        .chain(settings.skip_categories.iter().flatten())
487    {
488        if !ALL_CATEGORIES
489            .iter()
490            .any(|known| known.eq_ignore_ascii_case(category))
491        {
492            bail!("Unknown rule category '{category}' in {scope}");
493        }
494    }
495    for (label, format) in &settings.required_labels {
496        if label.trim().is_empty() {
497            bail!("Required label names cannot be empty in {scope}");
498        }
499        validate_label_format(format)
500            .with_context(|| format!("Invalid format for required label '{label}' in {scope}"))?;
501    }
502    for (field, patterns) in [
503        ("approved-registries", &settings.approved_registries),
504        (
505            "extend-approved-registries",
506            &settings.extend_approved_registries,
507        ),
508        ("approved-base-images", &settings.approved_base_images),
509        (
510            "extend-approved-base-images",
511            &settings.extend_approved_base_images,
512        ),
513    ] {
514        for pattern in patterns.iter().flatten() {
515            if pattern.trim().is_empty() {
516                bail!("Empty glob pattern in {field} in {scope}");
517            }
518            Pattern::new(pattern).with_context(|| {
519                format!("Invalid glob pattern '{pattern}' in {field} in {scope}")
520            })?;
521        }
522    }
523    if settings
524        .max_suppression_days
525        .is_some_and(|days| days > 365_000)
526    {
527        bail!("max-suppression-days cannot exceed 365000 in {scope}");
528    }
529    if let Some(pattern) = &settings.suppression_reason_pattern {
530        regex::Regex::new(pattern)
531            .with_context(|| format!("Invalid suppression-reason-pattern in {scope}"))?;
532    }
533    Ok(())
534}
535
536fn validate_label_format(format: &str) -> anyhow::Result<()> {
537    if matches!(
538        format.to_ascii_lowercase().as_str(),
539        "text" | "url" | "semver" | "hash" | "rfc3339" | "spdx" | "email"
540    ) {
541        return Ok(());
542    }
543    if let Some(pattern) = format.strip_prefix("regex:") {
544        regex::Regex::new(pattern).context("invalid regular expression")?;
545        return Ok(());
546    }
547    bail!("expected text, url, semver, hash, rfc3339, spdx, email, or regex:<pattern>")
548}
549
550fn replace<T>(target: &mut Option<T>, child: Option<T>) {
551    if child.is_some() {
552        *target = child;
553    }
554}
555
556fn merge_restrictive_switch(target: &mut Option<bool>, child: Option<bool>, restrictive: bool) {
557    let Some(child) = child else {
558        return;
559    };
560    *target = Some(match *target {
561        Some(parent) if restrictive => parent || child,
562        Some(parent) => parent && child,
563        None => child,
564    });
565}
566
567fn merge_list(target: &mut Option<Vec<String>>, child: Option<Vec<String>>) {
568    let Some(child) = child else {
569        return;
570    };
571    let target = target.get_or_insert_with(Vec::new);
572    for value in child {
573        if !target
574            .iter()
575            .any(|current| current.eq_ignore_ascii_case(&value))
576        {
577            target.push(value);
578        }
579    }
580}
581
582fn absolute_path(path: &Path) -> anyhow::Result<PathBuf> {
583    if path.is_absolute() {
584        Ok(path.to_path_buf())
585    } else {
586        Ok(std::env::current_dir()
587            .context("Cannot resolve the current directory")?
588            .join(path))
589    }
590}
591
592fn normalized_path(path: &Path) -> String {
593    path.to_string_lossy().replace('\\', "/")
594}
595
596#[cfg(test)]
597mod tests {
598    use super::{DroastConfig, PolicySettings};
599    fn fixture(name: &str) -> std::path::PathBuf {
600        let path =
601            std::env::temp_dir().join(format!("droast-config-{name}-{}", std::process::id()));
602        let _ = std::fs::remove_dir_all(&path);
603        std::fs::create_dir_all(&path).unwrap();
604        path
605    }
606
607    #[test]
608    fn loads_legacy_fields_and_new_policy_fields() {
609        let config: DroastConfig = toml::from_str(
610            r#"
611skip = ["DF001"]
612no-roast = true
613preset = "production"
614categories = ["security"]
615
616[severity-overrides]
617DF013 = "warning"
618
619[required-labels]
620"org.opencontainers.image.source" = "url"
621"#,
622        )
623        .unwrap();
624
625        assert_eq!(config.settings.skip.as_deref().unwrap(), ["DF001"]);
626        assert_eq!(config.settings.no_roast, Some(true));
627        assert_eq!(config.preset.as_deref(), Some("production"));
628        assert_eq!(config.settings.severity_overrides["DF013"], "warning");
629    }
630
631    #[test]
632    fn inherited_lists_are_additive_and_scalars_are_overridden() {
633        let mut parent = PolicySettings {
634            skip: Some(vec!["DF001".into()]),
635            min_severity: Some("error".into()),
636            ..PolicySettings::default()
637        };
638        parent.merge(PolicySettings {
639            skip: Some(vec!["DF002".into()]),
640            min_severity: Some("warning".into()),
641            ..PolicySettings::default()
642        });
643
644        assert_eq!(parent.skip.unwrap(), ["DF001", "DF002"]);
645        assert_eq!(parent.min_severity.as_deref(), Some("warning"));
646    }
647
648    #[test]
649    fn allowlists_replace_by_default_and_extend_only_when_explicit() {
650        let mut settings = PolicySettings {
651            approved_registries: Some(vec!["docker.io".into(), "ghcr.io".into()]),
652            ..PolicySettings::default()
653        };
654        settings.merge(PolicySettings {
655            approved_registries: Some(vec!["registry.example.com".into()]),
656            ..PolicySettings::default()
657        });
658        assert_eq!(
659            settings.approved_registries.as_deref().unwrap(),
660            ["registry.example.com"]
661        );
662
663        settings.merge(PolicySettings {
664            extend_approved_registries: Some(vec!["mirror.example.com".into()]),
665            ..PolicySettings::default()
666        });
667        assert_eq!(
668            settings.approved_registries.unwrap(),
669            ["registry.example.com", "mirror.example.com"]
670        );
671    }
672
673    #[test]
674    fn inherited_governance_cannot_be_weakened() {
675        let mut parent = PolicySettings {
676            inline_suppressions: Some(false),
677            require_suppression_reason: Some(true),
678            require_suppression_expiration: Some(true),
679            max_suppression_days: Some(30),
680            report_unused_suppressions: Some(true),
681            strict_labels: Some(true),
682            ..PolicySettings::default()
683        };
684        parent.merge(PolicySettings {
685            inline_suppressions: Some(true),
686            require_suppression_reason: Some(false),
687            require_suppression_expiration: Some(false),
688            max_suppression_days: Some(90),
689            report_unused_suppressions: Some(false),
690            strict_labels: Some(false),
691            ..PolicySettings::default()
692        });
693
694        assert_eq!(parent.inline_suppressions, Some(false));
695        assert_eq!(parent.require_suppression_reason, Some(true));
696        assert_eq!(parent.require_suppression_expiration, Some(true));
697        assert_eq!(parent.max_suppression_days, Some(30));
698        assert_eq!(parent.report_unused_suppressions, Some(true));
699        assert_eq!(parent.strict_labels, Some(true));
700    }
701
702    #[test]
703    fn explicit_inheritance_merges_organization_and_repository_policy() {
704        let root = fixture("inheritance");
705        let parent = root.join("organization.toml");
706        let child = root.join("droast.toml");
707        std::fs::write(
708            &parent,
709            r#"
710skip = ["DF012"]
711approved-registries = ["registry.example.com"]
712require-suppression-reason = true
713"#,
714        )
715        .unwrap();
716        std::fs::write(
717            &child,
718            r#"
719extends = "organization.toml"
720skip = ["DF022"]
721extend-approved-registries = ["ghcr.io"]
722require-suppression-expiration = true
723"#,
724        )
725        .unwrap();
726
727        let config = DroastConfig::load_from(&child).unwrap();
728        assert_eq!(config.settings.skip.unwrap(), ["DF012", "DF022"]);
729        assert_eq!(
730            config.settings.approved_registries.unwrap(),
731            ["registry.example.com", "ghcr.io"]
732        );
733        assert_eq!(config.settings.require_suppression_reason, Some(true));
734        assert_eq!(config.settings.require_suppression_expiration, Some(true));
735        std::fs::remove_dir_all(root).unwrap();
736    }
737
738    #[test]
739    fn shellcheck_settings_are_inherited_and_validated() {
740        let root = fixture("shellcheck");
741        let parent = root.join("organization.toml");
742        let child = root.join("droast.toml");
743        std::fs::write(
744            &parent,
745            "[shellcheck]\nmode = \"auto\"\nexclude = [\"SC2086\"]\n",
746        )
747        .unwrap();
748        std::fs::write(
749            &child,
750            "extends = \"organization.toml\"\n[shellcheck]\nmode = \"required\"\nexclude = [\"SC2046\"]\n",
751        )
752        .unwrap();
753
754        let config = DroastConfig::load_from(&child).unwrap();
755        assert_eq!(config.shellcheck.mode.as_deref(), Some("required"));
756        assert_eq!(config.shellcheck.exclude, ["SC2086", "SC2046"]);
757
758        std::fs::write(&child, "[shellcheck]\nexclude = [\"not-a-rule\"]\n").unwrap();
759        assert!(DroastConfig::load_from(&child).is_err());
760        std::fs::remove_dir_all(root).unwrap();
761    }
762
763    #[test]
764    fn workflow_engine_is_inherited_and_validated() {
765        let root = fixture("workflow-engine");
766        let parent = root.join("organization.toml");
767        let child = root.join("droast.toml");
768        std::fs::write(&parent, "[workflow]\nengine = \"podman\"\n").unwrap();
769        std::fs::write(&child, "extends = \"organization.toml\"\n").unwrap();
770
771        let config = DroastConfig::load_from(&child).unwrap();
772        assert_eq!(config.workflow.engine.as_deref(), Some("podman"));
773
774        std::fs::write(&child, "[workflow]\nengine = \"unsupported\"\n").unwrap();
775        assert!(DroastConfig::load_from(&child).is_err());
776        std::fs::remove_dir_all(root).unwrap();
777    }
778
779    #[test]
780    fn inheritance_cycles_are_rejected() {
781        let root = fixture("cycle");
782        let first = root.join("first.toml");
783        let second = root.join("second.toml");
784        std::fs::write(&first, "extends = \"second.toml\"\n").unwrap();
785        std::fs::write(&second, "extends = \"first.toml\"\n").unwrap();
786
787        let error = DroastConfig::load_from(&first).unwrap_err().to_string();
788        assert!(error.contains("inheritance cycle"), "{error}");
789        std::fs::remove_dir_all(root).unwrap();
790    }
791
792    #[test]
793    fn path_overrides_apply_in_order() {
794        let root = fixture("paths");
795        std::fs::create_dir_all(root.join("services/legacy")).unwrap();
796        let config_path = root.join("droast.toml");
797        std::fs::write(
798            &config_path,
799            r#"
800min-severity = "warning"
801
802[[overrides]]
803paths = ["services/**/Dockerfile"]
804min-severity = "error"
805skip = ["DF012"]
806
807[[overrides]]
808paths = ["services/legacy/Dockerfile"]
809skip = ["DF022"]
810"#,
811        )
812        .unwrap();
813
814        let config = DroastConfig::load_from(&config_path).unwrap();
815        let settings = config
816            .effective_for(&root.join("services/legacy/Dockerfile"))
817            .unwrap();
818        assert_eq!(settings.min_severity.as_deref(), Some("error"));
819        assert_eq!(settings.skip.unwrap(), ["DF012", "DF022"]);
820        std::fs::remove_dir_all(root).unwrap();
821    }
822
823    #[test]
824    fn preset_is_a_default_that_explicit_values_can_override() {
825        let root = fixture("preset");
826        let config_path = root.join("droast.toml");
827        std::fs::write(
828            &config_path,
829            "preset = \"strict\"\nmin-severity = \"warning\"\nrequire-suppression-expiration = false\n",
830        )
831        .unwrap();
832
833        let config = DroastConfig::load_from(&config_path).unwrap();
834        assert_eq!(config.settings.min_severity.as_deref(), Some("warning"));
835        assert_eq!(config.settings.require_suppression_reason, Some(true));
836        assert_eq!(config.settings.require_suppression_expiration, Some(false));
837        std::fs::remove_dir_all(root).unwrap();
838    }
839
840    #[test]
841    fn unknown_rules_and_categories_are_rejected() {
842        let root = fixture("validation");
843        let config_path = root.join("droast.toml");
844        std::fs::write(&config_path, "skip = [\"DF999\"]\n").unwrap();
845        assert!(DroastConfig::load_from(&config_path)
846            .unwrap_err()
847            .to_string()
848            .contains("Unknown rule ID"));
849
850        std::fs::write(&config_path, "categories = [\"imaginary\"]\n").unwrap();
851        assert!(DroastConfig::load_from(&config_path)
852            .unwrap_err()
853            .to_string()
854            .contains("Unknown rule category"));
855        std::fs::remove_dir_all(root).unwrap();
856    }
857
858    #[test]
859    fn unknown_keys_and_invalid_policy_globs_are_rejected() {
860        assert!(toml::from_str::<DroastConfig>("minimum-severity = \"error\"\n").is_err());
861
862        let root = fixture("bad-glob");
863        let config_path = root.join("droast.toml");
864        std::fs::write(&config_path, "approved-registries = [\"[bad\"]\n").unwrap();
865        assert!(DroastConfig::load_from(&config_path)
866            .unwrap_err()
867            .to_string()
868            .contains("Invalid glob pattern"));
869        std::fs::remove_dir_all(root).unwrap();
870    }
871
872    #[test]
873    fn remote_inheritance_requires_an_explicit_pinned_download() {
874        let root = fixture("remote");
875        let config_path = root.join("droast.toml");
876        std::fs::write(
877            &config_path,
878            "extends = \"https://example.com/droast.toml\"\n",
879        )
880        .unwrap();
881
882        let error = DroastConfig::load_from(&config_path)
883            .unwrap_err()
884            .to_string();
885        assert!(error.contains("not fetched automatically"), "{error}");
886        std::fs::remove_dir_all(root).unwrap();
887    }
888
889    #[test]
890    fn published_configuration_examples_are_valid_toml() {
891        for (name, content) in [
892            (
893                "enterprise",
894                include_str!("../examples/droast-enterprise.toml"),
895            ),
896            (
897                "organization",
898                include_str!("../examples/droast-organization.toml"),
899            ),
900            (
901                "repository",
902                include_str!("../examples/droast-repository.toml"),
903            ),
904        ] {
905            let config: DroastConfig =
906                toml::from_str(content).unwrap_or_else(|error| panic!("{name}: {error}"));
907            config
908                .validate()
909                .unwrap_or_else(|error| panic!("{name}: {error}"));
910        }
911
912        let schema: serde_json::Value =
913            serde_json::from_str(include_str!("../schemas/droast.schema.json"))
914                .expect("published configuration schema must be valid JSON");
915        assert_eq!(schema["title"], "droast configuration");
916    }
917}