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