Skip to main content

sbom_tools/config/
validation.rs

1//! Configuration validation for sbom-tools.
2//!
3//! Provides validation traits and implementations for all configuration types.
4
5use super::types::{
6    AppConfig, BehaviorConfig, ComplianceConfig, DiffConfig, EnrichmentConfig, FilterConfig,
7    MatchingConfig, MatrixConfig, MultiDiffConfig, OutputConfig, TimelineConfig, TuiConfig,
8    ViewConfig,
9};
10
11// ============================================================================
12// Configuration Error
13// ============================================================================
14
15/// Error type for configuration validation.
16#[derive(Debug, Clone)]
17pub struct ConfigError {
18    /// The field that failed validation
19    pub field: String,
20    /// Description of the validation error
21    pub message: String,
22}
23
24impl std::fmt::Display for ConfigError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}: {}", self.field, self.message)
27    }
28}
29
30impl std::error::Error for ConfigError {}
31
32// ============================================================================
33// Validation Trait
34// ============================================================================
35
36/// Trait for validatable configuration types.
37pub trait Validatable {
38    /// Validate the configuration, returning any errors found.
39    fn validate(&self) -> Vec<ConfigError>;
40
41    /// Check if the configuration is valid.
42    fn is_valid(&self) -> bool {
43        self.validate().is_empty()
44    }
45}
46
47// ============================================================================
48// Validation Implementations
49// ============================================================================
50
51impl Validatable for AppConfig {
52    fn validate(&self) -> Vec<ConfigError> {
53        let mut errors = Vec::new();
54        errors.extend(self.matching.validate());
55        errors.extend(self.filtering.validate());
56        errors.extend(self.output.validate());
57        errors.extend(self.behavior.validate());
58        errors.extend(self.tui.validate());
59        errors.extend(self.compliance.validate());
60
61        if let Some(ref enrichment) = self.enrichment {
62            errors.extend(enrichment.validate());
63        }
64
65        errors
66    }
67}
68
69impl Validatable for MatchingConfig {
70    fn validate(&self) -> Vec<ConfigError> {
71        let mut errors = Vec::new();
72        // fuzzy_preset: FuzzyPreset enum makes invalid values unrepresentable at parse time
73
74        if let Some(threshold) = self.threshold
75            && !(0.0..=1.0).contains(&threshold)
76        {
77            errors.push(ConfigError {
78                field: "matching.threshold".to_string(),
79                message: format!("Threshold must be between 0.0 and 1.0, got {threshold}"),
80            });
81        }
82
83        errors
84    }
85}
86
87impl Validatable for FilterConfig {
88    fn validate(&self) -> Vec<ConfigError> {
89        let mut errors = Vec::new();
90        if let Some(ref severity) = self.min_severity {
91            let valid_severities = ["critical", "high", "medium", "low", "info"];
92            if !valid_severities.contains(&severity.to_lowercase().as_str()) {
93                errors.push(ConfigError {
94                    field: "filtering.min_severity".to_string(),
95                    message: format!(
96                        "Invalid severity '{}'. Valid options: {}",
97                        severity,
98                        valid_severities.join(", ")
99                    ),
100                });
101            }
102        }
103        errors
104    }
105}
106
107impl Validatable for OutputConfig {
108    fn validate(&self) -> Vec<ConfigError> {
109        let mut errors = Vec::new();
110
111        // Validate output file path if specified
112        if let Some(ref file_path) = self.file
113            && let Some(parent) = file_path.parent()
114            && !parent.as_os_str().is_empty()
115            && !parent.exists()
116        {
117            errors.push(ConfigError {
118                field: "output.file".to_string(),
119                message: format!("Parent directory does not exist: {}", parent.display()),
120            });
121        }
122
123        // Warn about contradictory streaming configuration
124        if self.streaming.disabled && self.streaming.force {
125            errors.push(ConfigError {
126                field: "output.streaming".to_string(),
127                message: "Contradictory streaming config: both 'disabled' and 'force' are true. \
128                          'disabled' takes precedence."
129                    .to_string(),
130            });
131        }
132
133        errors
134    }
135}
136
137impl Validatable for BehaviorConfig {
138    fn validate(&self) -> Vec<ConfigError> {
139        // BehaviorConfig contains only boolean flags that don't need validation
140        Vec::new()
141    }
142}
143
144impl Validatable for ComplianceConfig {
145    fn validate(&self) -> Vec<ConfigError> {
146        let mut errors = Vec::new();
147
148        // Standards and profile parse through the same alias-aware parsers
149        // the CLI flags use, so a config typo fails at load time with the
150        // exact list of valid values.
151        for (i, standard) in self.standards.iter().enumerate() {
152            if let Err(e) = standard.parse::<crate::quality::StandardSelector>() {
153                errors.push(ConfigError {
154                    field: format!("compliance.standards[{i}]"),
155                    message: e,
156                });
157            }
158        }
159
160        if let Some(ref profile) = self.profile
161            && let Err(e) = profile.parse::<crate::quality::ScoringProfile>()
162        {
163            errors.push(ConfigError {
164                field: "compliance.profile".to_string(),
165                message: e,
166            });
167        }
168
169        if let Some(min_score) = self.min_score
170            && !(0.0..=100.0).contains(&min_score)
171        {
172            errors.push(ConfigError {
173                field: "compliance.min_score".to_string(),
174                message: format!("min_score must be between 0 and 100, got {min_score}"),
175            });
176        }
177
178        if let Some(ref class) = self.cra_product_class
179            && let Err(message) = crate::model::CraProductClass::parse_cli_strict(class)
180        {
181            errors.push(ConfigError {
182                field: "compliance.cra_product_class".to_string(),
183                message,
184            });
185        }
186
187        errors
188    }
189}
190
191impl Validatable for TuiConfig {
192    fn validate(&self) -> Vec<ConfigError> {
193        let mut errors = Vec::new();
194
195        // theme: ThemeName enum makes invalid values unrepresentable at parse time
196
197        if !(0.0..=1.0).contains(&self.initial_threshold) {
198            errors.push(ConfigError {
199                field: "tui.initial_threshold".to_string(),
200                message: format!(
201                    "Initial threshold must be between 0.0 and 1.0, got {}",
202                    self.initial_threshold
203                ),
204            });
205        }
206
207        errors
208    }
209}
210
211impl Validatable for EnrichmentConfig {
212    fn validate(&self) -> Vec<ConfigError> {
213        let mut errors = Vec::new();
214
215        let valid_providers = ["osv", "nvd"];
216        if !valid_providers.contains(&self.provider.as_str()) {
217            errors.push(ConfigError {
218                field: "enrichment.provider".to_string(),
219                message: format!(
220                    "Invalid provider '{}'. Valid options: {}",
221                    self.provider,
222                    valid_providers.join(", ")
223                ),
224            });
225        }
226
227        if self.max_concurrent == 0 {
228            errors.push(ConfigError {
229                field: "enrichment.max_concurrent".to_string(),
230                message: "Max concurrent requests must be at least 1".to_string(),
231            });
232        }
233
234        // Keep the validator in lockstep with the published JSON Schema
235        // (`schemars(range(min = 1))`): a TTL or timeout of 0 must fail
236        // `config check`, not silently disable caching / hang requests.
237        if self.cache_ttl_hours == 0 {
238            errors.push(ConfigError {
239                field: "enrichment.cache_ttl_hours".to_string(),
240                message: "Cache TTL must be at least 1 hour".to_string(),
241            });
242        }
243
244        if self.timeout_secs == 0 {
245            errors.push(ConfigError {
246                field: "enrichment.timeout_secs".to_string(),
247                message: "API timeout must be at least 1 second".to_string(),
248            });
249        }
250
251        // Every URL override must be a well-formed http(s) URL with a host.
252        // These point the enricher at a network endpoint that supplies
253        // SECURITY data (which CVEs affect you); an unvalidated override could
254        // otherwise be a non-http scheme (file://, etc.) or a malformed URL.
255        // Private/self-hosted hosts are intentionally allowed (legitimate
256        // internal mirrors); only scheme + host well-formedness is enforced.
257        for (field, url) in [
258            ("enrichment.api_base", &self.api_base),
259            ("enrichment.kev_url", &self.kev_url),
260            ("enrichment.epss_url", &self.epss_url),
261            ("enrichment.huggingface_url", &self.huggingface_url),
262        ] {
263            if let Some(url) = url
264                && !is_valid_http_url(url)
265            {
266                errors.push(ConfigError {
267                    field: field.to_string(),
268                    message: format!(
269                        "'{url}' is not a valid http(s) URL with a host; \
270                         enrichment endpoints must use http:// or https://"
271                    ),
272                });
273            }
274        }
275
276        errors
277    }
278}
279
280/// Whether `url` is a syntactically valid `http`/`https` URL with a non-empty
281/// host. Deliberately lightweight (no `url` crate — config validation runs
282/// without the enrichment feature): rejects non-http schemes, empty hosts, and
283/// whitespace/control characters, which is enough to stop scheme abuse and
284/// malformed overrides.
285fn is_valid_http_url(url: &str) -> bool {
286    let rest = match url.strip_prefix("https://") {
287        Some(r) => r,
288        None => match url.strip_prefix("http://") {
289            Some(r) => r,
290            None => return false,
291        },
292    };
293    if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
294        return false;
295    }
296    // Authority = everything up to the first '/', '?' or '#'.
297    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
298    // Strip optional userinfo and port; a non-empty host must remain.
299    let host = authority.rsplit('@').next().unwrap_or(authority);
300    let host = host.split(':').next().unwrap_or(host);
301    !host.is_empty()
302}
303
304impl Validatable for DiffConfig {
305    fn validate(&self) -> Vec<ConfigError> {
306        let mut errors = Vec::new();
307
308        // Validate paths exist
309        if !self.paths.old.exists() {
310            errors.push(ConfigError {
311                field: "paths.old".to_string(),
312                message: format!("File not found: {}", self.paths.old.display()),
313            });
314        }
315        if !self.paths.new.exists() {
316            errors.push(ConfigError {
317                field: "paths.new".to_string(),
318                message: format!("File not found: {}", self.paths.new.display()),
319            });
320        }
321
322        // Validate nested configs
323        errors.extend(self.matching.validate());
324        errors.extend(self.filtering.validate());
325
326        // Validate rules file if specified
327        if let Some(ref rules_file) = self.rules.rules_file
328            && !rules_file.exists()
329        {
330            errors.push(ConfigError {
331                field: "rules.rules_file".to_string(),
332                message: format!("Rules file not found: {}", rules_file.display()),
333            });
334        }
335
336        // Validate ecosystem rules file if specified
337        if let Some(ref config_file) = self.ecosystem_rules.config_file
338            && !config_file.exists()
339        {
340            errors.push(ConfigError {
341                field: "ecosystem_rules.config_file".to_string(),
342                message: format!("Ecosystem rules file not found: {}", config_file.display()),
343            });
344        }
345
346        errors
347    }
348}
349
350impl Validatable for ViewConfig {
351    fn validate(&self) -> Vec<ConfigError> {
352        let mut errors = Vec::new();
353        if !self.sbom_path.exists() {
354            errors.push(ConfigError {
355                field: "sbom_path".to_string(),
356                message: format!("File not found: {}", self.sbom_path.display()),
357            });
358        }
359        errors
360    }
361}
362
363impl Validatable for MultiDiffConfig {
364    fn validate(&self) -> Vec<ConfigError> {
365        let mut errors = Vec::new();
366
367        if !self.baseline.exists() {
368            errors.push(ConfigError {
369                field: "baseline".to_string(),
370                message: format!("Baseline file not found: {}", self.baseline.display()),
371            });
372        }
373
374        for (i, target) in self.targets.iter().enumerate() {
375            if !target.exists() {
376                errors.push(ConfigError {
377                    field: format!("targets[{i}]"),
378                    message: format!("Target file not found: {}", target.display()),
379                });
380            }
381        }
382
383        if self.targets.is_empty() {
384            errors.push(ConfigError {
385                field: "targets".to_string(),
386                message: "At least one target SBOM is required".to_string(),
387            });
388        }
389
390        errors.extend(self.matching.validate());
391        errors
392    }
393}
394
395impl Validatable for TimelineConfig {
396    fn validate(&self) -> Vec<ConfigError> {
397        let mut errors = Vec::new();
398
399        for (i, path) in self.sbom_paths.iter().enumerate() {
400            if !path.exists() {
401                errors.push(ConfigError {
402                    field: format!("sbom_paths[{i}]"),
403                    message: format!("SBOM file not found: {}", path.display()),
404                });
405            }
406        }
407
408        if self.sbom_paths.len() < 2 {
409            errors.push(ConfigError {
410                field: "sbom_paths".to_string(),
411                message: "Timeline analysis requires at least 2 SBOMs".to_string(),
412            });
413        }
414
415        errors.extend(self.matching.validate());
416        errors
417    }
418}
419
420impl Validatable for MatrixConfig {
421    fn validate(&self) -> Vec<ConfigError> {
422        let mut errors = Vec::new();
423
424        for (i, path) in self.sbom_paths.iter().enumerate() {
425            if !path.exists() {
426                errors.push(ConfigError {
427                    field: format!("sbom_paths[{i}]"),
428                    message: format!("SBOM file not found: {}", path.display()),
429                });
430            }
431        }
432
433        if self.sbom_paths.len() < 2 {
434            errors.push(ConfigError {
435                field: "sbom_paths".to_string(),
436                message: "Matrix comparison requires at least 2 SBOMs".to_string(),
437            });
438        }
439
440        if !(0.0..=1.0).contains(&self.cluster_threshold) {
441            errors.push(ConfigError {
442                field: "cluster_threshold".to_string(),
443                message: format!(
444                    "Cluster threshold must be between 0.0 and 1.0, got {}",
445                    self.cluster_threshold
446                ),
447            });
448        }
449
450        errors.extend(self.matching.validate());
451        errors
452    }
453}
454
455// ============================================================================
456// Tests
457// ============================================================================
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn is_valid_http_url_accepts_and_rejects() {
465        // Valid http(s) URLs with a host.
466        assert!(is_valid_http_url("https://api.osv.dev"));
467        assert!(is_valid_http_url("http://localhost:8080/v1"));
468        assert!(is_valid_http_url(
469            "https://user@internal.mirror:443/path?q=1"
470        ));
471        // Rejected: non-http scheme, missing host, malformed, whitespace.
472        assert!(!is_valid_http_url("file:///etc/passwd"));
473        assert!(!is_valid_http_url("ftp://example.com"));
474        assert!(!is_valid_http_url("https://"));
475        assert!(!is_valid_http_url("https:///path-only"));
476        assert!(!is_valid_http_url("api.osv.dev"));
477        assert!(!is_valid_http_url("https://exa mple.com"));
478        assert!(!is_valid_http_url(""));
479    }
480
481    #[test]
482    fn enrichment_config_rejects_bad_url_override() {
483        let mut config = EnrichmentConfig {
484            api_base: Some("file:///etc/passwd".to_string()),
485            ..Default::default()
486        };
487        let errors = config.validate();
488        assert!(
489            errors.iter().any(|e| e.field == "enrichment.api_base"),
490            "a non-http api_base override must be a config error"
491        );
492
493        // A valid override passes (this field, at least).
494        config.api_base = Some("https://osv.example.internal/v1".to_string());
495        let errors = config.validate();
496        assert!(!errors.iter().any(|e| e.field == "enrichment.api_base"));
497    }
498
499    #[test]
500    fn test_matching_config_validation() {
501        // FuzzyPreset enum makes invalid presets unrepresentable at compile time.
502        // Valid config should pass.
503        let config = MatchingConfig {
504            fuzzy_preset: super::super::FuzzyPreset::Balanced,
505            threshold: None,
506            include_unchanged: false,
507        };
508        assert!(config.is_valid());
509    }
510
511    #[test]
512    fn test_matching_config_threshold_validation() {
513        let valid = MatchingConfig {
514            fuzzy_preset: super::super::FuzzyPreset::Balanced,
515            threshold: Some(0.85),
516            include_unchanged: false,
517        };
518        assert!(valid.is_valid());
519
520        let invalid = MatchingConfig {
521            fuzzy_preset: super::super::FuzzyPreset::Balanced,
522            threshold: Some(1.5),
523            include_unchanged: false,
524        };
525        assert!(!invalid.is_valid());
526    }
527
528    #[test]
529    fn test_filter_config_validation() {
530        let config = FilterConfig {
531            only_changes: true,
532            min_severity: Some("high".to_string()),
533            exclude_vex_resolved: false,
534            fail_on_vex_gap: false,
535            fail_on_ml_regression: false,
536        };
537        assert!(config.is_valid());
538
539        let invalid = FilterConfig {
540            only_changes: true,
541            min_severity: Some("invalid".to_string()),
542            exclude_vex_resolved: false,
543            fail_on_vex_gap: false,
544            fail_on_ml_regression: false,
545        };
546        assert!(!invalid.is_valid());
547    }
548
549    #[test]
550    fn test_tui_config_validation() {
551        let valid = TuiConfig::default();
552        assert!(valid.is_valid());
553
554        // Theme is now ThemeName enum — invalid values are unrepresentable.
555        // Only threshold validation remains testable.
556        let invalid = TuiConfig {
557            initial_threshold: 2.0,
558            ..TuiConfig::default()
559        };
560        assert!(!invalid.is_valid());
561    }
562
563    #[test]
564    fn test_enrichment_config_validation() {
565        let valid = EnrichmentConfig::default();
566        assert!(valid.is_valid());
567
568        let invalid = EnrichmentConfig {
569            max_concurrent: 0,
570            ..EnrichmentConfig::default()
571        };
572        assert!(!invalid.is_valid());
573    }
574
575    #[test]
576    fn enrichment_config_rejects_zero_ttl_and_timeout() {
577        // The JSON Schema declares minimum 1 for both; the runtime validator
578        // must agree so `config check` and the schema can't disagree.
579        let zero_ttl = EnrichmentConfig {
580            cache_ttl_hours: 0,
581            ..EnrichmentConfig::default()
582        };
583        assert!(
584            zero_ttl
585                .validate()
586                .iter()
587                .any(|e| e.field == "enrichment.cache_ttl_hours"),
588            "cache_ttl_hours: 0 must be a config error"
589        );
590
591        let zero_timeout = EnrichmentConfig {
592            timeout_secs: 0,
593            ..EnrichmentConfig::default()
594        };
595        assert!(
596            zero_timeout
597                .validate()
598                .iter()
599                .any(|e| e.field == "enrichment.timeout_secs"),
600            "timeout_secs: 0 must be a config error"
601        );
602    }
603
604    #[test]
605    fn compliance_config_accepts_canonical_values_and_aliases() {
606        let config = ComplianceConfig {
607            standards: vec![
608                "ntia".to_string(),
609                "cra-phase1".to_string(),
610                "nist-ssdf".to_string(), // alias
611            ],
612            profile: Some("cyber-resilience".to_string()), // alias
613            min_score: Some(70.0),
614            cra_product_class: Some("important-class-1".to_string()),
615            ..Default::default()
616        };
617        assert!(config.is_valid(), "{:?}", config.validate());
618    }
619
620    #[test]
621    fn compliance_config_rejects_bad_values() {
622        let config = ComplianceConfig {
623            standards: vec!["not-a-standard".to_string()],
624            profile: Some("not-a-profile".to_string()),
625            min_score: Some(250.0),
626            cra_product_class: Some("mega-critical".to_string()),
627            ..Default::default()
628        };
629        let errors = config.validate();
630        for field in [
631            "compliance.standards[0]",
632            "compliance.profile",
633            "compliance.min_score",
634            "compliance.cra_product_class",
635        ] {
636            assert!(
637                errors.iter().any(|e| e.field == field),
638                "expected an error for {field}: {errors:?}"
639            );
640        }
641    }
642
643    #[test]
644    fn test_config_error_display() {
645        let error = ConfigError {
646            field: "test_field".to_string(),
647            message: "test error message".to_string(),
648        };
649        assert_eq!(error.to_string(), "test_field: test error message");
650    }
651
652    #[test]
653    fn test_app_config_validation() {
654        let valid = AppConfig::default();
655        assert!(valid.is_valid());
656
657        // Enum fields make invalid presets unrepresentable.
658        // Test with invalid threshold instead.
659        let mut invalid = AppConfig::default();
660        invalid.matching.threshold = Some(5.0);
661        assert!(!invalid.is_valid());
662    }
663}