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
66const fn 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
179const fn 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 #[must_use]
300 pub fn new() -> Self {
301 Self {
302 version: default_version(),
303 settings: GlobalSettings::default(),
304 ecosystems: HashMap::new(),
305 cross_ecosystem: HashMap::new(),
306 custom_rules: CustomRules::default(),
307 }
308 }
309
310 #[must_use]
312 pub fn builtin() -> Self {
313 let mut config = Self::new();
314 config.load_builtin_rules();
315 config
316 }
317
318 pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
320 serde_yaml_ng::from_str(yaml)
321 }
322
323 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
325 serde_json::from_str(json)
326 }
327
328 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
330 let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
331
332 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
333
334 match extension.to_lowercase().as_str() {
335 "yaml" | "yml" => Self::from_yaml(&content).map_err(ConfigError::Yaml),
336 "json" => Self::from_json(&content).map_err(ConfigError::Json),
337 _ => {
338 Self::from_yaml(&content)
340 .map_err(ConfigError::Yaml)
341 .or_else(|_| Self::from_json(&content).map_err(ConfigError::Json))
342 }
343 }
344 }
345
346 pub fn load_with_precedence(paths: &[&str]) -> Result<Self, ConfigError> {
348 for path_str in paths {
349 let path = if path_str.starts_with('~') {
350 if let Some(home) = dirs::home_dir() {
351 home.join(&path_str[2..])
352 } else {
353 continue;
354 }
355 } else {
356 Path::new(path_str).to_path_buf()
357 };
358
359 if path.exists() {
360 return Self::from_file(&path);
361 }
362 }
363
364 Ok(Self::builtin())
366 }
367
368 fn load_builtin_rules(&mut self) {
370 self.ecosystems.insert(
372 "pypi".to_string(),
373 EcosystemConfig {
374 normalization: NormalizationConfig {
375 case_sensitive: false,
376 equivalent_chars: vec![vec!["-".to_string(), "_".to_string(), ".".to_string()]],
377 collapse_separators: true,
378 ..Default::default()
379 },
380 strip_prefixes: vec!["python-".to_string(), "py-".to_string(), "lib".to_string()],
381 strip_suffixes: vec![
382 "-python".to_string(),
383 "-py".to_string(),
384 "-py3".to_string(),
385 "-lib".to_string(),
386 ],
387 aliases: Self::pypi_aliases(),
388 versioning: VersioningConfig {
389 spec: VersionSpec::Pep440,
390 prerelease_tags: vec![
391 "a".to_string(),
392 "b".to_string(),
393 "rc".to_string(),
394 "alpha".to_string(),
395 "beta".to_string(),
396 "dev".to_string(),
397 "post".to_string(),
398 ],
399 ..Default::default()
400 },
401 security: SecurityConfig {
402 known_typosquats: vec![
403 TyposquatEntry {
404 malicious: "python-dateutils".to_string(),
405 legitimate: "python-dateutil".to_string(),
406 description: Some("Common typosquat".to_string()),
407 },
408 TyposquatEntry {
409 malicious: "request".to_string(),
410 legitimate: "requests".to_string(),
411 description: Some("Missing 's' typosquat".to_string()),
412 },
413 ],
414 ..Default::default()
415 },
416 ..Default::default()
417 },
418 );
419
420 self.ecosystems.insert(
422 "npm".to_string(),
423 EcosystemConfig {
424 normalization: NormalizationConfig {
425 case_sensitive: false,
426 scope_handling: ScopeHandling::PreserveScopeCase,
427 ..Default::default()
428 },
429 strip_prefixes: vec!["node-".to_string(), "@types/".to_string()],
430 strip_suffixes: vec!["-js".to_string(), ".js".to_string(), "-node".to_string()],
431 package_groups: Self::npm_package_groups(),
432 versioning: VersioningConfig {
433 spec: VersionSpec::Semver,
434 prerelease_tags: vec![
435 "alpha".to_string(),
436 "beta".to_string(),
437 "rc".to_string(),
438 "next".to_string(),
439 "canary".to_string(),
440 ],
441 ..Default::default()
442 },
443 security: SecurityConfig {
444 suspicious_patterns: vec![
445 r"^[a-z]{1,2}$".to_string(), ],
447 ..Default::default()
448 },
449 ..Default::default()
450 },
451 );
452
453 self.ecosystems.insert(
455 "cargo".to_string(),
456 EcosystemConfig {
457 normalization: NormalizationConfig {
458 case_sensitive: false,
459 equivalent_chars: vec![vec!["_".to_string(), "-".to_string()]],
461 ..Default::default()
462 },
463 strip_prefixes: vec!["rust-".to_string(), "lib".to_string()],
464 strip_suffixes: vec!["-rs".to_string(), "-rust".to_string()],
465 versioning: VersioningConfig {
466 spec: VersionSpec::Semver,
467 ..Default::default()
468 },
469 ..Default::default()
470 },
471 );
472
473 self.ecosystems.insert(
475 "maven".to_string(),
476 EcosystemConfig {
477 normalization: NormalizationConfig {
478 case_sensitive: true,
479 use_full_coordinate: true,
480 ..Default::default()
481 },
482 group_migrations: vec![GroupMigration {
483 from: "javax.*".to_string(),
484 to: "jakarta.*".to_string(),
485 after_version: Some("9".to_string()),
486 }],
487 versioning: VersioningConfig {
488 spec: VersionSpec::Maven,
489 qualifier_order: vec![
490 "alpha".to_string(),
491 "beta".to_string(),
492 "milestone".to_string(),
493 "rc".to_string(),
494 "snapshot".to_string(),
495 "final".to_string(),
496 "ga".to_string(),
497 "sp".to_string(),
498 ],
499 ..Default::default()
500 },
501 ..Default::default()
502 },
503 );
504
505 self.ecosystems.insert(
507 "golang".to_string(),
508 EcosystemConfig {
509 normalization: NormalizationConfig {
510 case_sensitive: true,
511 strip_version_suffix: true,
512 ..Default::default()
513 },
514 import_mappings: vec![
515 ImportMapping {
516 pattern: "github.com/*/*".to_string(),
517 mapping_type: "github".to_string(),
518 },
519 ImportMapping {
520 pattern: "golang.org/x/*".to_string(),
521 mapping_type: "stdlib_extension".to_string(),
522 },
523 ],
524 versioning: VersioningConfig {
525 spec: VersionSpec::Gomod,
526 ..Default::default()
527 },
528 ..Default::default()
529 },
530 );
531
532 self.ecosystems.insert(
534 "nuget".to_string(),
535 EcosystemConfig {
536 normalization: NormalizationConfig {
537 case_sensitive: false,
538 ..Default::default()
539 },
540 versioning: VersioningConfig {
541 spec: VersionSpec::Semver,
542 ..Default::default()
543 },
544 ..Default::default()
545 },
546 );
547
548 self.ecosystems.insert(
550 "rubygems".to_string(),
551 EcosystemConfig {
552 normalization: NormalizationConfig {
553 case_sensitive: true,
554 ..Default::default()
555 },
556 strip_prefixes: vec!["ruby-".to_string()],
557 strip_suffixes: vec!["-ruby".to_string(), "-rb".to_string()],
558 versioning: VersioningConfig {
559 spec: VersionSpec::Rubygems,
560 ..Default::default()
561 },
562 ..Default::default()
563 },
564 );
565
566 self.ecosystems.insert(
568 "composer".to_string(),
569 EcosystemConfig {
570 normalization: NormalizationConfig {
571 case_sensitive: false,
572 use_full_coordinate: true,
573 ..Default::default()
574 },
575 versioning: VersioningConfig {
576 spec: VersionSpec::Semver,
577 ..Default::default()
578 },
579 ..Default::default()
580 },
581 );
582
583 self.load_cross_ecosystem_mappings();
585 }
586
587 fn pypi_aliases() -> HashMap<String, Vec<String>> {
589 let mut aliases = HashMap::new();
590 aliases.insert(
591 "pillow".to_string(),
592 vec!["PIL".to_string(), "python-pillow".to_string()],
593 );
594 aliases.insert(
595 "scikit-learn".to_string(),
596 vec!["sklearn".to_string(), "scikit_learn".to_string()],
597 );
598 aliases.insert(
599 "beautifulsoup4".to_string(),
600 vec![
601 "bs4".to_string(),
602 "BeautifulSoup".to_string(),
603 "beautifulsoup".to_string(),
604 ],
605 );
606 aliases.insert(
607 "pyyaml".to_string(),
608 vec!["yaml".to_string(), "PyYAML".to_string()],
609 );
610 aliases.insert(
611 "opencv-python".to_string(),
612 vec![
613 "cv2".to_string(),
614 "opencv-python-headless".to_string(),
615 "opencv".to_string(),
616 ],
617 );
618 aliases.insert("python-dateutil".to_string(), vec!["dateutil".to_string()]);
619 aliases.insert("attrs".to_string(), vec!["attr".to_string()]);
620 aliases.insert(
621 "importlib-metadata".to_string(),
622 vec!["importlib_metadata".to_string()],
623 );
624 aliases.insert(
625 "typing-extensions".to_string(),
626 vec!["typing_extensions".to_string()],
627 );
628 aliases
629 }
630
631 fn npm_package_groups() -> HashMap<String, PackageGroup> {
633 let mut groups = HashMap::new();
634 groups.insert(
635 "lodash".to_string(),
636 PackageGroup {
637 canonical: "lodash".to_string(),
638 members: vec![
639 "lodash-es".to_string(),
640 "lodash.merge".to_string(),
641 "lodash.get".to_string(),
642 "lodash.set".to_string(),
643 "lodash.clonedeep".to_string(),
644 ],
645 },
646 );
647 groups.insert(
648 "babel".to_string(),
649 PackageGroup {
650 canonical: "@babel/core".to_string(),
651 members: vec!["@babel/*".to_string()],
652 },
653 );
654 groups.insert(
655 "react".to_string(),
656 PackageGroup {
657 canonical: "react".to_string(),
658 members: vec![
659 "react-dom".to_string(),
660 "react-router".to_string(),
661 "react-redux".to_string(),
662 ],
663 },
664 );
665 groups
666 }
667
668 fn load_cross_ecosystem_mappings(&mut self) {
670 let mut yaml_mapping = HashMap::new();
672 yaml_mapping.insert("pypi".to_string(), Some("pyyaml".to_string()));
673 yaml_mapping.insert("npm".to_string(), Some("js-yaml".to_string()));
674 yaml_mapping.insert("cargo".to_string(), Some("serde_yaml".to_string()));
675 yaml_mapping.insert("golang".to_string(), Some("gopkg.in/yaml.v3".to_string()));
676 yaml_mapping.insert("rubygems".to_string(), Some("psych".to_string()));
677 self.cross_ecosystem
678 .insert("yaml_parsing".to_string(), yaml_mapping);
679
680 let mut json_mapping = HashMap::new();
682 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()));
685 json_mapping.insert("golang".to_string(), None); self.cross_ecosystem
687 .insert("json_parsing".to_string(), json_mapping);
688
689 let mut http_mapping = HashMap::new();
691 http_mapping.insert("pypi".to_string(), Some("requests".to_string()));
692 http_mapping.insert("npm".to_string(), Some("axios".to_string()));
693 http_mapping.insert("cargo".to_string(), Some("reqwest".to_string()));
694 http_mapping.insert("golang".to_string(), None); http_mapping.insert("rubygems".to_string(), Some("faraday".to_string()));
696 self.cross_ecosystem
697 .insert("http_client".to_string(), http_mapping);
698
699 let mut test_mapping = HashMap::new();
701 test_mapping.insert("pypi".to_string(), Some("pytest".to_string()));
702 test_mapping.insert("npm".to_string(), Some("jest".to_string()));
703 test_mapping.insert("cargo".to_string(), None); test_mapping.insert("golang".to_string(), None); test_mapping.insert("rubygems".to_string(), Some("rspec".to_string()));
706 self.cross_ecosystem
707 .insert("testing".to_string(), test_mapping);
708 }
709
710 #[must_use]
712 pub fn get_ecosystem(&self, ecosystem: &str) -> Option<&EcosystemConfig> {
713 self.ecosystems.get(&ecosystem.to_lowercase())
714 }
715
716 #[must_use]
718 pub fn is_empty(&self) -> bool {
719 self.ecosystems.is_empty()
720 && self.cross_ecosystem.is_empty()
721 && self.custom_rules.equivalences.is_empty()
722 }
723
724 pub fn merge(&mut self, other: Self) {
726 for (key, value) in other.ecosystems {
728 self.ecosystems.insert(key, value);
729 }
730
731 for (key, value) in other.cross_ecosystem {
733 self.cross_ecosystem.insert(key, value);
734 }
735
736 self.custom_rules
738 .internal_prefixes
739 .extend(other.custom_rules.internal_prefixes);
740 self.custom_rules
741 .equivalences
742 .extend(other.custom_rules.equivalences);
743 self.custom_rules
744 .ignored_packages
745 .extend(other.custom_rules.ignored_packages);
746
747 if other.settings.enable_security_checks != self.settings.enable_security_checks {
749 self.settings.enable_security_checks = other.settings.enable_security_checks;
750 }
751 }
752
753 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
755 serde_yaml_ng::to_string(self)
756 }
757
758 pub fn to_json(&self) -> Result<String, serde_json::Error> {
760 serde_json::to_string_pretty(self)
761 }
762}
763
764impl Default for EcosystemRulesConfig {
765 fn default() -> Self {
766 Self::builtin()
767 }
768}
769
770#[derive(Debug)]
772pub enum ConfigError {
773 Io(std::io::Error),
774 Yaml(serde_yaml_ng::Error),
775 Json(serde_json::Error),
776}
777
778impl std::fmt::Display for ConfigError {
779 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780 match self {
781 Self::Io(e) => write!(f, "IO error: {e}"),
782 Self::Yaml(e) => write!(f, "YAML parse error: {e}"),
783 Self::Json(e) => write!(f, "JSON parse error: {e}"),
784 }
785 }
786}
787
788impl std::error::Error for ConfigError {}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn test_builtin_config() {
796 let config = EcosystemRulesConfig::builtin();
797
798 assert!(config.ecosystems.contains_key("pypi"));
799 assert!(config.ecosystems.contains_key("npm"));
800 assert!(config.ecosystems.contains_key("cargo"));
801 assert!(config.ecosystems.contains_key("maven"));
802 assert!(config.ecosystems.contains_key("golang"));
803 }
804
805 #[test]
806 fn test_pypi_config() {
807 let config = EcosystemRulesConfig::builtin();
808 let pypi = config.get_ecosystem("pypi").unwrap();
809
810 assert!(!pypi.normalization.case_sensitive);
811 assert!(!pypi.strip_prefixes.is_empty());
812 assert!(pypi.aliases.contains_key("pillow"));
813 assert_eq!(pypi.versioning.spec, VersionSpec::Pep440);
814 }
815
816 #[test]
817 fn test_npm_config() {
818 let config = EcosystemRulesConfig::builtin();
819 let npm = config.get_ecosystem("npm").unwrap();
820
821 assert_eq!(
822 npm.normalization.scope_handling,
823 ScopeHandling::PreserveScopeCase
824 );
825 assert!(npm.package_groups.contains_key("lodash"));
826 }
827
828 #[test]
829 fn test_cross_ecosystem_mapping() {
830 let config = EcosystemRulesConfig::builtin();
831
832 let yaml_libs = config.cross_ecosystem.get("yaml_parsing").unwrap();
833 assert_eq!(yaml_libs.get("pypi").unwrap(), &Some("pyyaml".to_string()));
834 assert_eq!(yaml_libs.get("npm").unwrap(), &Some("js-yaml".to_string()));
835 }
836
837 #[test]
838 fn test_yaml_parsing() {
839 let yaml = r#"
840version: "1.0"
841settings:
842 case_sensitive_default: false
843ecosystems:
844 custom:
845 normalization:
846 case_sensitive: true
847 strip_prefixes:
848 - "my-"
849 strip_suffixes:
850 - "-custom"
851"#;
852
853 let config = EcosystemRulesConfig::from_yaml(yaml).unwrap();
854 assert!(config.ecosystems.contains_key("custom"));
855
856 let custom = config.get_ecosystem("custom").unwrap();
857 assert!(custom.normalization.case_sensitive);
858 assert_eq!(custom.strip_prefixes, vec!["my-"]);
859 }
860
861 #[test]
862 fn test_config_merge() {
863 let mut base = EcosystemRulesConfig::builtin();
864 let overlay = EcosystemRulesConfig::from_yaml(
865 r#"
866ecosystems:
867 pypi:
868 strip_prefixes:
869 - "custom-"
870custom_rules:
871 internal_prefixes:
872 - "@mycompany/"
873"#,
874 )
875 .unwrap();
876
877 base.merge(overlay);
878
879 let pypi = base.get_ecosystem("pypi").unwrap();
881 assert_eq!(pypi.strip_prefixes, vec!["custom-"]);
882
883 assert!(
885 base.custom_rules
886 .internal_prefixes
887 .contains(&"@mycompany/".to_string())
888 );
889 }
890
891 #[test]
892 fn test_security_config() {
893 let config = EcosystemRulesConfig::builtin();
894 let pypi = config.get_ecosystem("pypi").unwrap();
895
896 assert!(!pypi.security.known_typosquats.is_empty());
897 let typosquat = &pypi.security.known_typosquats[0];
898 assert_eq!(typosquat.malicious, "python-dateutils");
899 assert_eq!(typosquat.legitimate, "python-dateutil");
900 }
901}