1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct EcosystemRulesConfig {
15 #[serde(default = "default_version")]
17 pub version: String,
18
19 #[serde(default)]
21 pub settings: GlobalSettings,
22
23 #[serde(default)]
25 pub ecosystems: HashMap<String, EcosystemConfig>,
26
27 #[serde(default)]
29 pub cross_ecosystem: HashMap<String, HashMap<String, Option<String>>>,
30
31 #[serde(default)]
33 pub custom_rules: CustomRules,
34}
35
36fn default_version() -> String {
37 "1.0".to_string()
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct GlobalSettings {
43 #[serde(default)]
45 pub case_sensitive_default: bool,
46
47 #[serde(default = "default_true")]
49 pub normalize_unicode: bool,
50
51 #[serde(default = "default_true")]
53 pub enable_security_checks: bool,
54}
55
56impl Default for GlobalSettings {
57 fn default() -> Self {
58 Self {
59 case_sensitive_default: false,
60 normalize_unicode: true,
61 enable_security_checks: true,
62 }
63 }
64}
65
66fn default_true() -> bool {
67 true
68}
69
70#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72pub struct EcosystemConfig {
73 #[serde(default)]
75 pub normalization: NormalizationConfig,
76
77 #[serde(default)]
79 pub strip_prefixes: Vec<String>,
80
81 #[serde(default)]
83 pub strip_suffixes: Vec<String>,
84
85 #[serde(default)]
87 pub aliases: HashMap<String, Vec<String>>,
88
89 #[serde(default)]
91 pub package_groups: HashMap<String, PackageGroup>,
92
93 #[serde(default)]
95 pub versioning: VersioningConfig,
96
97 #[serde(default)]
99 pub security: SecurityConfig,
100
101 #[serde(default)]
103 pub import_mappings: Vec<ImportMapping>,
104
105 #[serde(default)]
107 pub group_migrations: Vec<GroupMigration>,
108}
109
110#[derive(Debug, Clone, Default, Deserialize, Serialize)]
112pub struct NormalizationConfig {
113 #[serde(default)]
115 pub case_sensitive: bool,
116
117 #[serde(default)]
120 pub equivalent_chars: Vec<Vec<String>>,
121
122 #[serde(default)]
124 pub collapse_separators: bool,
125
126 #[serde(default)]
128 pub use_full_coordinate: bool,
129
130 #[serde(default)]
132 pub strip_version_suffix: bool,
133
134 #[serde(default)]
136 pub scope_handling: ScopeHandling,
137}
138
139#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum ScopeHandling {
143 #[default]
145 Lowercase,
146 PreserveScopeCase,
148 PreserveCase,
150}
151
152#[derive(Debug, Clone, Default, Deserialize, Serialize)]
154pub struct PackageGroup {
155 pub canonical: String,
157
158 #[serde(default)]
160 pub members: Vec<String>,
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct VersioningConfig {
166 #[serde(default = "default_semver")]
168 pub spec: VersionSpec,
169
170 #[serde(default)]
172 pub prerelease_tags: Vec<String>,
173
174 #[serde(default)]
176 pub qualifier_order: Vec<String>,
177}
178
179fn default_semver() -> VersionSpec {
180 VersionSpec::Semver
181}
182
183impl Default for VersioningConfig {
184 fn default() -> Self {
185 Self {
186 spec: VersionSpec::Semver,
187 prerelease_tags: vec![],
188 qualifier_order: vec![],
189 }
190 }
191}
192
193#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
195#[serde(rename_all = "lowercase")]
196pub enum VersionSpec {
197 #[default]
199 Semver,
200 Pep440,
202 Maven,
204 Rubygems,
206 Gomod,
208 Generic,
210}
211
212#[derive(Debug, Clone, Default, Deserialize, Serialize)]
214pub struct SecurityConfig {
215 #[serde(default)]
217 pub known_typosquats: Vec<TyposquatEntry>,
218
219 #[serde(default)]
221 pub suspicious_patterns: Vec<String>,
222
223 #[serde(default)]
225 pub known_malicious: Vec<String>,
226}
227
228#[derive(Debug, Clone, Deserialize, Serialize)]
230pub struct TyposquatEntry {
231 pub malicious: String,
233
234 pub legitimate: String,
236
237 #[serde(default)]
239 pub description: Option<String>,
240}
241
242#[derive(Debug, Clone, Deserialize, Serialize)]
244pub struct ImportMapping {
245 pub pattern: String,
247
248 #[serde(rename = "type")]
250 pub mapping_type: String,
251}
252
253#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct GroupMigration {
256 pub from: String,
258
259 pub to: String,
261
262 #[serde(default)]
264 pub after_version: Option<String>,
265}
266
267#[derive(Debug, Clone, Default, Deserialize, Serialize)]
269pub struct CustomRules {
270 #[serde(default)]
272 pub internal_prefixes: Vec<String>,
273
274 #[serde(default)]
276 pub equivalences: Vec<CustomEquivalence>,
277
278 #[serde(default)]
280 pub ignored_packages: Vec<String>,
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize)]
285pub struct CustomEquivalence {
286 pub canonical: String,
288
289 pub aliases: Vec<String>,
291
292 #[serde(default)]
294 pub version_sensitive: bool,
295}
296
297impl EcosystemRulesConfig {
298 pub fn new() -> Self {
300 Self {
301 version: default_version(),
302 settings: GlobalSettings::default(),
303 ecosystems: HashMap::new(),
304 cross_ecosystem: HashMap::new(),
305 custom_rules: CustomRules::default(),
306 }
307 }
308
309 pub fn builtin() -> Self {
311 let mut config = Self::new();
312 config.load_builtin_rules();
313 config
314 }
315
316 pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
318 serde_yaml_ng::from_str(yaml)
319 }
320
321 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
323 serde_json::from_str(json)
324 }
325
326 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
328 let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
329
330 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
331
332 match extension.to_lowercase().as_str() {
333 "yaml" | "yml" => Self::from_yaml(&content).map_err(ConfigError::Yaml),
334 "json" => Self::from_json(&content).map_err(ConfigError::Json),
335 _ => {
336 Self::from_yaml(&content)
338 .map_err(ConfigError::Yaml)
339 .or_else(|_| Self::from_json(&content).map_err(ConfigError::Json))
340 }
341 }
342 }
343
344 pub fn load_with_precedence(paths: &[&str]) -> Result<Self, ConfigError> {
346 for path_str in paths {
347 let path = if path_str.starts_with('~') {
348 if let Some(home) = dirs::home_dir() {
349 home.join(&path_str[2..])
350 } else {
351 continue;
352 }
353 } else {
354 Path::new(path_str).to_path_buf()
355 };
356
357 if path.exists() {
358 return Self::from_file(&path);
359 }
360 }
361
362 Ok(Self::builtin())
364 }
365
366 fn load_builtin_rules(&mut self) {
368 self.ecosystems.insert(
370 "pypi".to_string(),
371 EcosystemConfig {
372 normalization: NormalizationConfig {
373 case_sensitive: false,
374 equivalent_chars: vec![vec!["-".to_string(), "_".to_string(), ".".to_string()]],
375 collapse_separators: true,
376 ..Default::default()
377 },
378 strip_prefixes: vec!["python-".to_string(), "py-".to_string(), "lib".to_string()],
379 strip_suffixes: vec![
380 "-python".to_string(),
381 "-py".to_string(),
382 "-py3".to_string(),
383 "-lib".to_string(),
384 ],
385 aliases: Self::pypi_aliases(),
386 versioning: VersioningConfig {
387 spec: VersionSpec::Pep440,
388 prerelease_tags: vec![
389 "a".to_string(),
390 "b".to_string(),
391 "rc".to_string(),
392 "alpha".to_string(),
393 "beta".to_string(),
394 "dev".to_string(),
395 "post".to_string(),
396 ],
397 ..Default::default()
398 },
399 security: SecurityConfig {
400 known_typosquats: vec![
401 TyposquatEntry {
402 malicious: "python-dateutils".to_string(),
403 legitimate: "python-dateutil".to_string(),
404 description: Some("Common typosquat".to_string()),
405 },
406 TyposquatEntry {
407 malicious: "request".to_string(),
408 legitimate: "requests".to_string(),
409 description: Some("Missing 's' typosquat".to_string()),
410 },
411 ],
412 ..Default::default()
413 },
414 ..Default::default()
415 },
416 );
417
418 self.ecosystems.insert(
420 "npm".to_string(),
421 EcosystemConfig {
422 normalization: NormalizationConfig {
423 case_sensitive: false,
424 scope_handling: ScopeHandling::PreserveScopeCase,
425 ..Default::default()
426 },
427 strip_prefixes: vec!["node-".to_string(), "@types/".to_string()],
428 strip_suffixes: vec!["-js".to_string(), ".js".to_string(), "-node".to_string()],
429 package_groups: Self::npm_package_groups(),
430 versioning: VersioningConfig {
431 spec: VersionSpec::Semver,
432 prerelease_tags: vec![
433 "alpha".to_string(),
434 "beta".to_string(),
435 "rc".to_string(),
436 "next".to_string(),
437 "canary".to_string(),
438 ],
439 ..Default::default()
440 },
441 security: SecurityConfig {
442 suspicious_patterns: vec![
443 r"^[a-z]{1,2}$".to_string(), ],
445 ..Default::default()
446 },
447 ..Default::default()
448 },
449 );
450
451 self.ecosystems.insert(
453 "cargo".to_string(),
454 EcosystemConfig {
455 normalization: NormalizationConfig {
456 case_sensitive: false,
457 equivalent_chars: vec![vec!["_".to_string(), "-".to_string()]],
459 ..Default::default()
460 },
461 strip_prefixes: vec!["rust-".to_string(), "lib".to_string()],
462 strip_suffixes: vec!["-rs".to_string(), "-rust".to_string()],
463 versioning: VersioningConfig {
464 spec: VersionSpec::Semver,
465 ..Default::default()
466 },
467 ..Default::default()
468 },
469 );
470
471 self.ecosystems.insert(
473 "maven".to_string(),
474 EcosystemConfig {
475 normalization: NormalizationConfig {
476 case_sensitive: true,
477 use_full_coordinate: true,
478 ..Default::default()
479 },
480 group_migrations: vec![GroupMigration {
481 from: "javax.*".to_string(),
482 to: "jakarta.*".to_string(),
483 after_version: Some("9".to_string()),
484 }],
485 versioning: VersioningConfig {
486 spec: VersionSpec::Maven,
487 qualifier_order: vec![
488 "alpha".to_string(),
489 "beta".to_string(),
490 "milestone".to_string(),
491 "rc".to_string(),
492 "snapshot".to_string(),
493 "final".to_string(),
494 "ga".to_string(),
495 "sp".to_string(),
496 ],
497 ..Default::default()
498 },
499 ..Default::default()
500 },
501 );
502
503 self.ecosystems.insert(
505 "golang".to_string(),
506 EcosystemConfig {
507 normalization: NormalizationConfig {
508 case_sensitive: true,
509 strip_version_suffix: true,
510 ..Default::default()
511 },
512 import_mappings: vec![
513 ImportMapping {
514 pattern: "github.com/*/*".to_string(),
515 mapping_type: "github".to_string(),
516 },
517 ImportMapping {
518 pattern: "golang.org/x/*".to_string(),
519 mapping_type: "stdlib_extension".to_string(),
520 },
521 ],
522 versioning: VersioningConfig {
523 spec: VersionSpec::Gomod,
524 ..Default::default()
525 },
526 ..Default::default()
527 },
528 );
529
530 self.ecosystems.insert(
532 "nuget".to_string(),
533 EcosystemConfig {
534 normalization: NormalizationConfig {
535 case_sensitive: false,
536 ..Default::default()
537 },
538 versioning: VersioningConfig {
539 spec: VersionSpec::Semver,
540 ..Default::default()
541 },
542 ..Default::default()
543 },
544 );
545
546 self.ecosystems.insert(
548 "rubygems".to_string(),
549 EcosystemConfig {
550 normalization: NormalizationConfig {
551 case_sensitive: true,
552 ..Default::default()
553 },
554 strip_prefixes: vec!["ruby-".to_string()],
555 strip_suffixes: vec!["-ruby".to_string(), "-rb".to_string()],
556 versioning: VersioningConfig {
557 spec: VersionSpec::Rubygems,
558 ..Default::default()
559 },
560 ..Default::default()
561 },
562 );
563
564 self.ecosystems.insert(
566 "composer".to_string(),
567 EcosystemConfig {
568 normalization: NormalizationConfig {
569 case_sensitive: false,
570 use_full_coordinate: true,
571 ..Default::default()
572 },
573 versioning: VersioningConfig {
574 spec: VersionSpec::Semver,
575 ..Default::default()
576 },
577 ..Default::default()
578 },
579 );
580
581 self.load_cross_ecosystem_mappings();
583 }
584
585 fn pypi_aliases() -> HashMap<String, Vec<String>> {
587 let mut aliases = HashMap::new();
588 aliases.insert(
589 "pillow".to_string(),
590 vec!["PIL".to_string(), "python-pillow".to_string()],
591 );
592 aliases.insert(
593 "scikit-learn".to_string(),
594 vec!["sklearn".to_string(), "scikit_learn".to_string()],
595 );
596 aliases.insert(
597 "beautifulsoup4".to_string(),
598 vec![
599 "bs4".to_string(),
600 "BeautifulSoup".to_string(),
601 "beautifulsoup".to_string(),
602 ],
603 );
604 aliases.insert(
605 "pyyaml".to_string(),
606 vec!["yaml".to_string(), "PyYAML".to_string()],
607 );
608 aliases.insert(
609 "opencv-python".to_string(),
610 vec![
611 "cv2".to_string(),
612 "opencv-python-headless".to_string(),
613 "opencv".to_string(),
614 ],
615 );
616 aliases.insert("python-dateutil".to_string(), vec!["dateutil".to_string()]);
617 aliases.insert("attrs".to_string(), vec!["attr".to_string()]);
618 aliases.insert(
619 "importlib-metadata".to_string(),
620 vec!["importlib_metadata".to_string()],
621 );
622 aliases.insert(
623 "typing-extensions".to_string(),
624 vec!["typing_extensions".to_string()],
625 );
626 aliases
627 }
628
629 fn npm_package_groups() -> HashMap<String, PackageGroup> {
631 let mut groups = HashMap::new();
632 groups.insert(
633 "lodash".to_string(),
634 PackageGroup {
635 canonical: "lodash".to_string(),
636 members: vec![
637 "lodash-es".to_string(),
638 "lodash.merge".to_string(),
639 "lodash.get".to_string(),
640 "lodash.set".to_string(),
641 "lodash.clonedeep".to_string(),
642 ],
643 },
644 );
645 groups.insert(
646 "babel".to_string(),
647 PackageGroup {
648 canonical: "@babel/core".to_string(),
649 members: vec!["@babel/*".to_string()],
650 },
651 );
652 groups.insert(
653 "react".to_string(),
654 PackageGroup {
655 canonical: "react".to_string(),
656 members: vec![
657 "react-dom".to_string(),
658 "react-router".to_string(),
659 "react-redux".to_string(),
660 ],
661 },
662 );
663 groups
664 }
665
666 fn load_cross_ecosystem_mappings(&mut self) {
668 let mut yaml_mapping = HashMap::new();
670 yaml_mapping.insert("pypi".to_string(), Some("pyyaml".to_string()));
671 yaml_mapping.insert("npm".to_string(), Some("js-yaml".to_string()));
672 yaml_mapping.insert("cargo".to_string(), Some("serde_yaml".to_string()));
673 yaml_mapping.insert("golang".to_string(), Some("gopkg.in/yaml.v3".to_string()));
674 yaml_mapping.insert("rubygems".to_string(), Some("psych".to_string()));
675 self.cross_ecosystem
676 .insert("yaml_parsing".to_string(), yaml_mapping);
677
678 let mut json_mapping = HashMap::new();
680 json_mapping.insert("pypi".to_string(), None); json_mapping.insert("npm".to_string(), None); json_mapping.insert("cargo".to_string(), Some("serde_json".to_string()));
683 json_mapping.insert("golang".to_string(), None); self.cross_ecosystem
685 .insert("json_parsing".to_string(), json_mapping);
686
687 let mut http_mapping = HashMap::new();
689 http_mapping.insert("pypi".to_string(), Some("requests".to_string()));
690 http_mapping.insert("npm".to_string(), Some("axios".to_string()));
691 http_mapping.insert("cargo".to_string(), Some("reqwest".to_string()));
692 http_mapping.insert("golang".to_string(), None); http_mapping.insert("rubygems".to_string(), Some("faraday".to_string()));
694 self.cross_ecosystem
695 .insert("http_client".to_string(), http_mapping);
696
697 let mut test_mapping = HashMap::new();
699 test_mapping.insert("pypi".to_string(), Some("pytest".to_string()));
700 test_mapping.insert("npm".to_string(), Some("jest".to_string()));
701 test_mapping.insert("cargo".to_string(), None); test_mapping.insert("golang".to_string(), None); test_mapping.insert("rubygems".to_string(), Some("rspec".to_string()));
704 self.cross_ecosystem
705 .insert("testing".to_string(), test_mapping);
706 }
707
708 pub fn get_ecosystem(&self, ecosystem: &str) -> Option<&EcosystemConfig> {
710 self.ecosystems.get(&ecosystem.to_lowercase())
711 }
712
713 pub fn is_empty(&self) -> bool {
715 self.ecosystems.is_empty()
716 && self.cross_ecosystem.is_empty()
717 && self.custom_rules.equivalences.is_empty()
718 }
719
720 pub fn merge(&mut self, other: Self) {
722 for (key, value) in other.ecosystems {
724 self.ecosystems.insert(key, value);
725 }
726
727 for (key, value) in other.cross_ecosystem {
729 self.cross_ecosystem.insert(key, value);
730 }
731
732 self.custom_rules
734 .internal_prefixes
735 .extend(other.custom_rules.internal_prefixes);
736 self.custom_rules
737 .equivalences
738 .extend(other.custom_rules.equivalences);
739 self.custom_rules
740 .ignored_packages
741 .extend(other.custom_rules.ignored_packages);
742
743 if other.settings.enable_security_checks != self.settings.enable_security_checks {
745 self.settings.enable_security_checks = other.settings.enable_security_checks;
746 }
747 }
748
749 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
751 serde_yaml_ng::to_string(self)
752 }
753
754 pub fn to_json(&self) -> Result<String, serde_json::Error> {
756 serde_json::to_string_pretty(self)
757 }
758}
759
760impl Default for EcosystemRulesConfig {
761 fn default() -> Self {
762 Self::builtin()
763 }
764}
765
766#[derive(Debug)]
768pub enum ConfigError {
769 Io(std::io::Error),
770 Yaml(serde_yaml_ng::Error),
771 Json(serde_json::Error),
772}
773
774impl std::fmt::Display for ConfigError {
775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776 match self {
777 Self::Io(e) => write!(f, "IO error: {}", e),
778 Self::Yaml(e) => write!(f, "YAML parse error: {}", e),
779 Self::Json(e) => write!(f, "JSON parse error: {}", e),
780 }
781 }
782}
783
784impl std::error::Error for ConfigError {}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn test_builtin_config() {
792 let config = EcosystemRulesConfig::builtin();
793
794 assert!(config.ecosystems.contains_key("pypi"));
795 assert!(config.ecosystems.contains_key("npm"));
796 assert!(config.ecosystems.contains_key("cargo"));
797 assert!(config.ecosystems.contains_key("maven"));
798 assert!(config.ecosystems.contains_key("golang"));
799 }
800
801 #[test]
802 fn test_pypi_config() {
803 let config = EcosystemRulesConfig::builtin();
804 let pypi = config.get_ecosystem("pypi").unwrap();
805
806 assert!(!pypi.normalization.case_sensitive);
807 assert!(!pypi.strip_prefixes.is_empty());
808 assert!(pypi.aliases.contains_key("pillow"));
809 assert_eq!(pypi.versioning.spec, VersionSpec::Pep440);
810 }
811
812 #[test]
813 fn test_npm_config() {
814 let config = EcosystemRulesConfig::builtin();
815 let npm = config.get_ecosystem("npm").unwrap();
816
817 assert_eq!(
818 npm.normalization.scope_handling,
819 ScopeHandling::PreserveScopeCase
820 );
821 assert!(npm.package_groups.contains_key("lodash"));
822 }
823
824 #[test]
825 fn test_cross_ecosystem_mapping() {
826 let config = EcosystemRulesConfig::builtin();
827
828 let yaml_libs = config.cross_ecosystem.get("yaml_parsing").unwrap();
829 assert_eq!(yaml_libs.get("pypi").unwrap(), &Some("pyyaml".to_string()));
830 assert_eq!(yaml_libs.get("npm").unwrap(), &Some("js-yaml".to_string()));
831 }
832
833 #[test]
834 fn test_yaml_parsing() {
835 let yaml = r#"
836version: "1.0"
837settings:
838 case_sensitive_default: false
839ecosystems:
840 custom:
841 normalization:
842 case_sensitive: true
843 strip_prefixes:
844 - "my-"
845 strip_suffixes:
846 - "-custom"
847"#;
848
849 let config = EcosystemRulesConfig::from_yaml(yaml).unwrap();
850 assert!(config.ecosystems.contains_key("custom"));
851
852 let custom = config.get_ecosystem("custom").unwrap();
853 assert!(custom.normalization.case_sensitive);
854 assert_eq!(custom.strip_prefixes, vec!["my-"]);
855 }
856
857 #[test]
858 fn test_config_merge() {
859 let mut base = EcosystemRulesConfig::builtin();
860 let overlay = EcosystemRulesConfig::from_yaml(
861 r#"
862ecosystems:
863 pypi:
864 strip_prefixes:
865 - "custom-"
866custom_rules:
867 internal_prefixes:
868 - "@mycompany/"
869"#,
870 )
871 .unwrap();
872
873 base.merge(overlay);
874
875 let pypi = base.get_ecosystem("pypi").unwrap();
877 assert_eq!(pypi.strip_prefixes, vec!["custom-"]);
878
879 assert!(base
881 .custom_rules
882 .internal_prefixes
883 .contains(&"@mycompany/".to_string()));
884 }
885
886 #[test]
887 fn test_security_config() {
888 let config = EcosystemRulesConfig::builtin();
889 let pypi = config.get_ecosystem("pypi").unwrap();
890
891 assert!(!pypi.security.known_typosquats.is_empty());
892 let typosquat = &pypi.security.known_typosquats[0];
893 assert_eq!(typosquat.malicious, "python-dateutils");
894 assert_eq!(typosquat.legitimate, "python-dateutil");
895 }
896}