1use crate::cli::OutputFormat;
2use crate::errors::NyxResult;
3use crate::labels::Cap;
4use crate::patterns::Severity;
5use console::style;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::fs;
10use std::path::Path;
11use std::str::FromStr;
12use toml;
13
14static DEFAULT_CONFIG_TOML: &str = include_str!("../../default-nyx.conf");
15
16#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum AnalysisMode {
19 #[default]
20 Full,
21 Ast,
22 Cfg,
23 Taint,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum RuleKind {
30 Source,
31 Sanitizer,
32 Sink,
33}
34
35impl fmt::Display for RuleKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Self::Source => write!(f, "source"),
39 Self::Sanitizer => write!(f, "sanitizer"),
40 Self::Sink => write!(f, "sink"),
41 }
42 }
43}
44
45impl FromStr for RuleKind {
46 type Err = String;
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_ascii_lowercase().as_str() {
49 "source" => Ok(Self::Source),
50 "sanitizer" => Ok(Self::Sanitizer),
51 "sink" => Ok(Self::Sink),
52 _ => Err(format!(
53 "invalid rule kind: {s:?} (expected source, sanitizer, sink)"
54 )),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum CapName {
63 EnvVar,
64 HtmlEscape,
65 ShellEscape,
66 UrlEncode,
67 JsonParse,
68 FileIo,
69 FmtString,
70 SqlQuery,
71 Deserialize,
72 Ssrf,
73 CodeExec,
74 Crypto,
75 UnauthorizedId,
77 All,
78}
79
80impl CapName {
81 pub fn to_cap(self) -> Cap {
83 match self {
84 Self::EnvVar => Cap::ENV_VAR,
85 Self::HtmlEscape => Cap::HTML_ESCAPE,
86 Self::ShellEscape => Cap::SHELL_ESCAPE,
87 Self::UrlEncode => Cap::URL_ENCODE,
88 Self::JsonParse => Cap::JSON_PARSE,
89 Self::FileIo => Cap::FILE_IO,
90 Self::FmtString => Cap::FMT_STRING,
91 Self::SqlQuery => Cap::SQL_QUERY,
92 Self::Deserialize => Cap::DESERIALIZE,
93 Self::Ssrf => Cap::SSRF,
94 Self::CodeExec => Cap::CODE_EXEC,
95 Self::Crypto => Cap::CRYPTO,
96 Self::UnauthorizedId => Cap::UNAUTHORIZED_ID,
97 Self::All => Cap::all(),
98 }
99 }
100}
101
102impl fmt::Display for CapName {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::EnvVar => write!(f, "env_var"),
106 Self::HtmlEscape => write!(f, "html_escape"),
107 Self::ShellEscape => write!(f, "shell_escape"),
108 Self::UrlEncode => write!(f, "url_encode"),
109 Self::JsonParse => write!(f, "json_parse"),
110 Self::FileIo => write!(f, "file_io"),
111 Self::FmtString => write!(f, "fmt_string"),
112 Self::SqlQuery => write!(f, "sql_query"),
113 Self::Deserialize => write!(f, "deserialize"),
114 Self::Ssrf => write!(f, "ssrf"),
115 Self::CodeExec => write!(f, "code_exec"),
116 Self::Crypto => write!(f, "crypto"),
117 Self::UnauthorizedId => write!(f, "unauthorized_id"),
118 Self::All => write!(f, "all"),
119 }
120 }
121}
122
123impl FromStr for CapName {
124 type Err = String;
125 fn from_str(s: &str) -> Result<Self, Self::Err> {
126 match s.to_ascii_lowercase().as_str() {
127 "env_var" => Ok(Self::EnvVar),
128 "html_escape" => Ok(Self::HtmlEscape),
129 "shell_escape" => Ok(Self::ShellEscape),
130 "url_encode" => Ok(Self::UrlEncode),
131 "json_parse" => Ok(Self::JsonParse),
132 "file_io" => Ok(Self::FileIo),
133 "fmt_string" => Ok(Self::FmtString),
134 "sql_query" => Ok(Self::SqlQuery),
135 "deserialize" => Ok(Self::Deserialize),
136 "ssrf" => Ok(Self::Ssrf),
137 "code_exec" => Ok(Self::CodeExec),
138 "crypto" => Ok(Self::Crypto),
139 "unauthorized_id" => Ok(Self::UnauthorizedId),
140 "all" => Ok(Self::All),
141 _ => Err(format!(
142 "invalid cap name: {s:?} (expected env_var, html_escape, shell_escape, \
143 url_encode, json_parse, file_io, fmt_string, sql_query, deserialize, \
144 ssrf, code_exec, crypto, unauthorized_id, all)"
145 )),
146 }
147 }
148}
149
150#[derive(Debug, Serialize, Deserialize, Clone)]
151#[serde(default)]
152pub struct ScannerConfig {
153 pub mode: AnalysisMode,
155
156 pub min_severity: Severity,
158
159 pub max_file_size_mb: Option<u64>,
161
162 pub excluded_extensions: Vec<String>,
164
165 pub excluded_directories: Vec<String>,
167
168 pub excluded_files: Vec<String>,
170
171 pub read_global_ignore: bool,
173
174 pub read_vcsignore: bool,
176
177 pub require_git_to_read_vcsignore: bool,
179
180 pub one_file_system: bool,
182
183 pub follow_symlinks: bool,
185
186 pub scan_hidden_files: bool,
188
189 pub include_nonprod: bool,
193
194 pub enable_state_analysis: bool,
197
198 pub enable_auth_analysis: bool,
202
203 pub enable_panic_recovery: bool,
208
209 pub enable_auth_as_taint: bool,
217}
218impl Default for ScannerConfig {
219 fn default() -> Self {
220 Self {
221 mode: AnalysisMode::Full,
222 min_severity: Severity::Low,
223 max_file_size_mb: Some(16),
224 excluded_extensions: vec![
225 "jpg", "png", "gif", "mp4", "avi", "mkv", "zip", "tar", "gz", "exe", "dll", "so",
226 ]
227 .into_iter()
228 .map(str::to_owned)
229 .collect(),
230 excluded_directories: vec![
231 "node_modules",
232 ".git",
233 "target",
234 ".vscode",
235 ".idea",
236 "build",
237 "dist",
238 ]
239 .into_iter()
240 .map(str::to_owned)
241 .collect(),
242 excluded_files: vec![].into_iter().map(str::to_owned).collect(),
243 read_global_ignore: false,
244 read_vcsignore: true,
245 require_git_to_read_vcsignore: true,
246 one_file_system: false,
247 follow_symlinks: false,
248 scan_hidden_files: false,
249 include_nonprod: false,
250 enable_state_analysis: true,
251 enable_auth_analysis: true,
252 enable_panic_recovery: false,
253 enable_auth_as_taint: false,
254 }
255 }
256}
257
258#[derive(Debug, Serialize, Deserialize, Clone)]
259#[serde(default)]
260pub struct DatabaseConfig {
261 pub path: String,
263
264 pub auto_cleanup_days: u32,
266
267 pub max_db_size_mb: u64,
269
270 pub vacuum_on_startup: bool,
272}
273impl Default for DatabaseConfig {
274 fn default() -> Self {
275 Self {
276 path: String::from(""),
277 auto_cleanup_days: 30,
278 max_db_size_mb: 1024,
279 vacuum_on_startup: false,
280 }
281 }
282}
283
284#[derive(Debug, Serialize, Deserialize, Clone)]
285#[serde(default)]
286pub struct OutputConfig {
287 pub default_format: OutputFormat,
289
290 pub quiet: bool,
292
293 pub max_results: Option<u32>,
295
296 pub attack_surface_ranking: bool,
298
299 pub min_score: Option<u32>,
303
304 #[serde(
307 default,
308 skip_serializing_if = "Option::is_none",
309 deserialize_with = "deserialize_confidence_opt"
310 )]
311 pub min_confidence: Option<crate::evidence::Confidence>,
312
313 #[serde(default)]
325 pub require_converged: bool,
326
327 #[serde(default)]
329 pub include_quality: bool,
330
331 #[serde(default)]
333 pub show_all: bool,
334
335 #[serde(default = "default_max_low")]
337 pub max_low: u32,
338
339 #[serde(default = "default_max_low_per_file")]
341 pub max_low_per_file: u32,
342
343 #[serde(default = "default_max_low_per_rule")]
345 pub max_low_per_rule: u32,
346
347 #[serde(default = "default_rollup_examples")]
349 pub rollup_examples: u32,
350}
351
352fn default_max_low() -> u32 {
353 20
354}
355fn default_max_low_per_file() -> u32 {
356 1
357}
358fn default_max_low_per_rule() -> u32 {
359 10
360}
361fn default_rollup_examples() -> u32 {
362 5
363}
364
365impl Default for OutputConfig {
366 fn default() -> Self {
367 Self {
368 default_format: OutputFormat::Console,
369 quiet: false,
370 max_results: None,
371 attack_surface_ranking: true,
372 min_score: None,
373 min_confidence: None,
374 require_converged: false,
375 include_quality: false,
376 show_all: false,
377 max_low: 20,
378 max_low_per_file: 1,
379 max_low_per_rule: 10,
380 rollup_examples: 5,
381 }
382 }
383}
384
385fn deserialize_confidence_opt<'de, D>(
387 deserializer: D,
388) -> Result<Option<crate::evidence::Confidence>, D::Error>
389where
390 D: serde::Deserializer<'de>,
391{
392 let opt: Option<String> = Option::deserialize(deserializer)?;
393 match opt {
394 None => Ok(None),
395 Some(s) => s
396 .parse::<crate::evidence::Confidence>()
397 .map(Some)
398 .map_err(serde::de::Error::custom),
399 }
400}
401
402#[derive(Debug, Serialize, Deserialize, Clone)]
403#[serde(default)]
404pub struct PerformanceConfig {
405 pub max_depth: Option<usize>,
410
411 pub min_depth: Option<usize>,
413
414 pub prune: bool,
416
417 pub worker_threads: Option<usize>,
419
420 pub batch_size: usize,
422
423 pub channel_multiplier: usize,
425
426 pub rayon_thread_stack_size: usize,
428
429 pub scan_timeout_secs: Option<u64>,
431
432 pub memory_limit_mb: u64,
434}
435
436impl Default for PerformanceConfig {
437 fn default() -> Self {
438 Self {
439 max_depth: None,
440 min_depth: None,
441 prune: false,
442 worker_threads: None,
443 batch_size: 100usize,
444 channel_multiplier: 4usize,
445 rayon_thread_stack_size: 8 * 1024 * 1024, scan_timeout_secs: None,
447 memory_limit_mb: 512,
448 }
449 }
450}
451
452#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
454pub struct ConfigLabelRule {
455 pub matchers: Vec<String>,
456 pub kind: RuleKind,
458 pub cap: CapName,
460 #[serde(default)]
461 pub case_sensitive: bool,
462}
463
464#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
466#[serde(default)]
467pub struct LanguageAnalysisConfig {
468 pub rules: Vec<ConfigLabelRule>,
469 pub terminators: Vec<String>,
470 pub event_handlers: Vec<String>,
471 pub auth: AuthAnalysisConfig,
472}
473
474fn default_auth_enabled() -> bool {
475 true
476}
477
478#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
480#[serde(default)]
481pub struct AuthAnalysisConfig {
482 pub enabled: bool,
483 pub admin_path_patterns: Vec<String>,
484 pub admin_guard_names: Vec<String>,
485 pub login_guard_names: Vec<String>,
486 #[serde(default)]
494 pub policy_guard_names: Vec<String>,
495 pub authorization_check_names: Vec<String>,
496 pub mutation_indicator_names: Vec<String>,
497 pub read_indicator_names: Vec<String>,
498 pub token_lookup_names: Vec<String>,
499 pub token_expiry_fields: Vec<String>,
500 pub token_recipient_fields: Vec<String>,
501 pub non_sink_receiver_types: Vec<String>,
508 pub non_sink_receiver_name_prefixes: Vec<String>,
513 #[serde(default)]
521 pub non_sink_global_receivers: Vec<String>,
522 #[serde(default)]
527 pub non_sink_method_names: Vec<String>,
528 pub realtime_receiver_prefixes: Vec<String>,
533 pub outbound_network_receiver_prefixes: Vec<String>,
537 pub cache_receiver_prefixes: Vec<String>,
541 pub acl_tables: Vec<String>,
547}
548
549impl Default for AuthAnalysisConfig {
550 fn default() -> Self {
551 Self {
552 enabled: default_auth_enabled(),
553 admin_path_patterns: Vec::new(),
554 admin_guard_names: Vec::new(),
555 login_guard_names: Vec::new(),
556 policy_guard_names: Vec::new(),
557 authorization_check_names: Vec::new(),
558 mutation_indicator_names: Vec::new(),
559 read_indicator_names: Vec::new(),
560 token_lookup_names: Vec::new(),
561 token_expiry_fields: Vec::new(),
562 token_recipient_fields: Vec::new(),
563 non_sink_receiver_types: Vec::new(),
564 non_sink_receiver_name_prefixes: Vec::new(),
565 non_sink_global_receivers: Vec::new(),
566 non_sink_method_names: Vec::new(),
567 realtime_receiver_prefixes: Vec::new(),
568 outbound_network_receiver_prefixes: Vec::new(),
569 cache_receiver_prefixes: Vec::new(),
570 acl_tables: Vec::new(),
571 }
572 }
573}
574
575#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
577#[serde(default)]
578pub struct AnalysisRulesConfig {
579 pub languages: HashMap<String, LanguageAnalysisConfig>,
580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
582 pub disabled_rules: Vec<String>,
583 #[serde(default)]
587 pub engine: crate::utils::AnalysisOptions,
588}
589
590#[derive(Debug, Serialize, Deserialize, Clone)]
592#[serde(default)]
593pub struct ServerConfig {
594 pub enabled: bool,
596 pub host: String,
598 pub port: u16,
600 pub open_browser: bool,
602 pub auto_reload: bool,
604 pub persist_runs: bool,
606 pub max_saved_runs: u32,
608 pub triage_sync: bool,
612}
613
614impl Default for ServerConfig {
615 fn default() -> Self {
616 Self {
617 enabled: true,
618 host: "127.0.0.1".into(),
619 port: 9700,
620 open_browser: true,
621 auto_reload: true,
622 persist_runs: true,
623 max_saved_runs: 50,
624 triage_sync: true,
625 }
626 }
627}
628
629#[derive(Debug, Serialize, Deserialize, Clone)]
631#[serde(default)]
632pub struct RunsConfig {
633 pub persist: bool,
635 pub max_runs: u32,
637 pub save_logs: bool,
639 pub save_stdout: bool,
641 pub save_code_snippets: bool,
643}
644
645impl Default for RunsConfig {
646 fn default() -> Self {
647 Self {
648 persist: false,
649 max_runs: 100,
650 save_logs: false,
651 save_stdout: false,
652 save_code_snippets: true,
653 }
654 }
655}
656
657#[derive(Debug, Serialize, Deserialize, Clone, Default)]
660#[serde(default)]
661pub struct ScanProfile {
662 pub mode: Option<AnalysisMode>,
663 pub min_severity: Option<Severity>,
664 pub max_file_size_mb: Option<u64>,
665 pub include_nonprod: Option<bool>,
666 pub enable_state_analysis: Option<bool>,
667 pub enable_auth_analysis: Option<bool>,
668 pub default_format: Option<OutputFormat>,
669 pub quiet: Option<bool>,
670 pub attack_surface_ranking: Option<bool>,
671 pub max_results: Option<u32>,
672 pub min_score: Option<u32>,
673 pub show_all: Option<bool>,
674 pub include_quality: Option<bool>,
675 pub worker_threads: Option<usize>,
676 pub max_depth: Option<usize>,
677}
678
679fn builtin_profile(name: &str) -> Option<ScanProfile> {
681 Some(match name {
682 "quick" => ScanProfile {
683 mode: Some(AnalysisMode::Ast),
684 min_severity: Some(Severity::Medium),
685 ..Default::default()
686 },
687 "full" => ScanProfile {
688 mode: Some(AnalysisMode::Full),
689 min_severity: Some(Severity::Low),
690 enable_state_analysis: Some(true),
691 enable_auth_analysis: Some(true),
692 ..Default::default()
693 },
694 "ci" => ScanProfile {
695 mode: Some(AnalysisMode::Full),
696 min_severity: Some(Severity::Medium),
697 quiet: Some(true),
698 default_format: Some(OutputFormat::Sarif),
699 ..Default::default()
700 },
701 "taint_only" => ScanProfile {
702 mode: Some(AnalysisMode::Taint),
703 ..Default::default()
704 },
705 "conservative_large_repo" => ScanProfile {
706 mode: Some(AnalysisMode::Ast),
707 min_severity: Some(Severity::High),
708 max_file_size_mb: Some(5),
709 max_depth: Some(10),
710 ..Default::default()
711 },
712 _ => return None,
713 })
714}
715
716#[derive(Debug, Serialize, Deserialize, Clone)]
733#[serde(default)]
734#[derive(Default)]
735pub struct Config {
736 pub scanner: ScannerConfig,
737 pub database: DatabaseConfig,
738 pub output: OutputConfig,
739 pub performance: PerformanceConfig,
740 pub analysis: AnalysisRulesConfig,
741 #[serde(default)]
744 pub detectors: crate::utils::detector_options::DetectorOptions,
745 pub server: ServerConfig,
746 pub runs: RunsConfig,
747 pub profiles: HashMap<String, ScanProfile>,
748 #[serde(skip)]
751 pub framework_ctx: Option<crate::utils::project::FrameworkContext>,
752}
753
754impl Config {
755 pub fn load(config_dir: &Path) -> NyxResult<(Self, Option<String>)> {
761 let mut config = Config::default();
762
763 let default_config_path = config_dir.join("nyx.conf");
764 if !default_config_path.exists() {
765 create_example_config(config_dir)?;
766 }
767
768 let user_config_path = config_dir.join("nyx.local");
769 let note = if user_config_path.exists() {
770 let user_config_content = fs::read_to_string(&user_config_path)?;
771 let user_config: Config = toml::from_str(&user_config_content)?;
772
773 config = merge_configs(config, user_config);
774
775 Some(format!(
776 "{}: Loaded user config from: {}\n",
777 style("note").green().bold(),
778 style(user_config_path.display())
779 .underlined()
780 .white()
781 .bold()
782 ))
783 } else {
784 Some(format!(
785 "{}: Using {} configuration.\n Create file in '{}' to customize.\n",
786 style("note").green().bold(),
787 style("default").bold(),
788 style(user_config_path.display())
789 .underlined()
790 .white()
791 .bold()
792 ))
793 };
794
795 config
796 .validate()
797 .map_err(crate::errors::NyxError::ConfigValidation)?;
798
799 Ok((config, note))
800 }
801
802 pub fn resolve_profile(&self, name: &str) -> Option<ScanProfile> {
804 self.profiles
805 .get(name)
806 .cloned()
807 .or_else(|| builtin_profile(name))
808 }
809
810 pub fn apply_profile(&mut self, name: &str) -> NyxResult<()> {
813 let profile = self.resolve_profile(name).ok_or_else(|| {
814 crate::errors::NyxError::Msg(format!(
815 "unknown profile '{name}'. Built-in profiles: quick, full, ci, taint_only, conservative_large_repo"
816 ))
817 })?;
818
819 if let Some(v) = profile.mode {
820 self.scanner.mode = v;
821 }
822 if let Some(v) = profile.min_severity {
823 self.scanner.min_severity = v;
824 }
825 if let Some(v) = profile.max_file_size_mb {
826 self.scanner.max_file_size_mb = Some(v);
827 }
828 if let Some(v) = profile.include_nonprod {
829 self.scanner.include_nonprod = v;
830 }
831 if let Some(v) = profile.enable_state_analysis {
832 self.scanner.enable_state_analysis = v;
833 }
834 if let Some(v) = profile.enable_auth_analysis {
835 self.scanner.enable_auth_analysis = v;
836 }
837 if let Some(v) = profile.default_format {
838 self.output.default_format = v;
839 }
840 if let Some(v) = profile.quiet {
841 self.output.quiet = v;
842 }
843 if let Some(v) = profile.attack_surface_ranking {
844 self.output.attack_surface_ranking = v;
845 }
846 if let Some(v) = profile.max_results {
847 self.output.max_results = Some(v);
848 }
849 if let Some(v) = profile.min_score {
850 self.output.min_score = Some(v);
851 }
852 if let Some(v) = profile.show_all {
853 self.output.show_all = v;
854 }
855 if let Some(v) = profile.include_quality {
856 self.output.include_quality = v;
857 }
858 if let Some(v) = profile.worker_threads {
859 self.performance.worker_threads = Some(v);
860 }
861 if let Some(v) = profile.max_depth {
862 self.performance.max_depth = Some(v);
863 }
864
865 Ok(())
866 }
867
868 pub fn validate(&self) -> Result<(), Vec<crate::errors::ConfigError>> {
871 use crate::errors::{ConfigError, ConfigErrorKind};
872 let mut errors = Vec::new();
873
874 if self.server.port == 0 {
876 errors.push(ConfigError {
877 section: "server".into(),
878 field: "port".into(),
879 message: "port must be 1–65535".into(),
880 kind: ConfigErrorKind::OutOfRange,
881 });
882 }
883 if self.server.host.is_empty() {
884 errors.push(ConfigError {
885 section: "server".into(),
886 field: "host".into(),
887 message: "host must not be empty".into(),
888 kind: ConfigErrorKind::EmptyRequired,
889 });
890 }
891 if self.server.persist_runs && self.server.max_saved_runs == 0 {
892 errors.push(ConfigError {
893 section: "server".into(),
894 field: "max_saved_runs".into(),
895 message: "max_saved_runs must be > 0 when persist_runs is true".into(),
896 kind: ConfigErrorKind::Conflict,
897 });
898 }
899
900 if self.runs.persist && self.runs.max_runs == 0 {
902 errors.push(ConfigError {
903 section: "runs".into(),
904 field: "max_runs".into(),
905 message: "max_runs must be > 0 when persist is true".into(),
906 kind: ConfigErrorKind::Conflict,
907 });
908 }
909
910 if self.performance.batch_size == 0 {
912 errors.push(ConfigError {
913 section: "performance".into(),
914 field: "batch_size".into(),
915 message: "batch_size must be > 0".into(),
916 kind: ConfigErrorKind::OutOfRange,
917 });
918 }
919 if self.performance.channel_multiplier == 0 {
920 errors.push(ConfigError {
921 section: "performance".into(),
922 field: "channel_multiplier".into(),
923 message: "channel_multiplier must be > 0".into(),
924 kind: ConfigErrorKind::OutOfRange,
925 });
926 }
927
928 if self.output.rollup_examples == 0 {
930 errors.push(ConfigError {
931 section: "output".into(),
932 field: "rollup_examples".into(),
933 message: "rollup_examples must be > 0".into(),
934 kind: ConfigErrorKind::OutOfRange,
935 });
936 }
937
938 for name in self.profiles.keys() {
940 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
941 errors.push(ConfigError {
942 section: "profiles".into(),
943 field: name.clone(),
944 message: format!(
945 "profile name '{name}' must contain only alphanumeric characters and underscores"
946 ),
947 kind: ConfigErrorKind::InvalidValue,
948 });
949 }
950 }
951
952 if errors.is_empty() {
953 Ok(())
954 } else {
955 Err(errors)
956 }
957 }
958}
959
960fn create_example_config(config_dir: &Path) -> NyxResult<()> {
961 let example_path = config_dir.join("nyx.conf");
962 if !example_path.exists() {
963 fs::write(&example_path, DEFAULT_CONFIG_TOML)?;
964 tracing::debug!("Example config created at: {}", example_path.display());
965 }
966 Ok(())
967}
968
969pub(crate) fn merge_configs(mut default: Config, user: Config) -> Config {
972 default.scanner.mode = user.scanner.mode;
974 default.scanner.min_severity = user.scanner.min_severity;
975 default.scanner.max_file_size_mb = user.scanner.max_file_size_mb;
976 default.scanner.read_global_ignore = user.scanner.read_global_ignore;
977 default.scanner.read_vcsignore = user.scanner.read_vcsignore;
978 default.scanner.require_git_to_read_vcsignore = user.scanner.require_git_to_read_vcsignore;
979 default.scanner.one_file_system = user.scanner.one_file_system;
980 default.scanner.follow_symlinks = user.scanner.follow_symlinks;
981 default.scanner.scan_hidden_files = user.scanner.scan_hidden_files;
982 default.scanner.include_nonprod = user.scanner.include_nonprod;
983 default.scanner.enable_state_analysis = user.scanner.enable_state_analysis;
984 default.scanner.enable_auth_analysis = user.scanner.enable_auth_analysis;
985 default.scanner.enable_panic_recovery = user.scanner.enable_panic_recovery;
986 default.scanner.enable_auth_as_taint = user.scanner.enable_auth_as_taint;
987
988 default
990 .scanner
991 .excluded_extensions
992 .extend(user.scanner.excluded_extensions);
993 default
994 .scanner
995 .excluded_directories
996 .extend(user.scanner.excluded_directories);
997 default.scanner.excluded_extensions.sort_unstable();
998 default.scanner.excluded_extensions.dedup();
999 default.scanner.excluded_directories.sort_unstable();
1000 default.scanner.excluded_directories.dedup();
1001 default
1002 .scanner
1003 .excluded_files
1004 .extend(user.scanner.excluded_files);
1005 default.scanner.excluded_files.sort_unstable();
1006 default.scanner.excluded_files.dedup();
1007
1008 default.database.path = user.database.path;
1010 default.database.auto_cleanup_days = user.database.auto_cleanup_days;
1011 default.database.max_db_size_mb = user.database.max_db_size_mb;
1012 default.database.vacuum_on_startup = user.database.vacuum_on_startup;
1013
1014 default.output.default_format = user.output.default_format;
1016 default.output.quiet = user.output.quiet;
1017 default.output.max_results = user.output.max_results;
1018 default.output.attack_surface_ranking = user.output.attack_surface_ranking;
1019 default.output.min_score = user.output.min_score;
1020 default.output.min_confidence = user.output.min_confidence;
1021 default.output.require_converged = user.output.require_converged;
1022 default.output.include_quality = user.output.include_quality;
1023 default.output.show_all = user.output.show_all;
1024 default.output.max_low = user.output.max_low;
1025 default.output.max_low_per_file = user.output.max_low_per_file;
1026 default.output.max_low_per_rule = user.output.max_low_per_rule;
1027 default.output.rollup_examples = user.output.rollup_examples;
1028
1029 default.performance.max_depth = user.performance.max_depth;
1031 default.performance.min_depth = user.performance.min_depth;
1032 default.performance.prune = user.performance.prune;
1033 default.performance.worker_threads = user.performance.worker_threads;
1034 default.performance.batch_size = user.performance.batch_size;
1035 default.performance.channel_multiplier = user.performance.channel_multiplier;
1036 default.performance.rayon_thread_stack_size = user.performance.rayon_thread_stack_size;
1037 default.performance.scan_timeout_secs = user.performance.scan_timeout_secs;
1038 default.performance.memory_limit_mb = user.performance.memory_limit_mb;
1039
1040 default.server = user.server;
1042
1043 default.runs = user.runs;
1045
1046 for (name, profile) in user.profiles {
1048 default.profiles.insert(name, profile);
1049 }
1050
1051 default.detectors.data_exfil.enabled = user.detectors.data_exfil.enabled;
1057 extend_dedup(
1058 &mut default.detectors.data_exfil.trusted_destinations,
1059 user.detectors.data_exfil.trusted_destinations,
1060 );
1061
1062 default.analysis.engine = user.analysis.engine;
1067 for (lang, user_lang_cfg) in user.analysis.languages {
1068 let entry = default.analysis.languages.entry(lang).or_default();
1069
1070 for rule in user_lang_cfg.rules {
1072 if !entry.rules.contains(&rule) {
1073 entry.rules.push(rule);
1074 }
1075 }
1076
1077 for t in user_lang_cfg.terminators {
1079 if !entry.terminators.contains(&t) {
1080 entry.terminators.push(t);
1081 }
1082 }
1083
1084 for eh in user_lang_cfg.event_handlers {
1086 if !entry.event_handlers.contains(&eh) {
1087 entry.event_handlers.push(eh);
1088 }
1089 }
1090
1091 entry.auth.enabled = user_lang_cfg.auth.enabled;
1092 extend_dedup(
1093 &mut entry.auth.admin_path_patterns,
1094 user_lang_cfg.auth.admin_path_patterns,
1095 );
1096 extend_dedup(
1097 &mut entry.auth.admin_guard_names,
1098 user_lang_cfg.auth.admin_guard_names,
1099 );
1100 extend_dedup(
1101 &mut entry.auth.login_guard_names,
1102 user_lang_cfg.auth.login_guard_names,
1103 );
1104 extend_dedup(
1105 &mut entry.auth.policy_guard_names,
1106 user_lang_cfg.auth.policy_guard_names,
1107 );
1108 extend_dedup(
1109 &mut entry.auth.authorization_check_names,
1110 user_lang_cfg.auth.authorization_check_names,
1111 );
1112 extend_dedup(
1113 &mut entry.auth.mutation_indicator_names,
1114 user_lang_cfg.auth.mutation_indicator_names,
1115 );
1116 extend_dedup(
1117 &mut entry.auth.read_indicator_names,
1118 user_lang_cfg.auth.read_indicator_names,
1119 );
1120 extend_dedup(
1121 &mut entry.auth.token_lookup_names,
1122 user_lang_cfg.auth.token_lookup_names,
1123 );
1124 extend_dedup(
1125 &mut entry.auth.token_expiry_fields,
1126 user_lang_cfg.auth.token_expiry_fields,
1127 );
1128 extend_dedup(
1129 &mut entry.auth.token_recipient_fields,
1130 user_lang_cfg.auth.token_recipient_fields,
1131 );
1132 extend_dedup(
1133 &mut entry.auth.non_sink_receiver_types,
1134 user_lang_cfg.auth.non_sink_receiver_types,
1135 );
1136 extend_dedup(
1137 &mut entry.auth.non_sink_receiver_name_prefixes,
1138 user_lang_cfg.auth.non_sink_receiver_name_prefixes,
1139 );
1140 extend_dedup(
1141 &mut entry.auth.non_sink_global_receivers,
1142 user_lang_cfg.auth.non_sink_global_receivers,
1143 );
1144 extend_dedup(
1145 &mut entry.auth.non_sink_method_names,
1146 user_lang_cfg.auth.non_sink_method_names,
1147 );
1148 extend_dedup(
1149 &mut entry.auth.realtime_receiver_prefixes,
1150 user_lang_cfg.auth.realtime_receiver_prefixes,
1151 );
1152 extend_dedup(
1153 &mut entry.auth.outbound_network_receiver_prefixes,
1154 user_lang_cfg.auth.outbound_network_receiver_prefixes,
1155 );
1156 extend_dedup(
1157 &mut entry.auth.cache_receiver_prefixes,
1158 user_lang_cfg.auth.cache_receiver_prefixes,
1159 );
1160 extend_dedup(&mut entry.auth.acl_tables, user_lang_cfg.auth.acl_tables);
1161 }
1162
1163 default
1164}
1165
1166fn extend_dedup(dst: &mut Vec<String>, src: Vec<String>) {
1167 for item in src {
1168 if !dst.contains(&item) {
1169 dst.push(item);
1170 }
1171 }
1172}
1173
1174#[test]
1175fn merge_configs_dedupes_and_keeps_order() {
1176 let mut default_cfg = Config::default();
1177 default_cfg.scanner.excluded_extensions = vec!["rs".into(), "toml".into()];
1178
1179 let mut user_cfg = Config::default();
1180 user_cfg.scanner.excluded_extensions = vec!["jpg".into(), "rs".into()];
1181
1182 let merged = merge_configs(default_cfg, user_cfg);
1183
1184 assert_eq!(
1185 merged.scanner.excluded_extensions,
1186 vec!["jpg", "rs", "toml"]
1187 );
1188}
1189
1190#[test]
1191fn merge_analysis_rules_unions_and_dedupes() {
1192 let mut default_cfg = Config::default();
1193 default_cfg.analysis.languages.insert(
1194 "javascript".into(),
1195 LanguageAnalysisConfig {
1196 rules: vec![ConfigLabelRule {
1197 matchers: vec!["escapeHtml".into()],
1198 kind: RuleKind::Sanitizer,
1199 cap: CapName::HtmlEscape,
1200 case_sensitive: false,
1201 }],
1202 terminators: vec!["process.exit".into()],
1203 event_handlers: vec![],
1204 auth: AuthAnalysisConfig::default(),
1205 },
1206 );
1207
1208 let mut user_cfg = Config::default();
1209 user_cfg.analysis.languages.insert(
1210 "javascript".into(),
1211 LanguageAnalysisConfig {
1212 rules: vec![
1213 ConfigLabelRule {
1214 matchers: vec!["escapeHtml".into()],
1215 kind: RuleKind::Sanitizer,
1216 cap: CapName::HtmlEscape,
1217 case_sensitive: false,
1218 },
1219 ConfigLabelRule {
1220 matchers: vec!["sanitizeUrl".into()],
1221 kind: RuleKind::Sanitizer,
1222 cap: CapName::UrlEncode,
1223 case_sensitive: false,
1224 },
1225 ],
1226 terminators: vec!["process.exit".into(), "abort".into()],
1227 event_handlers: vec!["addEventListener".into()],
1228 auth: AuthAnalysisConfig {
1229 enabled: true,
1230 admin_guard_names: vec!["requireAdmin".into()],
1231 token_lookup_names: vec!["findByToken".into()],
1232 ..AuthAnalysisConfig::default()
1233 },
1234 },
1235 );
1236
1237 let merged = merge_configs(default_cfg, user_cfg);
1238 let js = merged.analysis.languages.get("javascript").unwrap();
1239 assert_eq!(js.rules.len(), 2); assert_eq!(js.terminators, vec!["process.exit", "abort"]);
1241 assert_eq!(js.event_handlers, vec!["addEventListener"]);
1242 assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1243 assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1244}
1245
1246#[test]
1247fn analysis_config_toml_roundtrip() {
1248 let toml_str = r#"
1249[analysis.languages.javascript]
1250terminators = ["process.exit"]
1251event_handlers = ["addEventListener"]
1252
1253[analysis.languages.javascript.auth]
1254enabled = true
1255admin_guard_names = ["requireAdmin"]
1256token_lookup_names = ["findByToken"]
1257
1258[[analysis.languages.javascript.rules]]
1259matchers = ["escapeHtml"]
1260kind = "sanitizer"
1261cap = "html_escape"
1262 "#;
1263 let cfg: Config = toml::from_str(toml_str).unwrap();
1264 let js = cfg.analysis.languages.get("javascript").unwrap();
1265 assert_eq!(js.rules.len(), 1);
1266 assert_eq!(js.rules[0].matchers, vec!["escapeHtml"]);
1267 assert_eq!(js.rules[0].kind, RuleKind::Sanitizer);
1268 assert_eq!(js.rules[0].cap, CapName::HtmlEscape);
1269 assert_eq!(js.terminators, vec!["process.exit"]);
1270 assert_eq!(js.event_handlers, vec!["addEventListener"]);
1271 assert!(js.auth.enabled);
1272 assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1273 assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1274}
1275
1276#[test]
1277fn analysis_auth_config_toml_roundtrip_supports_typescript_overlay() {
1278 let toml_str = r#"
1279[analysis.languages.javascript.auth]
1280enabled = true
1281admin_guard_names = ["requireAdmin"]
1282
1283[analysis.languages.typescript.auth]
1284enabled = true
1285authorization_check_names = ["requireTypedOwnership"]
1286token_lookup_names = ["findInviteToken"]
1287 "#;
1288 let cfg: Config = toml::from_str(toml_str).unwrap();
1289 let js = cfg.analysis.languages.get("javascript").unwrap();
1290 let ts = cfg.analysis.languages.get("typescript").unwrap();
1291 assert!(js.auth.enabled);
1292 assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1293 assert!(ts.auth.enabled);
1294 assert_eq!(
1295 ts.auth.authorization_check_names,
1296 vec!["requireTypedOwnership"]
1297 );
1298 assert_eq!(ts.auth.token_lookup_names, vec!["findInviteToken"]);
1299}
1300
1301#[test]
1302fn merge_analysis_rules_preserves_per_language_auth_sections() {
1303 let mut default_cfg = Config::default();
1304 default_cfg.analysis.languages.insert(
1305 "javascript".into(),
1306 LanguageAnalysisConfig {
1307 auth: AuthAnalysisConfig {
1308 admin_guard_names: vec!["requireAdmin".into()],
1309 ..AuthAnalysisConfig::default()
1310 },
1311 ..LanguageAnalysisConfig::default()
1312 },
1313 );
1314
1315 let mut user_cfg = Config::default();
1316 user_cfg.analysis.languages.insert(
1317 "javascript".into(),
1318 LanguageAnalysisConfig {
1319 auth: AuthAnalysisConfig {
1320 token_lookup_names: vec!["findByToken".into()],
1321 ..AuthAnalysisConfig::default()
1322 },
1323 ..LanguageAnalysisConfig::default()
1324 },
1325 );
1326 user_cfg.analysis.languages.insert(
1327 "typescript".into(),
1328 LanguageAnalysisConfig {
1329 auth: AuthAnalysisConfig {
1330 authorization_check_names: vec!["requireTypedOwnership".into()],
1331 ..AuthAnalysisConfig::default()
1332 },
1333 ..LanguageAnalysisConfig::default()
1334 },
1335 );
1336
1337 let merged = merge_configs(default_cfg, user_cfg);
1338 let js = merged.analysis.languages.get("javascript").unwrap();
1339 let ts = merged.analysis.languages.get("typescript").unwrap();
1340
1341 assert_eq!(js.auth.admin_guard_names, vec!["requireAdmin"]);
1342 assert_eq!(js.auth.token_lookup_names, vec!["findByToken"]);
1343 assert_eq!(
1344 ts.auth.authorization_check_names,
1345 vec!["requireTypedOwnership"]
1346 );
1347}
1348
1349#[test]
1350fn load_creates_example_and_reads_user_overrides() {
1351 let cfg_dir = tempfile::tempdir().unwrap();
1352 let cfg_path = cfg_dir.path();
1353
1354 let user_toml = r#"
1355 [scanner]
1356 one_file_system = true
1357 excluded_extensions = ["foo"]
1358
1359 [output]
1360 quiet = true
1361 "#;
1362 fs::write(cfg_path.join("nyx.local"), user_toml).unwrap();
1363
1364 let (cfg, _note) = Config::load(cfg_path).expect("Config::load should succeed");
1365
1366 assert!(cfg_path.join("nyx.conf").is_file());
1367
1368 assert!(cfg.scanner.one_file_system);
1369 assert!(cfg.output.quiet);
1370 assert!(cfg.scanner.excluded_extensions.contains(&"foo".to_string()));
1371
1372 assert!(!cfg.scanner.follow_symlinks);
1373}
1374
1375#[test]
1378fn enum_roundtrip_output_format() {
1379 let toml_str = r#"
1380 [output]
1381 default_format = "json"
1382 "#;
1383 let cfg: Config = toml::from_str(toml_str).unwrap();
1384 assert_eq!(cfg.output.default_format, OutputFormat::Json);
1385
1386 let toml_str = r#"
1387 [output]
1388 default_format = "sarif"
1389 "#;
1390 let cfg: Config = toml::from_str(toml_str).unwrap();
1391 assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
1392
1393 let toml_str = r#"
1394 [output]
1395 default_format = "console"
1396 "#;
1397 let cfg: Config = toml::from_str(toml_str).unwrap();
1398 assert_eq!(cfg.output.default_format, OutputFormat::Console);
1399}
1400
1401#[test]
1402fn enum_roundtrip_rule_kind() {
1403 let toml_str = r#"
1404 [[analysis.languages.javascript.rules]]
1405 matchers = ["foo"]
1406 kind = "source"
1407 cap = "all"
1408
1409 [[analysis.languages.javascript.rules]]
1410 matchers = ["bar"]
1411 kind = "sanitizer"
1412 cap = "html_escape"
1413
1414 [[analysis.languages.javascript.rules]]
1415 matchers = ["baz"]
1416 kind = "sink"
1417 cap = "sql_query"
1418 "#;
1419 let cfg: Config = toml::from_str(toml_str).unwrap();
1420 let js = cfg.analysis.languages.get("javascript").unwrap();
1421 assert_eq!(js.rules[0].kind, RuleKind::Source);
1422 assert_eq!(js.rules[1].kind, RuleKind::Sanitizer);
1423 assert_eq!(js.rules[2].kind, RuleKind::Sink);
1424}
1425
1426#[test]
1427fn enum_roundtrip_cap_name() {
1428 let caps = [
1429 "env_var",
1430 "html_escape",
1431 "shell_escape",
1432 "url_encode",
1433 "json_parse",
1434 "file_io",
1435 "fmt_string",
1436 "sql_query",
1437 "deserialize",
1438 "ssrf",
1439 "code_exec",
1440 "crypto",
1441 "all",
1442 ];
1443 for cap_str in caps {
1444 let toml_str = format!(
1445 r#"
1446 [[analysis.languages.rust.rules]]
1447 matchers = ["x"]
1448 kind = "source"
1449 cap = "{cap_str}"
1450 "#
1451 );
1452 let cfg: Config = toml::from_str(&toml_str)
1453 .unwrap_or_else(|e| panic!("failed to parse cap '{cap_str}': {e}"));
1454 let rs = cfg.analysis.languages.get("rust").unwrap();
1455 assert_eq!(rs.rules[0].cap.to_string(), cap_str);
1456 }
1457}
1458
1459#[test]
1460fn backward_compat_existing_toml() {
1461 let toml_str = r#"
1463 [scanner]
1464 mode = "full"
1465 min_severity = "Medium"
1466
1467 [output]
1468 default_format = "console"
1469 quiet = true
1470
1471 [[analysis.languages.javascript.rules]]
1472 matchers = ["escapeHtml"]
1473 kind = "sanitizer"
1474 cap = "html_escape"
1475
1476 [analysis.languages.javascript.auth]
1477 enabled = false
1478 admin_path_patterns = ["/admin/"]
1479 "#;
1480 let cfg: Config = toml::from_str(toml_str).unwrap();
1481 assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
1482 assert_eq!(cfg.output.default_format, OutputFormat::Console);
1483 assert_eq!(
1484 cfg.analysis.languages["javascript"].rules[0].kind,
1485 RuleKind::Sanitizer
1486 );
1487 assert_eq!(
1488 cfg.analysis.languages["javascript"].rules[0].cap,
1489 CapName::HtmlEscape
1490 );
1491 assert!(!cfg.analysis.languages["javascript"].auth.enabled);
1492 assert_eq!(
1493 cfg.analysis.languages["javascript"]
1494 .auth
1495 .admin_path_patterns,
1496 vec!["/admin/"]
1497 );
1498}
1499
1500#[test]
1501fn auth_analysis_config_defaults() {
1502 let cfg = AuthAnalysisConfig::default();
1503 assert!(cfg.enabled);
1504 assert!(cfg.admin_path_patterns.is_empty());
1505 assert!(cfg.authorization_check_names.is_empty());
1506}
1507
1508#[test]
1511fn server_config_defaults() {
1512 let cfg = ServerConfig::default();
1513 assert!(cfg.enabled);
1514 assert_eq!(cfg.host, "127.0.0.1");
1515 assert_eq!(cfg.port, 9700);
1516 assert!(cfg.open_browser);
1517 assert!(cfg.auto_reload);
1518 assert!(cfg.persist_runs);
1519 assert_eq!(cfg.max_saved_runs, 50);
1520}
1521
1522#[test]
1523fn runs_config_defaults() {
1524 let cfg = RunsConfig::default();
1525 assert!(!cfg.persist);
1526 assert_eq!(cfg.max_runs, 100);
1527 assert!(!cfg.save_logs);
1528 assert!(!cfg.save_stdout);
1529 assert!(cfg.save_code_snippets);
1530}
1531
1532#[test]
1533fn server_config_toml_roundtrip() {
1534 let toml_str = r#"
1535 [server]
1536 enabled = false
1537 host = "0.0.0.0"
1538 port = 8080
1539 open_browser = false
1540 auto_reload = false
1541 persist_runs = false
1542 max_saved_runs = 10
1543 "#;
1544 let cfg: Config = toml::from_str(toml_str).unwrap();
1545 assert!(!cfg.server.enabled);
1546 assert_eq!(cfg.server.host, "0.0.0.0");
1547 assert_eq!(cfg.server.port, 8080);
1548 assert!(!cfg.server.open_browser);
1549 assert!(!cfg.server.auto_reload);
1550 assert!(!cfg.server.persist_runs);
1551 assert_eq!(cfg.server.max_saved_runs, 10);
1552}
1553
1554#[test]
1555fn missing_new_sections_use_defaults() {
1556 let toml_str = r#"
1557 [scanner]
1558 mode = "ast"
1559 "#;
1560 let cfg: Config = toml::from_str(toml_str).unwrap();
1561 assert_eq!(cfg.server.port, 9700);
1563 assert!(!cfg.runs.persist);
1564 assert!(cfg.profiles.is_empty());
1565}
1566
1567#[test]
1570fn profile_apply_overrides() {
1571 let mut cfg = Config::default();
1572 cfg.apply_profile("ci").unwrap();
1573 assert_eq!(cfg.scanner.mode, AnalysisMode::Full);
1574 assert_eq!(cfg.scanner.min_severity, Severity::Medium);
1575 assert!(cfg.output.quiet);
1576 assert_eq!(cfg.output.default_format, OutputFormat::Sarif);
1577}
1578
1579#[test]
1580fn profile_not_found_errors() {
1581 let mut cfg = Config::default();
1582 let result = cfg.apply_profile("nonexistent");
1583 assert!(result.is_err());
1584}
1585
1586#[test]
1587fn builtin_profiles_resolve() {
1588 let cfg = Config::default();
1589 assert!(cfg.resolve_profile("quick").is_some());
1590 assert!(cfg.resolve_profile("full").is_some());
1591 assert!(cfg.resolve_profile("ci").is_some());
1592 assert!(cfg.resolve_profile("taint_only").is_some());
1593 assert!(cfg.resolve_profile("conservative_large_repo").is_some());
1594 assert!(cfg.resolve_profile("nonexistent").is_none());
1595}
1596
1597#[test]
1598fn user_profile_overrides_builtin() {
1599 let mut cfg = Config::default();
1600 cfg.profiles.insert(
1601 "ci".into(),
1602 ScanProfile {
1603 mode: Some(AnalysisMode::Ast),
1604 ..Default::default()
1605 },
1606 );
1607 let profile = cfg.resolve_profile("ci").unwrap();
1608 assert_eq!(profile.mode, Some(AnalysisMode::Ast));
1610}
1611
1612#[test]
1613fn profile_toml_roundtrip() {
1614 let toml_str = r#"
1615 [profiles.my_scan]
1616 mode = "ast"
1617 min_severity = "High"
1618 quiet = true
1619 "#;
1620 let cfg: Config = toml::from_str(toml_str).unwrap();
1621 let profile = cfg.profiles.get("my_scan").unwrap();
1622 assert_eq!(profile.mode, Some(AnalysisMode::Ast));
1623 assert_eq!(profile.min_severity, Some(Severity::High));
1624 assert_eq!(profile.quiet, Some(true));
1625}
1626
1627#[test]
1630fn validate_good_config() {
1631 let cfg = Config::default();
1632 assert!(cfg.validate().is_ok());
1633}
1634
1635#[test]
1636fn validate_zero_port() {
1637 let mut cfg = Config::default();
1638 cfg.server.port = 0;
1639 let err = cfg.validate().unwrap_err();
1640 assert!(err.iter().any(|e| e.field == "port"));
1641}
1642
1643#[test]
1644fn validate_empty_host() {
1645 let mut cfg = Config::default();
1646 cfg.server.host = String::new();
1647 let err = cfg.validate().unwrap_err();
1648 assert!(err.iter().any(|e| e.field == "host"));
1649}
1650
1651#[test]
1652fn validate_zero_batch_size() {
1653 let mut cfg = Config::default();
1654 cfg.performance.batch_size = 0;
1655 let err = cfg.validate().unwrap_err();
1656 assert!(err.iter().any(|e| e.field == "batch_size"));
1657}
1658
1659#[test]
1660fn validate_bad_profile_name() {
1661 let mut cfg = Config::default();
1662 cfg.profiles
1663 .insert("has spaces".into(), ScanProfile::default());
1664 let err = cfg.validate().unwrap_err();
1665 assert!(err.iter().any(|e| e.section == "profiles"));
1666}
1667
1668#[test]
1669fn validate_returns_all_errors() {
1670 let mut cfg = Config::default();
1671 cfg.server.port = 0;
1672 cfg.server.host = String::new();
1673 cfg.performance.batch_size = 0;
1674 let err = cfg.validate().unwrap_err();
1675 assert!(err.len() >= 3);
1676}
1677
1678#[test]
1681fn merge_excluded_files_union() {
1682 let mut default_cfg = Config::default();
1683 default_cfg.scanner.excluded_files = vec!["a.rs".into(), "b.rs".into()];
1684
1685 let mut user_cfg = Config::default();
1686 user_cfg.scanner.excluded_files = vec!["b.rs".into(), "c.rs".into()];
1687
1688 let merged = merge_configs(default_cfg, user_cfg);
1689 assert_eq!(merged.scanner.excluded_files, vec!["a.rs", "b.rs", "c.rs"]);
1690}