1use 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)]
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#[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 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 pub fn load() -> Self {
214 Self::try_load().unwrap_or_default()
215 }
216
217 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}