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