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)]
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 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 pub fn load() -> Self {
193 Self::try_load().unwrap_or_default()
194 }
195
196 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}