1use super::types::{
6 AppConfig, BehaviorConfig, ComplianceConfig, DiffConfig, EnrichmentConfig, FilterConfig,
7 MatchingConfig, MatrixConfig, MultiDiffConfig, OutputConfig, TimelineConfig, TuiConfig,
8 ViewConfig,
9};
10
11#[derive(Debug, Clone)]
17pub struct ConfigError {
18 pub field: String,
20 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
32pub trait Validatable {
38 fn validate(&self) -> Vec<ConfigError>;
40
41 fn is_valid(&self) -> bool {
43 self.validate().is_empty()
44 }
45}
46
47impl 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 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 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 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 Vec::new()
141 }
142}
143
144impl Validatable for ComplianceConfig {
145 fn validate(&self) -> Vec<ConfigError> {
146 let mut errors = Vec::new();
147
148 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 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 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 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
280fn 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 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
298 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 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 errors.extend(self.matching.validate());
324 errors.extend(self.filtering.validate());
325
326 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 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#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn is_valid_http_url_accepts_and_rejects() {
465 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 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 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 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 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 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(), ],
612 profile: Some("cyber-resilience".to_string()), 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 let mut invalid = AppConfig::default();
660 invalid.matching.threshold = Some(5.0);
661 assert!(!invalid.is_valid());
662 }
663}