1use crate::Severity;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum Profile {
20 Fast,
22 #[default]
24 Balanced,
25 Strict,
27}
28
29impl std::fmt::Display for Profile {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Profile::Fast => write!(f, "fast"),
33 Profile::Balanced => write!(f, "balanced"),
34 Profile::Strict => write!(f, "strict"),
35 }
36 }
37}
38
39impl std::str::FromStr for Profile {
40 type Err = String;
41
42 fn from_str(s: &str) -> Result<Self, Self::Err> {
43 match s.to_lowercase().as_str() {
44 "fast" => Ok(Profile::Fast),
45 "balanced" => Ok(Profile::Balanced),
46 "strict" => Ok(Profile::Strict),
47 _ => Err(format!(
48 "Unknown profile: {}. Use: fast, balanced, strict",
49 s
50 )),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
57#[serde(rename_all = "kebab-case")]
58pub enum BaselineMode {
59 #[default]
61 All,
62 NewOnly,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ScanConfig {
69 #[serde(default)]
71 pub include: Vec<String>,
72
73 #[serde(default)]
75 pub exclude: Vec<String>,
76
77 #[serde(default = "default_max_file_size")]
79 pub max_file_size: usize,
80}
81
82fn default_max_file_size() -> usize {
83 10 * 1024 * 1024
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct RulesConfig {
89 #[serde(default = "default_enable")]
91 pub enable: Vec<String>,
92
93 #[serde(default)]
95 pub disable: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct RulesetsConfig {
101 #[serde(default)]
103 pub security: Vec<String>,
104
105 #[serde(default)]
107 pub maintainability: Vec<String>,
108
109 #[serde(flatten)]
111 pub custom: HashMap<String, Vec<String>>,
112}
113
114fn default_enable() -> Vec<String> {
115 vec!["*".to_string()]
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ProfileThresholds {
121 #[serde(default = "default_max_function_lines")]
123 pub max_function_lines: usize,
124
125 #[serde(default = "default_max_complexity")]
127 pub max_complexity: usize,
128
129 #[serde(default = "default_max_cognitive_complexity")]
131 pub max_cognitive_complexity: usize,
132
133 #[serde(default = "default_max_file_lines")]
135 pub max_file_lines: usize,
136}
137
138fn default_max_function_lines() -> usize {
139 100
140}
141
142fn default_max_complexity() -> usize {
143 15
144}
145
146fn default_max_cognitive_complexity() -> usize {
147 20
148}
149
150fn default_max_file_lines() -> usize {
151 1000
152}
153
154impl Default for ProfileThresholds {
155 fn default() -> Self {
156 Self {
157 max_function_lines: default_max_function_lines(),
158 max_complexity: default_max_complexity(),
159 max_cognitive_complexity: default_max_cognitive_complexity(),
160 max_file_lines: default_max_file_lines(),
161 }
162 }
163}
164
165impl ProfileThresholds {
166 pub fn for_profile(profile: Profile) -> Self {
168 match profile {
169 Profile::Fast => Self {
170 max_function_lines: 200,
171 max_complexity: 25,
172 max_cognitive_complexity: 35,
173 max_file_lines: 2000,
174 },
175 Profile::Balanced => Self::default(),
176 Profile::Strict => Self {
177 max_function_lines: 50,
178 max_complexity: 10,
179 max_cognitive_complexity: 15,
180 max_file_lines: 500,
181 },
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188pub struct ProfilesConfig {
189 #[serde(default)]
191 pub default: Profile,
192
193 #[serde(default = "fast_profile_defaults")]
195 pub fast: ProfileThresholds,
196
197 #[serde(default)]
199 pub balanced: ProfileThresholds,
200
201 #[serde(default = "strict_profile_defaults")]
203 pub strict: ProfileThresholds,
204}
205
206fn fast_profile_defaults() -> ProfileThresholds {
207 ProfileThresholds::for_profile(Profile::Fast)
208}
209
210fn strict_profile_defaults() -> ProfileThresholds {
211 ProfileThresholds::for_profile(Profile::Strict)
212}
213
214impl ProfilesConfig {
215 pub fn get_thresholds(&self, profile: Profile) -> &ProfileThresholds {
217 match profile {
218 Profile::Fast => &self.fast,
219 Profile::Balanced => &self.balanced,
220 Profile::Strict => &self.strict,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ThresholdOverride {
228 pub path: String,
230
231 #[serde(default)]
233 pub max_function_lines: Option<usize>,
234
235 #[serde(default)]
237 pub max_complexity: Option<usize>,
238
239 #[serde(default)]
241 pub max_cognitive_complexity: Option<usize>,
242
243 #[serde(default)]
245 pub disable_rules: Vec<String>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250pub struct AllowConfig {
251 #[serde(default)]
253 pub settimeout_string: bool,
254
255 #[serde(default = "default_true")]
257 pub settimeout_function: bool,
258
259 #[serde(default)]
261 pub innerhtml_paths: Vec<String>,
262
263 #[serde(default)]
265 pub eval_paths: Vec<String>,
266
267 #[serde(default)]
269 pub unsafe_rust_paths: Vec<String>,
270
271 #[serde(default)]
273 pub approved_secrets: Vec<String>,
274}
275
276fn default_true() -> bool {
277 true
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct BaselineConfig {
283 #[serde(default = "default_baseline_file")]
285 pub file: PathBuf,
286
287 #[serde(default)]
289 pub mode: BaselineMode,
290}
291
292fn default_baseline_file() -> PathBuf {
293 PathBuf::from(".rma/baseline.json")
294}
295
296pub const CURRENT_CONFIG_VERSION: u32 = 1;
298
299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
301pub struct RmaTomlConfig {
302 #[serde(default)]
304 pub config_version: Option<u32>,
305
306 #[serde(default)]
308 pub scan: ScanConfig,
309
310 #[serde(default)]
312 pub rules: RulesConfig,
313
314 #[serde(default)]
316 pub rulesets: RulesetsConfig,
317
318 #[serde(default)]
320 pub profiles: ProfilesConfig,
321
322 #[serde(default)]
324 pub severity: HashMap<String, Severity>,
325
326 #[serde(default)]
328 pub threshold_overrides: Vec<ThresholdOverride>,
329
330 #[serde(default)]
332 pub allow: AllowConfig,
333
334 #[serde(default)]
336 pub baseline: BaselineConfig,
337}
338
339#[derive(Debug)]
341pub struct ConfigLoadResult {
342 pub config: RmaTomlConfig,
344 pub version_warning: Option<String>,
346}
347
348impl RmaTomlConfig {
349 pub fn load(path: &Path) -> Result<Self, String> {
351 let result = Self::load_with_validation(path)?;
352 Ok(result.config)
353 }
354
355 pub fn load_with_validation(path: &Path) -> Result<ConfigLoadResult, String> {
357 let content = std::fs::read_to_string(path)
358 .map_err(|e| format!("Failed to read config file: {}", e))?;
359
360 let config: RmaTomlConfig =
361 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
362
363 let version_warning = config.validate_version()?;
365
366 Ok(ConfigLoadResult {
367 config,
368 version_warning,
369 })
370 }
371
372 fn validate_version(&self) -> Result<Option<String>, String> {
374 match self.config_version {
375 Some(CURRENT_CONFIG_VERSION) => Ok(None),
376 Some(version) if version > CURRENT_CONFIG_VERSION => Err(format!(
377 "Unsupported config version: {}. Maximum supported version is {}. \
378 Please upgrade RMA or use a compatible config format.",
379 version, CURRENT_CONFIG_VERSION
380 )),
381 Some(version) => {
382 Err(format!(
384 "Invalid config version: {}. Expected version {}.",
385 version, CURRENT_CONFIG_VERSION
386 ))
387 }
388 None => Ok(Some(
389 "Config file is missing 'config_version'. Assuming version 1. \
390 Add 'config_version = 1' to suppress this warning."
391 .to_string(),
392 )),
393 }
394 }
395
396 pub fn has_version(&self) -> bool {
398 self.config_version.is_some()
399 }
400
401 pub fn effective_version(&self) -> u32 {
403 self.config_version.unwrap_or(CURRENT_CONFIG_VERSION)
404 }
405
406 pub fn discover(start_path: &Path) -> Option<(PathBuf, Self)> {
408 let candidates = [
409 start_path.join("rma.toml"),
410 start_path.join(".rma/rma.toml"),
411 start_path.join(".rma.toml"),
412 ];
413
414 for candidate in &candidates {
415 if candidate.exists()
416 && let Ok(config) = Self::load(candidate)
417 {
418 return Some((candidate.clone(), config));
419 }
420 }
421
422 let mut current = start_path.to_path_buf();
424 for _ in 0..5 {
425 if let Some(parent) = current.parent() {
426 let config_path = parent.join("rma.toml");
427 if config_path.exists()
428 && let Ok(config) = Self::load(&config_path)
429 {
430 return Some((config_path, config));
431 }
432 current = parent.to_path_buf();
433 } else {
434 break;
435 }
436 }
437
438 None
439 }
440
441 pub fn validate(&self) -> Vec<ConfigWarning> {
443 let mut warnings = Vec::new();
444
445 if self.config_version.is_none() {
447 warnings.push(ConfigWarning {
448 level: WarningLevel::Warning,
449 message: "Missing 'config_version'. Add 'config_version = 1' to your config file."
450 .to_string(),
451 });
452 } else if let Some(version) = self.config_version
453 && version > CURRENT_CONFIG_VERSION
454 {
455 warnings.push(ConfigWarning {
456 level: WarningLevel::Error,
457 message: format!(
458 "Unsupported config version: {}. Maximum supported is {}.",
459 version, CURRENT_CONFIG_VERSION
460 ),
461 });
462 }
463
464 for disabled in &self.rules.disable {
466 for enabled in &self.rules.enable {
467 if enabled == disabled {
468 warnings.push(ConfigWarning {
469 level: WarningLevel::Warning,
470 message: format!(
471 "Rule '{}' is both enabled and disabled (disable takes precedence)",
472 disabled
473 ),
474 });
475 }
476 }
477 }
478
479 for (i, override_) in self.threshold_overrides.iter().enumerate() {
481 if override_.path.is_empty() {
482 warnings.push(ConfigWarning {
483 level: WarningLevel::Error,
484 message: format!("threshold_overrides[{}]: path cannot be empty", i),
485 });
486 }
487 }
488
489 if self.baseline.mode == BaselineMode::NewOnly && !self.baseline.file.exists() {
491 warnings.push(ConfigWarning {
492 level: WarningLevel::Warning,
493 message: format!(
494 "Baseline mode is 'new-only' but baseline file '{}' does not exist. Run 'rma baseline' first.",
495 self.baseline.file.display()
496 ),
497 });
498 }
499
500 for rule_id in self.severity.keys() {
502 if rule_id.is_empty() {
503 warnings.push(ConfigWarning {
504 level: WarningLevel::Error,
505 message: "Empty rule ID in severity overrides".to_string(),
506 });
507 }
508 }
509
510 warnings
511 }
512
513 pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
515 self.is_rule_enabled_with_ruleset(rule_id, None)
516 }
517
518 pub fn is_rule_enabled_with_ruleset(&self, rule_id: &str, ruleset: Option<&str>) -> bool {
523 for pattern in &self.rules.disable {
525 if Self::matches_pattern(rule_id, pattern) {
526 return false;
527 }
528 }
529
530 if let Some(ruleset_name) = ruleset {
532 let ruleset_rules = self.get_ruleset_rules(ruleset_name);
533 if !ruleset_rules.is_empty() {
534 return ruleset_rules
536 .iter()
537 .any(|r| Self::matches_pattern(rule_id, r));
538 }
539 }
540
541 for pattern in &self.rules.enable {
543 if Self::matches_pattern(rule_id, pattern) {
544 return true;
545 }
546 }
547
548 false
549 }
550
551 pub fn get_ruleset_rules(&self, name: &str) -> Vec<String> {
553 match name {
554 "security" => self.rulesets.security.clone(),
555 "maintainability" => self.rulesets.maintainability.clone(),
556 _ => self.rulesets.custom.get(name).cloned().unwrap_or_default(),
557 }
558 }
559
560 pub fn get_ruleset_names(&self) -> Vec<String> {
562 let mut names = Vec::new();
563 if !self.rulesets.security.is_empty() {
564 names.push("security".to_string());
565 }
566 if !self.rulesets.maintainability.is_empty() {
567 names.push("maintainability".to_string());
568 }
569 names.extend(self.rulesets.custom.keys().cloned());
570 names
571 }
572
573 pub fn get_severity_override(&self, rule_id: &str) -> Option<Severity> {
575 self.severity.get(rule_id).copied()
576 }
577
578 pub fn get_thresholds_for_path(&self, path: &Path, profile: Profile) -> ProfileThresholds {
580 let mut thresholds = self.profiles.get_thresholds(profile).clone();
581
582 let path_str = path.to_string_lossy();
584 for override_ in &self.threshold_overrides {
585 if Self::matches_glob(&path_str, &override_.path) {
586 if let Some(v) = override_.max_function_lines {
587 thresholds.max_function_lines = v;
588 }
589 if let Some(v) = override_.max_complexity {
590 thresholds.max_complexity = v;
591 }
592 if let Some(v) = override_.max_cognitive_complexity {
593 thresholds.max_cognitive_complexity = v;
594 }
595 }
596 }
597
598 thresholds
599 }
600
601 pub fn is_path_allowed(&self, path: &Path, rule_type: AllowType) -> bool {
603 let path_str = path.to_string_lossy();
604 let allowed_paths = match rule_type {
605 AllowType::InnerHtml => &self.allow.innerhtml_paths,
606 AllowType::Eval => &self.allow.eval_paths,
607 AllowType::UnsafeRust => &self.allow.unsafe_rust_paths,
608 };
609
610 for pattern in allowed_paths {
611 if Self::matches_glob(&path_str, pattern) {
612 return true;
613 }
614 }
615
616 false
617 }
618
619 fn matches_pattern(rule_id: &str, pattern: &str) -> bool {
620 if pattern == "*" {
621 return true;
622 }
623
624 if let Some(prefix) = pattern.strip_suffix("/*") {
625 return rule_id.starts_with(prefix);
626 }
627
628 rule_id == pattern
629 }
630
631 fn matches_glob(path: &str, pattern: &str) -> bool {
632 let pattern = pattern
634 .replace("**", "§")
635 .replace('*', "[^/]*")
636 .replace('§', ".*");
637 regex::Regex::new(&format!("^{}$", pattern))
638 .map(|re| re.is_match(path))
639 .unwrap_or(false)
640 }
641
642 pub fn default_toml(profile: Profile) -> String {
644 let thresholds = ProfileThresholds::for_profile(profile);
645
646 format!(
647 r#"# RMA Configuration
648# Documentation: https://github.com/bumahkib7/rust-monorepo-analyzer
649
650# Config format version (required for future compatibility)
651config_version = 1
652
653[scan]
654# Paths to include in scanning (default: all supported files)
655include = ["src/**", "lib/**", "scripts/**"]
656
657# Paths to exclude from scanning
658exclude = [
659 "node_modules/**",
660 "target/**",
661 "dist/**",
662 "build/**",
663 "vendor/**",
664 "**/*.min.js",
665 "**/*.bundle.js",
666]
667
668# Maximum file size to scan (10MB default)
669max_file_size = 10485760
670
671[rules]
672# Rules to enable (wildcards supported)
673enable = ["*"]
674
675# Rules to disable (takes precedence over enable)
676disable = []
677
678[profiles]
679# Default profile: fast, balanced, or strict
680default = "{profile}"
681
682[profiles.fast]
683max_function_lines = 200
684max_complexity = 25
685max_cognitive_complexity = 35
686max_file_lines = 2000
687
688[profiles.balanced]
689max_function_lines = {max_function_lines}
690max_complexity = {max_complexity}
691max_cognitive_complexity = {max_cognitive_complexity}
692max_file_lines = 1000
693
694[profiles.strict]
695max_function_lines = 50
696max_complexity = 10
697max_cognitive_complexity = 15
698max_file_lines = 500
699
700[rulesets]
701# Named rule groups for targeted scanning
702security = ["js/innerhtml-xss", "js/timer-string-eval", "js/dynamic-code-execution", "rust/unsafe-block", "python/shell-injection"]
703maintainability = ["generic/long-function", "generic/high-complexity", "js/console-log"]
704
705[severity]
706# Override severity for specific rules
707# "generic/long-function" = "warning"
708# "js/innerhtml-xss" = "error"
709# "rust/unsafe-block" = "warning"
710
711# [[threshold_overrides]]
712# path = "src/legacy/**"
713# max_function_lines = 300
714# max_complexity = 30
715
716# [[threshold_overrides]]
717# path = "tests/**"
718# disable_rules = ["generic/long-function"]
719
720[allow]
721# Approved patterns that won't trigger alerts
722settimeout_string = false
723settimeout_function = true
724innerhtml_paths = []
725eval_paths = []
726unsafe_rust_paths = []
727approved_secrets = []
728
729[baseline]
730# Baseline file for tracking legacy issues
731file = ".rma/baseline.json"
732# Mode: "all" or "new-only"
733mode = "all"
734"#,
735 profile = profile,
736 max_function_lines = thresholds.max_function_lines,
737 max_complexity = thresholds.max_complexity,
738 max_cognitive_complexity = thresholds.max_cognitive_complexity,
739 )
740 }
741}
742
743#[derive(Debug, Clone, Copy)]
745pub enum AllowType {
746 InnerHtml,
747 Eval,
748 UnsafeRust,
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "kebab-case")]
754pub enum ConfigSource {
755 Default,
757 ConfigFile,
759 CliFlag,
761}
762
763impl std::fmt::Display for ConfigSource {
764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765 match self {
766 ConfigSource::Default => write!(f, "default"),
767 ConfigSource::ConfigFile => write!(f, "config-file"),
768 ConfigSource::CliFlag => write!(f, "cli-flag"),
769 }
770 }
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct EffectiveConfig {
781 pub config_file: Option<PathBuf>,
783
784 pub profile: Profile,
786
787 pub profile_source: ConfigSource,
789
790 pub thresholds: ProfileThresholds,
792
793 pub enabled_rules_count: usize,
795
796 pub disabled_rules_count: usize,
798
799 pub severity_overrides_count: usize,
801
802 pub threshold_override_paths: Vec<String>,
804
805 pub baseline_mode: BaselineMode,
807
808 pub baseline_mode_source: ConfigSource,
810
811 pub exclude_patterns: Vec<String>,
813
814 pub include_patterns: Vec<String>,
816}
817
818impl EffectiveConfig {
819 pub fn resolve(
821 toml_config: Option<&RmaTomlConfig>,
822 config_path: Option<&Path>,
823 cli_profile: Option<Profile>,
824 cli_baseline_mode: bool,
825 ) -> Self {
826 let (profile, profile_source) = if let Some(p) = cli_profile {
828 (p, ConfigSource::CliFlag)
829 } else if let Some(cfg) = toml_config {
830 (cfg.profiles.default, ConfigSource::ConfigFile)
831 } else {
832 (Profile::default(), ConfigSource::Default)
833 };
834
835 let (baseline_mode, baseline_mode_source) = if cli_baseline_mode {
837 (BaselineMode::NewOnly, ConfigSource::CliFlag)
838 } else if let Some(cfg) = toml_config {
839 (cfg.baseline.mode, ConfigSource::ConfigFile)
840 } else {
841 (BaselineMode::default(), ConfigSource::Default)
842 };
843
844 let thresholds = toml_config
846 .map(|cfg| cfg.profiles.get_thresholds(profile).clone())
847 .unwrap_or_else(|| ProfileThresholds::for_profile(profile));
848
849 let (enabled_rules_count, disabled_rules_count) = toml_config
851 .map(|cfg| (cfg.rules.enable.len(), cfg.rules.disable.len()))
852 .unwrap_or((1, 0)); let severity_overrides_count = toml_config.map(|cfg| cfg.severity.len()).unwrap_or(0);
856
857 let threshold_override_paths = toml_config
859 .map(|cfg| {
860 cfg.threshold_overrides
861 .iter()
862 .map(|o| o.path.clone())
863 .collect()
864 })
865 .unwrap_or_default();
866
867 let exclude_patterns = toml_config
869 .map(|cfg| cfg.scan.exclude.clone())
870 .unwrap_or_default();
871
872 let include_patterns = toml_config
873 .map(|cfg| cfg.scan.include.clone())
874 .unwrap_or_default();
875
876 Self {
877 config_file: config_path.map(|p| p.to_path_buf()),
878 profile,
879 profile_source,
880 thresholds,
881 enabled_rules_count,
882 disabled_rules_count,
883 severity_overrides_count,
884 threshold_override_paths,
885 baseline_mode,
886 baseline_mode_source,
887 exclude_patterns,
888 include_patterns,
889 }
890 }
891
892 pub fn to_text(&self) -> String {
894 let mut out = String::new();
895
896 out.push_str("Effective Configuration\n");
897 out.push_str("═══════════════════════════════════════════════════════════\n\n");
898
899 out.push_str(" Config file: ");
901 match &self.config_file {
902 Some(p) => out.push_str(&format!("{}\n", p.display())),
903 None => out.push_str("(none - using defaults)\n"),
904 }
905
906 out.push_str(&format!(
908 " Profile: {} (from {})\n",
909 self.profile, self.profile_source
910 ));
911
912 out.push_str("\n Thresholds:\n");
914 out.push_str(&format!(
915 " max_function_lines: {}\n",
916 self.thresholds.max_function_lines
917 ));
918 out.push_str(&format!(
919 " max_complexity: {}\n",
920 self.thresholds.max_complexity
921 ));
922 out.push_str(&format!(
923 " max_cognitive_complexity: {}\n",
924 self.thresholds.max_cognitive_complexity
925 ));
926 out.push_str(&format!(
927 " max_file_lines: {}\n",
928 self.thresholds.max_file_lines
929 ));
930
931 out.push_str("\n Rules:\n");
933 out.push_str(&format!(
934 " enabled patterns: {}\n",
935 self.enabled_rules_count
936 ));
937 out.push_str(&format!(
938 " disabled patterns: {}\n",
939 self.disabled_rules_count
940 ));
941 out.push_str(&format!(
942 " severity overrides: {}\n",
943 self.severity_overrides_count
944 ));
945
946 if !self.threshold_override_paths.is_empty() {
948 out.push_str("\n Threshold overrides:\n");
949 for path in &self.threshold_override_paths {
950 out.push_str(&format!(" - {}\n", path));
951 }
952 }
953
954 out.push_str(&format!(
956 "\n Baseline mode: {:?} (from {})\n",
957 self.baseline_mode, self.baseline_mode_source
958 ));
959
960 out
961 }
962
963 pub fn to_json(&self) -> Result<String, serde_json::Error> {
965 serde_json::to_string_pretty(self)
966 }
967}
968
969#[derive(Debug, Clone)]
971pub struct ConfigWarning {
972 pub level: WarningLevel,
973 pub message: String,
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
977pub enum WarningLevel {
978 Warning,
979 Error,
980}
981
982#[derive(Debug, Clone, PartialEq, Eq)]
984pub struct InlineSuppression {
985 pub rule_id: String,
987 pub reason: Option<String>,
989 pub line: usize,
991 pub suppression_type: SuppressionType,
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
997pub enum SuppressionType {
998 NextLine,
1000 Block,
1002}
1003
1004impl InlineSuppression {
1005 pub fn parse(line: &str, line_number: usize) -> Option<Self> {
1012 let trimmed = line.trim();
1013
1014 let comment_body = if let Some(rest) = trimmed.strip_prefix("//") {
1016 rest.trim()
1017 } else if let Some(rest) = trimmed.strip_prefix('#') {
1018 rest.trim()
1019 } else {
1020 return None;
1021 };
1022
1023 if let Some(rest) = comment_body.strip_prefix("rma-ignore-next-line") {
1025 return Self::parse_suppression_body(
1026 rest.trim(),
1027 line_number,
1028 SuppressionType::NextLine,
1029 );
1030 }
1031
1032 if let Some(rest) = comment_body.strip_prefix("rma-ignore") {
1034 return Self::parse_suppression_body(rest.trim(), line_number, SuppressionType::Block);
1035 }
1036
1037 None
1038 }
1039
1040 fn parse_suppression_body(
1041 body: &str,
1042 line_number: usize,
1043 suppression_type: SuppressionType,
1044 ) -> Option<Self> {
1045 if body.is_empty() {
1046 return None;
1047 }
1048
1049 let mut parts = body.splitn(2, ' ');
1051 let rule_id = parts.next()?.trim().to_string();
1052
1053 if rule_id.is_empty() {
1054 return None;
1055 }
1056
1057 let reason = parts.next().and_then(|rest| {
1058 if let Some(start) = rest.find("reason=\"") {
1060 let after_quote = &rest[start + 8..];
1061 if let Some(end) = after_quote.find('"') {
1062 return Some(after_quote[..end].to_string());
1063 }
1064 }
1065 None
1066 });
1067
1068 Some(Self {
1069 rule_id,
1070 reason,
1071 line: line_number,
1072 suppression_type,
1073 })
1074 }
1075
1076 pub fn applies_to(&self, finding_line: usize, rule_id: &str) -> bool {
1078 if self.rule_id != rule_id && self.rule_id != "*" {
1079 return false;
1080 }
1081
1082 match self.suppression_type {
1083 SuppressionType::NextLine => finding_line == self.line + 1,
1084 SuppressionType::Block => finding_line >= self.line,
1085 }
1086 }
1087
1088 pub fn validate(&self, require_reason: bool) -> Result<(), String> {
1090 if require_reason && self.reason.is_none() {
1091 return Err(format!(
1092 "Suppression for '{}' at line {} requires a reason in strict profile",
1093 self.rule_id, self.line
1094 ));
1095 }
1096 Ok(())
1097 }
1098}
1099
1100pub fn parse_inline_suppressions(content: &str) -> Vec<InlineSuppression> {
1102 content
1103 .lines()
1104 .enumerate()
1105 .filter_map(|(i, line)| InlineSuppression::parse(line, i + 1))
1106 .collect()
1107}
1108
1109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1121pub struct Fingerprint(String);
1122
1123impl Fingerprint {
1124 pub fn generate(rule_id: &str, file_path: &Path, snippet: &str) -> Self {
1126 use sha2::{Digest, Sha256};
1127
1128 let mut hasher = Sha256::new();
1129
1130 hasher.update(rule_id.as_bytes());
1132 hasher.update(b"|");
1133
1134 let normalized_path = file_path
1136 .to_string_lossy()
1137 .replace('\\', "/")
1138 .to_lowercase();
1139 hasher.update(normalized_path.as_bytes());
1140 hasher.update(b"|");
1141
1142 let normalized_snippet = Self::normalize_snippet(snippet);
1144 hasher.update(normalized_snippet.as_bytes());
1145
1146 let hash = hasher.finalize();
1147 Self(format!("sha256:{:x}", hash))
1148 }
1149
1150 fn normalize_snippet(snippet: &str) -> String {
1152 snippet
1153 .split_whitespace()
1154 .collect::<Vec<_>>()
1155 .join(" ")
1156 .trim()
1157 .to_string()
1158 }
1159
1160 pub fn as_str(&self) -> &str {
1162 &self.0
1163 }
1164
1165 pub fn from_string(s: String) -> Self {
1167 Self(s)
1168 }
1169}
1170
1171impl std::fmt::Display for Fingerprint {
1172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173 write!(f, "{}", self.0)
1174 }
1175}
1176
1177impl From<Fingerprint> for String {
1178 fn from(fp: Fingerprint) -> String {
1179 fp.0
1180 }
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1185pub struct BaselineEntry {
1186 pub rule_id: String,
1187 pub file: PathBuf,
1188 #[serde(default)]
1189 pub line: usize,
1190 pub fingerprint: String,
1191 pub first_seen: String,
1192 #[serde(default)]
1193 pub suppressed: bool,
1194 #[serde(default)]
1195 pub comment: Option<String>,
1196}
1197
1198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1200pub struct Baseline {
1201 pub version: String,
1202 pub created: String,
1203 pub entries: Vec<BaselineEntry>,
1204}
1205
1206impl Baseline {
1207 pub fn new() -> Self {
1208 Self {
1209 version: "1.0".to_string(),
1210 created: chrono::Utc::now().to_rfc3339(),
1211 entries: Vec::new(),
1212 }
1213 }
1214
1215 pub fn load(path: &Path) -> Result<Self, String> {
1216 let content = std::fs::read_to_string(path)
1217 .map_err(|e| format!("Failed to read baseline file: {}", e))?;
1218
1219 serde_json::from_str(&content).map_err(|e| format!("Failed to parse baseline: {}", e))
1220 }
1221
1222 pub fn save(&self, path: &Path) -> Result<(), String> {
1223 if let Some(parent) = path.parent() {
1224 std::fs::create_dir_all(parent)
1225 .map_err(|e| format!("Failed to create directory: {}", e))?;
1226 }
1227
1228 let content = serde_json::to_string_pretty(self)
1229 .map_err(|e| format!("Failed to serialize baseline: {}", e))?;
1230
1231 std::fs::write(path, content).map_err(|e| format!("Failed to write baseline: {}", e))
1232 }
1233
1234 pub fn contains_fingerprint(&self, fingerprint: &Fingerprint) -> bool {
1236 self.entries
1237 .iter()
1238 .any(|e| e.fingerprint == fingerprint.as_str())
1239 }
1240
1241 pub fn contains(&self, rule_id: &str, file: &Path, fingerprint: &str) -> bool {
1243 self.entries
1244 .iter()
1245 .any(|e| e.rule_id == rule_id && e.file == file && e.fingerprint == fingerprint)
1246 }
1247
1248 pub fn add_with_fingerprint(
1250 &mut self,
1251 rule_id: String,
1252 file: PathBuf,
1253 line: usize,
1254 fingerprint: Fingerprint,
1255 ) {
1256 if !self.contains_fingerprint(&fingerprint) {
1257 self.entries.push(BaselineEntry {
1258 rule_id,
1259 file,
1260 line,
1261 fingerprint: fingerprint.into(),
1262 first_seen: chrono::Utc::now().to_rfc3339(),
1263 suppressed: false,
1264 comment: None,
1265 });
1266 }
1267 }
1268
1269 pub fn add(&mut self, rule_id: String, file: PathBuf, line: usize, fingerprint: String) {
1271 if !self.contains(&rule_id, &file, &fingerprint) {
1272 self.entries.push(BaselineEntry {
1273 rule_id,
1274 file,
1275 line,
1276 fingerprint,
1277 first_seen: chrono::Utc::now().to_rfc3339(),
1278 suppressed: false,
1279 comment: None,
1280 });
1281 }
1282 }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287 use super::*;
1288 use std::str::FromStr;
1289
1290 #[test]
1291 fn test_profile_parsing() {
1292 assert_eq!(Profile::from_str("fast").unwrap(), Profile::Fast);
1293 assert_eq!(Profile::from_str("balanced").unwrap(), Profile::Balanced);
1294 assert_eq!(Profile::from_str("strict").unwrap(), Profile::Strict);
1295 assert!(Profile::from_str("unknown").is_err());
1296 }
1297
1298 #[test]
1299 fn test_rule_matching() {
1300 assert!(RmaTomlConfig::matches_pattern("security/xss", "*"));
1301 assert!(RmaTomlConfig::matches_pattern("security/xss", "security/*"));
1302 assert!(!RmaTomlConfig::matches_pattern(
1303 "generic/long",
1304 "security/*"
1305 ));
1306 assert!(RmaTomlConfig::matches_pattern(
1307 "security/xss",
1308 "security/xss"
1309 ));
1310 }
1311
1312 #[test]
1313 fn test_default_config_parses() {
1314 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1315 let config: RmaTomlConfig = toml::from_str(&toml).expect("Default config should parse");
1316 assert_eq!(config.profiles.default, Profile::Balanced);
1317 }
1318
1319 #[test]
1320 fn test_thresholds_for_profile() {
1321 let fast = ProfileThresholds::for_profile(Profile::Fast);
1322 let strict = ProfileThresholds::for_profile(Profile::Strict);
1323
1324 assert!(fast.max_function_lines > strict.max_function_lines);
1325 assert!(fast.max_complexity > strict.max_complexity);
1326 }
1327
1328 #[test]
1329 fn test_fingerprint_stable_across_line_changes() {
1330 let fp1 = Fingerprint::generate(
1332 "js/xss-sink",
1333 Path::new("src/app.js"),
1334 "element.textContent = userInput;",
1335 );
1336 let fp2 = Fingerprint::generate(
1337 "js/xss-sink",
1338 Path::new("src/app.js"),
1339 "element.textContent = userInput;",
1340 );
1341
1342 assert_eq!(fp1, fp2);
1343 }
1344
1345 #[test]
1346 fn test_fingerprint_stable_with_whitespace_changes() {
1347 let fp1 = Fingerprint::generate(
1349 "generic/long-function",
1350 Path::new("src/utils.rs"),
1351 "fn very_long_function() {",
1352 );
1353 let fp2 = Fingerprint::generate(
1354 "generic/long-function",
1355 Path::new("src/utils.rs"),
1356 "fn very_long_function() {",
1357 );
1358 let fp3 = Fingerprint::generate(
1359 "generic/long-function",
1360 Path::new("src/utils.rs"),
1361 " fn very_long_function() { ",
1362 );
1363
1364 assert_eq!(fp1, fp2);
1365 assert_eq!(fp2, fp3);
1366 }
1367
1368 #[test]
1369 fn test_fingerprint_different_for_different_rules() {
1370 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1371 let fp2 = Fingerprint::generate("js/eval", Path::new("src/app.js"), "element.x = val;");
1372
1373 assert_ne!(fp1, fp2);
1374 }
1375
1376 #[test]
1377 fn test_fingerprint_different_for_different_files() {
1378 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/app.js"), "element.x = val;");
1379 let fp2 =
1380 Fingerprint::generate("js/xss-sink", Path::new("src/other.js"), "element.x = val;");
1381
1382 assert_ne!(fp1, fp2);
1383 }
1384
1385 #[test]
1386 fn test_fingerprint_path_normalization() {
1387 let fp1 = Fingerprint::generate("js/xss-sink", Path::new("src/components/App.js"), "x");
1389 let fp2 = Fingerprint::generate("js/xss-sink", Path::new("src\\components\\App.js"), "x");
1390
1391 assert_eq!(fp1, fp2);
1392 }
1393
1394 #[test]
1395 fn test_effective_config_precedence() {
1396 let toml_config = RmaTomlConfig::default();
1398 let effective = EffectiveConfig::resolve(
1399 Some(&toml_config),
1400 Some(Path::new("rma.toml")),
1401 Some(Profile::Strict), false,
1403 );
1404
1405 assert_eq!(effective.profile, Profile::Strict);
1406 assert_eq!(effective.profile_source, ConfigSource::CliFlag);
1407 }
1408
1409 #[test]
1410 fn test_effective_config_defaults() {
1411 let effective = EffectiveConfig::resolve(None, None, None, false);
1413
1414 assert_eq!(effective.profile, Profile::Balanced);
1415 assert_eq!(effective.profile_source, ConfigSource::Default);
1416 assert!(effective.config_file.is_none());
1417 }
1418
1419 #[test]
1420 fn test_effective_config_from_file() {
1421 let mut toml_config = RmaTomlConfig::default();
1423 toml_config.profiles.default = Profile::Fast;
1424
1425 let effective =
1426 EffectiveConfig::resolve(Some(&toml_config), Some(Path::new("rma.toml")), None, false);
1427
1428 assert_eq!(effective.profile, Profile::Fast);
1429 assert_eq!(effective.profile_source, ConfigSource::ConfigFile);
1430 }
1431
1432 #[test]
1433 fn test_config_version_missing_warns() {
1434 let toml = r#"
1435[profiles]
1436default = "balanced"
1437"#;
1438 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1439 assert!(config.config_version.is_none());
1440 assert!(!config.has_version());
1441 assert_eq!(config.effective_version(), 1);
1442
1443 let warnings = config.validate();
1444 assert!(
1445 warnings
1446 .iter()
1447 .any(|w| w.message.contains("Missing 'config_version'"))
1448 );
1449 }
1450
1451 #[test]
1452 fn test_config_version_1_ok() {
1453 let toml = r#"
1454config_version = 1
1455
1456[profiles]
1457default = "balanced"
1458"#;
1459 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1460 assert_eq!(config.config_version, Some(1));
1461 assert!(config.has_version());
1462 assert_eq!(config.effective_version(), 1);
1463
1464 let warnings = config.validate();
1465 assert!(
1466 !warnings
1467 .iter()
1468 .any(|w| w.message.contains("config_version"))
1469 );
1470 }
1471
1472 #[test]
1473 fn test_config_version_999_fails() {
1474 let toml = r#"
1475config_version = 999
1476
1477[profiles]
1478default = "balanced"
1479"#;
1480 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1481 assert_eq!(config.config_version, Some(999));
1482
1483 let warnings = config.validate();
1484 let error = warnings.iter().find(|w| w.level == WarningLevel::Error);
1485 assert!(error.is_some());
1486 assert!(
1487 error
1488 .unwrap()
1489 .message
1490 .contains("Unsupported config version: 999")
1491 );
1492 }
1493
1494 #[test]
1495 fn test_default_toml_includes_version() {
1496 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1497 assert!(toml.contains("config_version = 1"));
1498
1499 let config: RmaTomlConfig = toml::from_str(&toml).unwrap();
1501 assert_eq!(config.config_version, Some(1));
1502 }
1503
1504 #[test]
1505 fn test_ruleset_security() {
1506 let toml = r#"
1507config_version = 1
1508
1509[rulesets]
1510security = ["js/innerhtml-xss", "js/timer-string-eval"]
1511maintainability = ["generic/long-function"]
1512
1513[rules]
1514enable = ["*"]
1515"#;
1516 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1517
1518 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1520 assert!(config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1521 assert!(!config.is_rule_enabled_with_ruleset("generic/long-function", Some("security")));
1522
1523 assert!(config.is_rule_enabled("generic/long-function"));
1525 }
1526
1527 #[test]
1528 fn test_ruleset_with_disable() {
1529 let toml = r#"
1530config_version = 1
1531
1532[rulesets]
1533security = ["js/innerhtml-xss", "js/timer-string-eval"]
1534
1535[rules]
1536enable = ["*"]
1537disable = ["js/timer-string-eval"]
1538"#;
1539 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1540
1541 assert!(config.is_rule_enabled_with_ruleset("js/innerhtml-xss", Some("security")));
1543 assert!(!config.is_rule_enabled_with_ruleset("js/timer-string-eval", Some("security")));
1544 }
1545
1546 #[test]
1547 fn test_get_ruleset_names() {
1548 let toml = r#"
1549config_version = 1
1550
1551[rulesets]
1552security = ["js/innerhtml-xss"]
1553maintainability = ["generic/long-function"]
1554"#;
1555 let config: RmaTomlConfig = toml::from_str(toml).unwrap();
1556 let names = config.get_ruleset_names();
1557
1558 assert!(names.contains(&"security".to_string()));
1559 assert!(names.contains(&"maintainability".to_string()));
1560 }
1561
1562 #[test]
1563 fn test_default_toml_includes_rulesets() {
1564 let toml = RmaTomlConfig::default_toml(Profile::Balanced);
1565 assert!(toml.contains("[rulesets]"));
1566 assert!(toml.contains("security = "));
1567 assert!(toml.contains("maintainability = "));
1568 }
1569
1570 #[test]
1571 fn test_inline_suppression_next_line() {
1572 let suppression = InlineSuppression::parse(
1573 "// rma-ignore-next-line js/innerhtml-xss reason=\"sanitized input\"",
1574 10,
1575 );
1576 assert!(suppression.is_some());
1577 let s = suppression.unwrap();
1578 assert_eq!(s.rule_id, "js/innerhtml-xss");
1579 assert_eq!(s.reason, Some("sanitized input".to_string()));
1580 assert_eq!(s.line, 10);
1581 assert_eq!(s.suppression_type, SuppressionType::NextLine);
1582
1583 assert!(s.applies_to(11, "js/innerhtml-xss"));
1585 assert!(!s.applies_to(12, "js/innerhtml-xss"));
1587 assert!(!s.applies_to(11, "js/console-log"));
1589 }
1590
1591 #[test]
1592 fn test_inline_suppression_block() {
1593 let suppression = InlineSuppression::parse(
1594 "// rma-ignore generic/long-function reason=\"legacy code\"",
1595 5,
1596 );
1597 assert!(suppression.is_some());
1598 let s = suppression.unwrap();
1599 assert_eq!(s.rule_id, "generic/long-function");
1600 assert_eq!(s.suppression_type, SuppressionType::Block);
1601
1602 assert!(s.applies_to(5, "generic/long-function"));
1604 assert!(s.applies_to(10, "generic/long-function"));
1605 assert!(s.applies_to(100, "generic/long-function"));
1606 }
1607
1608 #[test]
1609 fn test_inline_suppression_without_reason() {
1610 let suppression = InlineSuppression::parse("// rma-ignore-next-line js/console-log", 1);
1611 assert!(suppression.is_some());
1612 let s = suppression.unwrap();
1613 assert_eq!(s.rule_id, "js/console-log");
1614 assert!(s.reason.is_none());
1615 }
1616
1617 #[test]
1618 fn test_inline_suppression_python_style() {
1619 let suppression = InlineSuppression::parse(
1620 "# rma-ignore-next-line python/hardcoded-secret reason=\"test data\"",
1621 3,
1622 );
1623 assert!(suppression.is_some());
1624 let s = suppression.unwrap();
1625 assert_eq!(s.rule_id, "python/hardcoded-secret");
1626 assert_eq!(s.reason, Some("test data".to_string()));
1627 }
1628
1629 #[test]
1630 fn test_inline_suppression_validation_strict() {
1631 let s = InlineSuppression {
1632 rule_id: "js/xss".to_string(),
1633 reason: None,
1634 line: 1,
1635 suppression_type: SuppressionType::NextLine,
1636 };
1637
1638 assert!(s.validate(true).is_err());
1640 assert!(s.validate(false).is_ok());
1642
1643 let s_with_reason = InlineSuppression {
1644 rule_id: "js/xss".to_string(),
1645 reason: Some("approved".to_string()),
1646 line: 1,
1647 suppression_type: SuppressionType::NextLine,
1648 };
1649
1650 assert!(s_with_reason.validate(true).is_ok());
1652 assert!(s_with_reason.validate(false).is_ok());
1653 }
1654
1655 #[test]
1656 fn test_parse_inline_suppressions() {
1657 let content = r#"
1658function foo() {
1659 // rma-ignore-next-line js/console-log reason="debugging"
1660 console.log("test");
1661
1662 // rma-ignore generic/long-function reason="complex algorithm"
1663 // ... lots of code ...
1664}
1665"#;
1666 let suppressions = parse_inline_suppressions(content);
1667 assert_eq!(suppressions.len(), 2);
1668 assert_eq!(suppressions[0].rule_id, "js/console-log");
1669 assert_eq!(suppressions[1].rule_id, "generic/long-function");
1670 }
1671
1672 #[test]
1673 fn test_suppression_does_not_affect_other_rules() {
1674 let suppression = InlineSuppression::parse(
1675 "// rma-ignore-next-line js/innerhtml-xss reason=\"safe\"",
1676 10,
1677 )
1678 .unwrap();
1679
1680 assert!(suppression.applies_to(11, "js/innerhtml-xss"));
1682 assert!(!suppression.applies_to(11, "js/console-log"));
1684 assert!(!suppression.applies_to(11, "generic/long-function"));
1685 }
1686}