1use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer};
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum IndentSize {
18 #[default]
20 Auto,
21 Fixed(usize),
23}
24
25impl fmt::Display for IndentSize {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 IndentSize::Auto => write!(f, "auto"),
29 IndentSize::Fixed(n) => write!(f, "{}", n),
30 }
31 }
32}
33
34impl<'de> Deserialize<'de> for IndentSize {
35 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36 where
37 D: Deserializer<'de>,
38 {
39 use serde::de::{self, Visitor};
40
41 struct IndentSizeVisitor;
42
43 impl<'de> Visitor<'de> for IndentSizeVisitor {
44 type Value = IndentSize;
45
46 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
47 formatter.write_str("a positive integer or \"auto\"")
48 }
49
50 fn visit_u64<E>(self, value: u64) -> Result<IndentSize, E>
51 where
52 E: de::Error,
53 {
54 Ok(IndentSize::Fixed(value as usize))
55 }
56
57 fn visit_i64<E>(self, value: i64) -> Result<IndentSize, E>
58 where
59 E: de::Error,
60 {
61 if value > 0 {
62 Ok(IndentSize::Fixed(value as usize))
63 } else {
64 Err(de::Error::custom("indent_size must be positive"))
65 }
66 }
67
68 fn visit_str<E>(self, value: &str) -> Result<IndentSize, E>
69 where
70 E: de::Error,
71 {
72 if value.eq_ignore_ascii_case("auto") {
73 Ok(IndentSize::Auto)
74 } else {
75 Err(de::Error::custom(
76 "expected \"auto\" or a positive integer for indent_size",
77 ))
78 }
79 }
80 }
81
82 deserializer.deserialize_any(IndentSizeVisitor)
83 }
84}
85
86impl JsonSchema for IndentSize {
87 fn schema_name() -> std::borrow::Cow<'static, str> {
88 "IndentSize".into()
89 }
90
91 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
92 serde_json::from_value(serde_json::json!({
93 "description": "Indentation size: a positive integer or \"auto\" for auto-detection",
94 "default": "auto",
95 "oneOf": [
96 { "type": "integer", "minimum": 1 },
97 { "type": "string", "enum": ["auto"] }
98 ]
99 }))
100 .unwrap()
101 }
102}
103
104pub const DEFAULT_CONFIG_TEMPLATE: &str = r#"# nginx-lint configuration file
106# This file was generated by `nginx-lint config init`
107# See https://github.com/walf443/nginx-lint for more documentation
108
109# Target nginx version your config is deployed against (e.g. "1.31.0").
110# When set, rules that don't apply to this version are automatically skipped.
111# Per-rule `skip_version_check = true` forces a rule to run regardless.
112# target_nginx_version = "1.31.0"
113
114# Color output settings
115[color]
116# Color mode: "auto", "always", or "never"
117ui = "auto"
118# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
119# bright_black, bright_red, bright_green, bright_yellow, bright_blue,
120# bright_magenta, bright_cyan, bright_white)
121error = "red"
122warning = "yellow"
123
124# =============================================================================
125# Include Resolution Settings
126# =============================================================================
127[include]
128
129# Base directory for resolving relative include paths (similar to nginx -p prefix).
130# When set, all relative include paths are resolved from this directory
131# instead of the directory containing the config file with the include directive.
132# prefix = "/etc/nginx"
133
134# Path mappings applied to include patterns before resolving them.
135# Mappings are applied in declaration order, each receiving the output of the
136# previous one (chained). Useful when the config references a directory that
137# differs from where the actual files live (e.g. sites-enabled → sites-available).
138#
139# Example (for Debian nginx package):
140
141# [[include.path_map]]
142# from = "/etc/nginx/"
143# to = ""
144#
145# [[include.path_map]]
146# from = "sites-enabled"
147# to = "sites-available"
148#
149# [[include.path_map]]
150# from = "modules-enabled"
151# to = "modules-available"
152
153# =============================================================================
154# Style Rules
155# =============================================================================
156
157[rules.indent]
158enabled = true
159# Indentation size: number or "auto" for auto-detection (default: "auto")
160# indent_size = 4
161indent_size = "auto"
162
163[rules.trailing-whitespace]
164enabled = true
165
166[rules.space-before-semicolon]
167enabled = true
168
169[rules.block-lines]
170enabled = true
171# Maximum number of lines allowed in a block (default: 100)
172# max_block_lines = 100
173
174# =============================================================================
175# Syntax Rules
176# =============================================================================
177
178[rules.duplicate-directive]
179enabled = true
180
181[rules.unmatched-braces]
182enabled = true
183
184[rules.unclosed-quote]
185enabled = true
186
187[rules.missing-semicolon]
188enabled = true
189
190[rules.invalid-directive-context]
191enabled = true
192# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
193# Example for nginx-rtmp-module:
194# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
195
196[rules.include-path-exists]
197enabled = true
198
199# =============================================================================
200# Security Rules
201# =============================================================================
202
203[rules.deprecated-ssl-protocol]
204enabled = true
205# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
206allowed_protocols = ["TLSv1.2", "TLSv1.3"]
207
208[rules.server-tokens-enabled]
209enabled = true
210
211[rules.autoindex-enabled]
212enabled = true
213
214[rules.weak-ssl-ciphers]
215enabled = true
216# Weak cipher patterns to detect
217weak_ciphers = [
218 "NULL",
219 "EXPORT",
220 "DES",
221 "RC4",
222 "MD5",
223 "aNULL",
224 "eNULL",
225 "ADH",
226 "AECDH",
227 "PSK",
228 "SRP",
229 "CAMELLIA",
230]
231# Required exclusion patterns
232required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
233
234[rules.nginx-rift]
235# CVE-2026-42945 / CVE-2026-9256: detects the rewrite-with-`?` +
236# capture-consumer pattern that triggers a heap buffer overflow on
237# nginx 0.6.27 .. 1.30.1 (CVE-2026-42945 fixed in 1.30.1 / 1.31.0; the
238# redirect-path CVE-2026-9256 those releases left open is fixed in
239# 1.30.2 / 1.31.1). The rule declares its applicable nginx version
240# range, so setting `target_nginx_version >= 1.30.2` above disables it
241# automatically. To run it anyway (e.g. on a mixed fleet), add
242# `skip_version_check = true` here.
243enabled = true
244# skip_version_check = true
245
246# =============================================================================
247# Best Practices
248# =============================================================================
249
250[rules.gzip-not-enabled]
251# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
252enabled = false
253
254[rules.missing-error-log]
255# Disabled by default: error_log is typically set at top level in main config
256enabled = false
257
258[rules.proxy-pass-domain]
259enabled = true
260
261[rules.upstream-server-no-resolve]
262enabled = true
263
264[rules.directive-inheritance]
265enabled = true
266# Exclude specific directives from checking
267# excluded_directives = ["grpc_set_header", "uwsgi_param"]
268# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
269# additional_directives = [
270# { name = "proxy_set_cookie", case_insensitive = true },
271# ]
272
273[rules.root-in-location]
274enabled = true
275
276[rules.alias-location-slash-mismatch]
277enabled = true
278
279[rules.proxy-pass-with-uri]
280enabled = true
281
282[rules.proxy-keepalive]
283enabled = true
284
285[rules.try-files-with-proxy]
286enabled = true
287
288[rules.if-is-evil-in-location]
289enabled = true
290
291# =============================================================================
292# Parser Settings
293# =============================================================================
294
295[parser]
296# Additional block directives for extension modules
297# These are added to the built-in list (http, server, location, etc.)
298# Example for nginx-rtmp-module:
299# block_directives = ["rtmp", "application"]
300"#;
301
302#[derive(Debug, Default, Deserialize, JsonSchema)]
309pub struct LintConfig {
310 #[serde(default)]
312 pub rules: HashMap<String, RuleConfig>,
313 #[serde(default)]
315 pub color: ColorConfig,
316 #[serde(default)]
318 pub parser: ParserConfig,
319 #[serde(default)]
321 pub include: IncludeConfig,
322 #[serde(default)]
329 pub target_nginx_version: Option<String>,
330}
331
332#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
334pub struct ParserConfig {
335 #[serde(default)]
338 pub block_directives: Vec<String>,
339}
340
341#[derive(Debug, Clone, Deserialize, JsonSchema)]
358pub struct PathMapping {
359 pub from: String,
361 pub to: String,
363}
364
365#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
367pub struct IncludeConfig {
368 #[serde(default)]
371 pub path_map: Vec<PathMapping>,
372 pub prefix: Option<String>,
376}
377
378#[derive(Debug, Clone, Deserialize, JsonSchema)]
380pub struct ColorConfig {
381 #[serde(default)]
383 pub ui: ColorMode,
384 #[serde(default = "default_error_color")]
386 pub error: Color,
387 #[serde(default = "default_warning_color")]
389 pub warning: Color,
390}
391
392impl Default for ColorConfig {
393 fn default() -> Self {
394 Self {
395 ui: ColorMode::Auto,
396 error: Color::Red,
397 warning: Color::Yellow,
398 }
399 }
400}
401
402fn default_error_color() -> Color {
403 Color::Red
404}
405
406fn default_warning_color() -> Color {
407 Color::Yellow
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
412pub enum Color {
413 Black,
414 Red,
415 Green,
416 Yellow,
417 Blue,
418 Magenta,
419 Cyan,
420 #[default]
421 White,
422 BrightBlack,
423 BrightRed,
424 BrightGreen,
425 BrightYellow,
426 BrightBlue,
427 BrightMagenta,
428 BrightCyan,
429 BrightWhite,
430}
431
432impl JsonSchema for Color {
433 fn schema_name() -> std::borrow::Cow<'static, str> {
434 "Color".into()
435 }
436
437 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
438 serde_json::from_value(serde_json::json!({
439 "type": "string",
440 "enum": [
441 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
442 "bright_black", "bright_red", "bright_green", "bright_yellow",
443 "bright_blue", "bright_magenta", "bright_cyan", "bright_white"
444 ]
445 }))
446 .unwrap()
447 }
448}
449
450impl<'de> Deserialize<'de> for Color {
451 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
452 where
453 D: serde::Deserializer<'de>,
454 {
455 use serde::de::Error;
456
457 let s = String::deserialize(deserializer)?;
458 match s.to_lowercase().as_str() {
459 "black" => Ok(Color::Black),
460 "red" => Ok(Color::Red),
461 "green" => Ok(Color::Green),
462 "yellow" => Ok(Color::Yellow),
463 "blue" => Ok(Color::Blue),
464 "magenta" => Ok(Color::Magenta),
465 "cyan" => Ok(Color::Cyan),
466 "white" => Ok(Color::White),
467 "bright_black" | "brightblack" => Ok(Color::BrightBlack),
468 "bright_red" | "brightred" => Ok(Color::BrightRed),
469 "bright_green" | "brightgreen" => Ok(Color::BrightGreen),
470 "bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
471 "bright_blue" | "brightblue" => Ok(Color::BrightBlue),
472 "bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
473 "bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
474 "bright_white" | "brightwhite" => Ok(Color::BrightWhite),
475 _ => Err(D::Error::custom(format!(
476 "invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
477 bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
478 s
479 ))),
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
486pub enum ColorMode {
487 #[default]
489 Auto,
490 Always,
492 Never,
494}
495
496impl JsonSchema for ColorMode {
497 fn schema_name() -> std::borrow::Cow<'static, str> {
498 "ColorMode".into()
499 }
500
501 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
502 serde_json::from_value(serde_json::json!({
503 "type": "string",
504 "description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
505 "default": "auto",
506 "enum": ["auto", "always", "never"]
507 }))
508 .unwrap()
509 }
510}
511
512impl<'de> Deserialize<'de> for ColorMode {
513 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
514 where
515 D: serde::Deserializer<'de>,
516 {
517 use serde::de::Error;
518
519 let s = String::deserialize(deserializer)?;
520 match s.as_str() {
521 "auto" => Ok(ColorMode::Auto),
522 "always" => Ok(ColorMode::Always),
523 "never" => Ok(ColorMode::Never),
524 _ => Err(D::Error::custom(format!(
525 "invalid color mode '{}', expected 'auto', 'always', or 'never'",
526 s
527 ))),
528 }
529 }
530}
531
532#[derive(Debug, Clone, Deserialize, JsonSchema)]
536pub struct AdditionalDirective {
537 pub name: String,
539 #[serde(default)]
541 pub case_insensitive: bool,
542 #[serde(default)]
544 pub multi_key: bool,
545}
546
547#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
553pub struct RuleConfig {
554 #[serde(default = "default_true")]
556 pub enabled: bool,
557 #[serde(default)]
562 pub skip_version_check: bool,
563 pub indent_size: Option<IndentSize>,
565 pub allowed_protocols: Option<Vec<String>>,
567 pub weak_ciphers: Option<Vec<String>>,
569 pub required_exclusions: Option<Vec<String>>,
571 pub additional_contexts: Option<HashMap<String, Vec<String>>>,
574 pub max_block_lines: Option<usize>,
576 pub excluded_directives: Option<Vec<String>>,
578 pub additional_directives: Option<Vec<AdditionalDirective>>,
580}
581
582fn default_true() -> bool {
583 true
584}
585
586impl LintConfig {
587 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
589 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
590 path: path.to_path_buf(),
591 source: e,
592 })?;
593
594 toml::from_str(&content).map_err(|e| ConfigError::ParseError {
595 path: path.to_path_buf(),
596 source: e,
597 })
598 }
599
600 pub fn parse(content: &str) -> Result<Self, String> {
602 toml::from_str(content).map_err(|e| e.to_string())
603 }
604
605 pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
609 let mut current = dir.to_path_buf();
610
611 loop {
612 let config_path = current.join(".nginx-lint.toml");
613 if config_path.exists() {
614 return Self::from_file(&config_path)
615 .ok()
616 .map(|cfg| (cfg, config_path));
617 }
618
619 if !current.pop() {
620 break;
621 }
622 }
623
624 None
625 }
626
627 pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
629 "gzip-not-enabled", "missing-error-log", ];
632
633 pub const NATIVE_RULE_NAMES: &'static [&'static str] = &[
640 "unmatched-braces",
641 "unclosed-quote",
642 "missing-semicolon",
643 "indent",
644 "include-path-exists",
645 ];
646
647 pub const KNOWN_RULE_NAMES: &'static [&'static str] = &[
659 "unmatched-braces",
661 "unclosed-quote",
662 "missing-semicolon",
663 "indent",
664 "include-path-exists",
665 "server-tokens-enabled",
668 "autoindex-enabled",
669 "gzip-not-enabled",
670 "duplicate-directive",
671 "space-before-semicolon",
672 "trailing-whitespace",
673 "block-lines",
674 "proxy-pass-domain",
675 "upstream-server-no-resolve",
676 "directive-inheritance",
677 "root-in-location",
678 "alias-location-slash-mismatch",
679 "proxy-pass-with-uri",
680 "proxy-keepalive",
681 "try-files-with-proxy",
682 "if-is-evil-in-location",
683 "unreachable-location",
684 "missing-error-log",
685 "deprecated-ssl-protocol",
686 "weak-ssl-ciphers",
687 "invalid-directive-context",
688 "map-missing-default",
689 "ssl-on-deprecated",
690 "listen-http2-deprecated",
691 "proxy-missing-host-header",
692 "client-max-body-size-not-set",
693 "nginx-rift",
694 ];
695
696 pub fn is_rule_enabled(&self, name: &str) -> bool {
698 self.rules
699 .get(name)
700 .map(|r| r.enabled)
701 .unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
702 }
703
704 pub fn rule_explicitly_configured(&self, name: &str) -> bool {
709 self.rules.contains_key(name)
710 }
711
712 pub fn rule_skip_version_check(&self, name: &str) -> bool {
714 self.rules
715 .get(name)
716 .map(|r| r.skip_version_check)
717 .unwrap_or(false)
718 }
719
720 pub fn target_nginx_version(&self) -> Option<&str> {
722 self.target_nginx_version.as_deref()
723 }
724
725 pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
727 self.rules.get(name)
728 }
729
730 pub fn color_mode(&self) -> ColorMode {
732 self.color.ui
733 }
734
735 pub fn additional_block_directives(&self) -> &[String] {
737 &self.parser.block_directives
738 }
739
740 pub fn include_path_mappings(&self) -> &[PathMapping] {
742 &self.include.path_map
743 }
744
745 pub fn json_schema() -> serde_json::Value {
750 let generator = schemars::SchemaGenerator::default();
751 let schema = generator.into_root_schema_for::<LintConfig>();
752 serde_json::to_value(schema).unwrap()
753 }
754
755 pub fn include_prefix(&self) -> Option<&str> {
757 self.include.prefix.as_deref()
758 }
759
760 pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
762 self.rules
763 .get("invalid-directive-context")
764 .and_then(|r| r.additional_contexts.as_ref())
765 }
766
767 pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
769 self.rules
770 .get("directive-inheritance")
771 .and_then(|r| r.excluded_directives.as_deref())
772 }
773
774 pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
776 self.rules
777 .get("directive-inheritance")
778 .and_then(|r| r.additional_directives.as_deref())
779 }
780
781 pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
783 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
784 path: path.to_path_buf(),
785 source: e,
786 })?;
787
788 Self::validate_content(&content, path)
789 }
790
791 fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
793 let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
794 path: path.to_path_buf(),
795 source: e,
796 })?;
797
798 let mut errors = Vec::new();
799
800 if let toml::Value::Table(root) = value {
801 let known_top_level: HashSet<&str> = [
803 "rules",
804 "color",
805 "parser",
806 "include",
807 "target_nginx_version",
808 ]
809 .into_iter()
810 .collect();
811
812 for key in root.keys() {
813 if !known_top_level.contains(key.as_str()) {
814 let line = find_key_line(content, None, key);
815 errors.push(ValidationError::UnknownField {
816 path: key.clone(),
817 line,
818 suggestion: suggest_field(key, &known_top_level),
819 });
820 }
821 }
822
823 if let Some(toml::Value::Table(color)) = root.get("color") {
825 let known_color_keys: HashSet<&str> =
826 ["ui", "error", "warning"].into_iter().collect();
827
828 for key in color.keys() {
829 if !known_color_keys.contains(key.as_str()) {
830 let line = find_key_line(content, Some("color"), key);
831 errors.push(ValidationError::UnknownField {
832 path: format!("color.{}", key),
833 line,
834 suggestion: suggest_field(key, &known_color_keys),
835 });
836 }
837 }
838 }
839
840 if let Some(toml::Value::Table(parser)) = root.get("parser") {
842 let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
843
844 for key in parser.keys() {
845 if !known_parser_keys.contains(key.as_str()) {
846 let line = find_key_line(content, Some("parser"), key);
847 errors.push(ValidationError::UnknownField {
848 path: format!("parser.{}", key),
849 line,
850 suggestion: suggest_field(key, &known_parser_keys),
851 });
852 }
853 }
854 }
855
856 if let Some(toml::Value::Table(include)) = root.get("include") {
858 let known_include_keys: HashSet<&str> =
859 ["path_map", "prefix"].into_iter().collect();
860
861 for key in include.keys() {
862 if !known_include_keys.contains(key.as_str()) {
863 let line = find_key_line(content, Some("include"), key);
864 errors.push(ValidationError::UnknownField {
865 path: format!("include.{}", key),
866 line,
867 suggestion: suggest_field(key, &known_include_keys),
868 });
869 }
870 }
871 }
872
873 if let Some(toml::Value::Table(rules)) = root.get("rules") {
875 let known_rules: HashSet<&str> = Self::KNOWN_RULE_NAMES.iter().copied().collect();
876
877 for (rule_name, rule_value) in rules {
878 if !known_rules.contains(rule_name.as_str()) {
879 let line = find_key_line(content, Some("rules"), rule_name);
880 errors.push(ValidationError::UnknownRule {
881 name: rule_name.clone(),
882 line,
883 suggestion: suggest_field(rule_name, &known_rules),
884 });
885 continue;
886 }
887
888 if let toml::Value::Table(rule_config) = rule_value {
890 let known_rule_options = get_known_rule_options(rule_name);
891 let section = format!("rules.{}", rule_name);
892
893 for key in rule_config.keys() {
894 if !known_rule_options.contains(key.as_str()) {
895 let line = find_key_line(content, Some(§ion), key);
896 errors.push(ValidationError::UnknownRuleOption {
897 rule: rule_name.clone(),
898 option: key.clone(),
899 line,
900 suggestion: suggest_field(key, &known_rule_options),
901 });
902 }
903 }
904 }
905 }
906 }
907 }
908
909 Ok(errors)
910 }
911}
912
913fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
915 let lines: Vec<&str> = content.lines().collect();
916
917 if section.is_none() {
919 let section_header = format!("[{}]", key);
920 for (i, line) in lines.iter().enumerate() {
921 if line.trim() == section_header {
922 return Some(i + 1);
923 }
924 }
925 return None;
926 }
927
928 let target_section = section.unwrap();
929 let mut in_section = false;
930
931 for (i, line) in lines.iter().enumerate() {
932 let trimmed = line.trim();
933
934 if trimmed.starts_with('[') && trimmed.ends_with(']') {
936 let section_name = &trimmed[1..trimmed.len() - 1];
937
938 let full_section = format!("{}.{}", target_section, key);
940 if section_name == full_section {
941 return Some(i + 1);
942 }
943
944 in_section = section_name == target_section
945 || section_name.starts_with(&format!("{}.", target_section));
946 continue;
947 }
948
949 if in_section && let Some((k, _)) = trimmed.split_once('=') {
951 let k = k.trim();
952 if k == key {
953 return Some(i + 1);
954 }
955 }
956 }
957
958 None
959}
960
961fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
963 let mut options: HashSet<&str> = ["enabled", "skip_version_check"].into_iter().collect();
964
965 match rule_name {
966 "indent" => {
967 options.insert("indent_size");
968 }
969 "deprecated-ssl-protocol" => {
970 options.insert("allowed_protocols");
971 }
972 "weak-ssl-ciphers" => {
973 options.insert("weak_ciphers");
974 options.insert("required_exclusions");
975 }
976 "block-lines" => {
977 options.insert("max_block_lines");
978 }
979 "directive-inheritance" => {
980 options.insert("excluded_directives");
981 options.insert("additional_directives");
982 }
983 _ => {}
984 }
985
986 options
987}
988
989fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
991 let input_lower = input.to_lowercase();
992
993 known
995 .iter()
996 .filter(|&&k| {
997 let k_lower = k.to_lowercase();
998 k_lower.contains(&input_lower)
1000 || input_lower.contains(&k_lower)
1001 || levenshtein_distance(&input_lower, &k_lower) <= 2
1002 })
1003 .min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
1004 .map(|&s| s.to_string())
1005}
1006
1007fn levenshtein_distance(a: &str, b: &str) -> usize {
1009 let a_chars: Vec<char> = a.chars().collect();
1010 let b_chars: Vec<char> = b.chars().collect();
1011 let a_len = a_chars.len();
1012 let b_len = b_chars.len();
1013
1014 if a_len == 0 {
1015 return b_len;
1016 }
1017 if b_len == 0 {
1018 return a_len;
1019 }
1020
1021 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
1022
1023 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
1024 row[0] = i;
1025 }
1026 for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
1027 *cell = j;
1028 }
1029
1030 for i in 1..=a_len {
1031 for j in 1..=b_len {
1032 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
1033 matrix[i][j] = (matrix[i - 1][j] + 1)
1034 .min(matrix[i][j - 1] + 1)
1035 .min(matrix[i - 1][j - 1] + cost);
1036 }
1037 }
1038
1039 matrix[a_len][b_len]
1040}
1041
1042#[derive(Debug, Clone)]
1048pub enum ValidationError {
1049 UnknownField {
1051 path: String,
1053 line: Option<usize>,
1055 suggestion: Option<String>,
1057 },
1058 UnknownRule {
1060 name: String,
1062 line: Option<usize>,
1064 suggestion: Option<String>,
1066 },
1067 UnknownRuleOption {
1069 rule: String,
1071 option: String,
1073 line: Option<usize>,
1075 suggestion: Option<String>,
1077 },
1078}
1079
1080impl std::fmt::Display for ValidationError {
1081 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1082 match self {
1083 ValidationError::UnknownField {
1084 path,
1085 line,
1086 suggestion,
1087 } => {
1088 if let Some(l) = line {
1089 write!(f, "line {}: ", l)?;
1090 }
1091 write!(f, "unknown field '{}'", path)?;
1092 if let Some(s) = suggestion {
1093 write!(f, ", did you mean '{}'?", s)?;
1094 }
1095 Ok(())
1096 }
1097 ValidationError::UnknownRule {
1098 name,
1099 line,
1100 suggestion,
1101 } => {
1102 if let Some(l) = line {
1103 write!(f, "line {}: ", l)?;
1104 }
1105 write!(f, "unknown rule '{}'", name)?;
1106 if let Some(s) = suggestion {
1107 write!(f, ", did you mean '{}'?", s)?;
1108 }
1109 Ok(())
1110 }
1111 ValidationError::UnknownRuleOption {
1112 rule,
1113 option,
1114 line,
1115 suggestion,
1116 } => {
1117 if let Some(l) = line {
1118 write!(f, "line {}: ", l)?;
1119 }
1120 write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
1121 if let Some(s) = suggestion {
1122 write!(f, ", did you mean '{}'?", s)?;
1123 }
1124 Ok(())
1125 }
1126 }
1127 }
1128}
1129
1130#[derive(Debug)]
1132pub enum ConfigError {
1133 IoError {
1135 path: std::path::PathBuf,
1137 source: std::io::Error,
1139 },
1140 ParseError {
1142 path: std::path::PathBuf,
1144 source: toml::de::Error,
1146 },
1147}
1148
1149impl std::fmt::Display for ConfigError {
1150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1151 match self {
1152 ConfigError::IoError { path, source } => {
1153 write!(
1154 f,
1155 "Failed to read config file '{}': {}",
1156 path.display(),
1157 source
1158 )
1159 }
1160 ConfigError::ParseError { path, source } => {
1161 write!(
1162 f,
1163 "Failed to parse config file '{}': {}",
1164 path.display(),
1165 source
1166 )
1167 }
1168 }
1169 }
1170}
1171
1172impl std::error::Error for ConfigError {
1173 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1174 match self {
1175 ConfigError::IoError { source, .. } => Some(source),
1176 ConfigError::ParseError { source, .. } => Some(source),
1177 }
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184 use std::io::Write;
1185 use tempfile::NamedTempFile;
1186
1187 #[test]
1188 fn test_default_config() {
1189 let config = LintConfig::default();
1190 assert!(config.is_rule_enabled("any-rule"));
1191 }
1192
1193 #[test]
1194 fn test_disabled_by_default_rules() {
1195 let config = LintConfig::default();
1196 assert!(!config.is_rule_enabled("gzip-not-enabled"));
1198 assert!(!config.is_rule_enabled("missing-error-log"));
1199 assert!(config.is_rule_enabled("server-tokens-enabled"));
1201 }
1202
1203 #[test]
1204 fn test_parse_config() {
1205 let toml_content = r#"
1206[rules.indent]
1207enabled = true
1208indent_size = 2
1209
1210[rules.server-tokens-enabled]
1211enabled = false
1212"#;
1213 let mut file = NamedTempFile::new().unwrap();
1214 write!(file, "{}", toml_content).unwrap();
1215
1216 let config = LintConfig::from_file(file.path()).unwrap();
1217
1218 assert!(config.is_rule_enabled("indent"));
1219 assert!(!config.is_rule_enabled("server-tokens-enabled"));
1220 assert!(config.is_rule_enabled("unknown-rule"));
1221
1222 let indent_config = config.get_rule_config("indent").unwrap();
1223 assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
1224 }
1225
1226 #[test]
1227 fn test_empty_config() {
1228 let toml_content = "";
1229 let mut file = NamedTempFile::new().unwrap();
1230 write!(file, "{}", toml_content).unwrap();
1231
1232 let config = LintConfig::from_file(file.path()).unwrap();
1233 assert!(config.is_rule_enabled("any-rule"));
1234 }
1235
1236 #[test]
1237 fn test_indent_size_auto() {
1238 let toml_content = r#"
1239[rules.indent]
1240enabled = true
1241indent_size = "auto"
1242"#;
1243 let mut file = NamedTempFile::new().unwrap();
1244 write!(file, "{}", toml_content).unwrap();
1245
1246 let config = LintConfig::from_file(file.path()).unwrap();
1247 let indent_config = config.get_rule_config("indent").unwrap();
1248 assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
1249 }
1250
1251 #[test]
1252 fn test_color_config_default() {
1253 let config = LintConfig::default();
1254 assert_eq!(config.color_mode(), ColorMode::Auto);
1255 }
1256
1257 #[test]
1258 fn test_color_config_auto() {
1259 let toml_content = r#"
1260[color]
1261ui = "auto"
1262"#;
1263 let mut file = NamedTempFile::new().unwrap();
1264 write!(file, "{}", toml_content).unwrap();
1265
1266 let config = LintConfig::from_file(file.path()).unwrap();
1267 assert_eq!(config.color_mode(), ColorMode::Auto);
1268 }
1269
1270 #[test]
1271 fn test_color_config_never() {
1272 let toml_content = r#"
1273[color]
1274ui = "never"
1275"#;
1276 let mut file = NamedTempFile::new().unwrap();
1277 write!(file, "{}", toml_content).unwrap();
1278
1279 let config = LintConfig::from_file(file.path()).unwrap();
1280 assert_eq!(config.color_mode(), ColorMode::Never);
1281 }
1282
1283 #[test]
1284 fn test_color_config_always() {
1285 let toml_content = r#"
1286[color]
1287ui = "always"
1288"#;
1289 let mut file = NamedTempFile::new().unwrap();
1290 write!(file, "{}", toml_content).unwrap();
1291
1292 let config = LintConfig::from_file(file.path()).unwrap();
1293 assert_eq!(config.color_mode(), ColorMode::Always);
1294 }
1295
1296 #[test]
1297 fn test_color_config_default_colors() {
1298 let config = LintConfig::default();
1299 assert_eq!(config.color.error, Color::Red);
1300 assert_eq!(config.color.warning, Color::Yellow);
1301 }
1302
1303 #[test]
1304 fn test_color_config_custom_colors() {
1305 let toml_content = r#"
1306[color]
1307error = "magenta"
1308warning = "cyan"
1309"#;
1310 let mut file = NamedTempFile::new().unwrap();
1311 write!(file, "{}", toml_content).unwrap();
1312
1313 let config = LintConfig::from_file(file.path()).unwrap();
1314 assert_eq!(config.color.error, Color::Magenta);
1315 assert_eq!(config.color.warning, Color::Cyan);
1316 }
1317
1318 #[test]
1319 fn test_color_config_bright_colors() {
1320 let toml_content = r#"
1321[color]
1322error = "bright_red"
1323warning = "bright_yellow"
1324"#;
1325 let mut file = NamedTempFile::new().unwrap();
1326 write!(file, "{}", toml_content).unwrap();
1327
1328 let config = LintConfig::from_file(file.path()).unwrap();
1329 assert_eq!(config.color.error, Color::BrightRed);
1330 assert_eq!(config.color.warning, Color::BrightYellow);
1331 }
1332
1333 #[test]
1334 fn test_block_lines_max_block_lines_parsing() {
1335 let toml_content = r#"
1336[rules.block-lines]
1337enabled = true
1338max_block_lines = 50
1339"#;
1340 let mut file = NamedTempFile::new().unwrap();
1341 write!(file, "{}", toml_content).unwrap();
1342
1343 let config = LintConfig::from_file(file.path()).unwrap();
1344 assert!(config.is_rule_enabled("block-lines"));
1345 let rule_config = config.get_rule_config("block-lines").unwrap();
1346 assert_eq!(rule_config.max_block_lines, Some(50));
1347 }
1348
1349 #[test]
1350 fn test_block_lines_default_no_max() {
1351 let toml_content = r#"
1352[rules.block-lines]
1353enabled = true
1354"#;
1355 let mut file = NamedTempFile::new().unwrap();
1356 write!(file, "{}", toml_content).unwrap();
1357
1358 let config = LintConfig::from_file(file.path()).unwrap();
1359 let rule_config = config.get_rule_config("block-lines").unwrap();
1360 assert_eq!(rule_config.max_block_lines, None);
1361 }
1362
1363 #[test]
1364 fn test_block_lines_validation_rejects_unknown_option() {
1365 let toml_content = r#"
1366[rules.block-lines]
1367enabled = true
1368unknown_option = 42
1369"#;
1370 let mut file = NamedTempFile::new().unwrap();
1371 write!(file, "{}", toml_content).unwrap();
1372
1373 let errors = LintConfig::validate_file(file.path()).unwrap();
1374 assert_eq!(errors.len(), 1);
1375 match &errors[0] {
1376 ValidationError::UnknownRuleOption { rule, option, .. } => {
1377 assert_eq!(rule, "block-lines");
1378 assert_eq!(option, "unknown_option");
1379 }
1380 other => panic!("expected UnknownRuleOption, got: {:?}", other),
1381 }
1382 }
1383
1384 #[test]
1385 fn test_include_path_map_empty_by_default() {
1386 let config = LintConfig::default();
1387 assert!(config.include_path_mappings().is_empty());
1388 }
1389
1390 #[test]
1391 fn test_include_path_map_single_entry() {
1392 let toml_content = r#"
1393[[include.path_map]]
1394from = "sites-enabled"
1395to = "sites-available"
1396"#;
1397 let config = LintConfig::parse(toml_content).unwrap();
1398 let mappings = config.include_path_mappings();
1399 assert_eq!(mappings.len(), 1);
1400 assert_eq!(mappings[0].from, "sites-enabled");
1401 assert_eq!(mappings[0].to, "sites-available");
1402 }
1403
1404 #[test]
1405 fn test_include_path_map_multiple_entries_preserve_order() {
1406 let toml_content = r#"
1407[[include.path_map]]
1408from = "sites-enabled"
1409to = "sites-available"
1410
1411[[include.path_map]]
1412from = "/etc/nginx"
1413to = "/usr/local/nginx"
1414"#;
1415 let config = LintConfig::parse(toml_content).unwrap();
1416 let mappings = config.include_path_mappings();
1417 assert_eq!(mappings.len(), 2);
1418 assert_eq!(mappings[0].from, "sites-enabled");
1419 assert_eq!(mappings[0].to, "sites-available");
1420 assert_eq!(mappings[1].from, "/etc/nginx");
1421 assert_eq!(mappings[1].to, "/usr/local/nginx");
1422 }
1423
1424 #[test]
1425 fn test_include_validation_rejects_unknown_field() {
1426 let toml_content = r#"
1427[include]
1428unknown_key = "value"
1429"#;
1430 let mut file = NamedTempFile::new().unwrap();
1431 write!(file, "{}", toml_content).unwrap();
1432
1433 let errors = LintConfig::validate_file(file.path()).unwrap();
1434 assert_eq!(errors.len(), 1);
1435 match &errors[0] {
1436 ValidationError::UnknownField { path, .. } => {
1437 assert_eq!(path, "include.unknown_key");
1438 }
1439 other => panic!("expected UnknownField, got: {:?}", other),
1440 }
1441 }
1442
1443 #[test]
1444 fn test_include_prefix_none_by_default() {
1445 let config = LintConfig::default();
1446 assert!(config.include_prefix().is_none());
1447 }
1448
1449 #[test]
1450 fn test_include_prefix_parsed() {
1451 let toml_content = r#"
1452[include]
1453prefix = "/etc/nginx"
1454"#;
1455 let config = LintConfig::parse(toml_content).unwrap();
1456 assert_eq!(config.include_prefix(), Some("/etc/nginx"));
1457 }
1458
1459 #[test]
1460 fn test_include_prefix_with_path_map() {
1461 let toml_content = r#"
1462[include]
1463prefix = "."
1464
1465[[include.path_map]]
1466from = "sites-enabled"
1467to = "sites-available"
1468"#;
1469 let config = LintConfig::parse(toml_content).unwrap();
1470 assert_eq!(config.include_prefix(), Some("."));
1471 assert_eq!(config.include_path_mappings().len(), 1);
1472 }
1473
1474 #[test]
1475 fn test_include_prefix_validation_accepted() {
1476 let toml_content = r#"
1477[include]
1478prefix = "/etc/nginx"
1479"#;
1480 let mut file = NamedTempFile::new().unwrap();
1481 write!(file, "{}", toml_content).unwrap();
1482
1483 let errors = LintConfig::validate_file(file.path()).unwrap();
1484 assert!(
1485 errors.is_empty(),
1486 "prefix should be a valid include field, got errors: {:?}",
1487 errors
1488 );
1489 }
1490
1491 #[test]
1492 fn test_json_schema_is_valid() {
1493 let schema = LintConfig::json_schema();
1494
1495 assert_eq!(
1497 schema.get("$schema").and_then(|v| v.as_str()),
1498 Some("https://json-schema.org/draft/2020-12/schema")
1499 );
1500
1501 let props = schema.get("properties").unwrap().as_object().unwrap();
1503 assert!(props.contains_key("rules"), "missing 'rules' property");
1504 assert!(props.contains_key("color"), "missing 'color' property");
1505 assert!(props.contains_key("parser"), "missing 'parser' property");
1506 assert!(props.contains_key("include"), "missing 'include' property");
1507 }
1508
1509 #[test]
1514 fn test_validate_accepts_previously_drifted_builtin_plugins() {
1515 let previously_missing = [
1517 "client-max-body-size-not-set",
1518 "listen-http2-deprecated",
1519 "map-missing-default",
1520 "proxy-missing-host-header",
1521 "ssl-on-deprecated",
1522 "unreachable-location",
1523 ];
1524
1525 for rule_name in previously_missing {
1526 let toml_content = format!("[rules.{rule_name}]\nenabled = false\n");
1527 let mut file = NamedTempFile::new().unwrap();
1528 write!(file, "{}", toml_content).unwrap();
1529
1530 let errors = LintConfig::validate_file(file.path()).unwrap();
1531 assert!(
1532 errors.is_empty(),
1533 "rule '{rule_name}' should be a known builtin plugin name, \
1534 but `validate_file` reported errors: {errors:?}"
1535 );
1536 }
1537 }
1538
1539 #[test]
1542 fn test_known_rules_constant_drives_validator() {
1543 for rule_name in LintConfig::KNOWN_RULE_NAMES {
1544 let toml_content = format!("[rules.{rule_name}]\nenabled = true\n");
1545 let mut file = NamedTempFile::new().unwrap();
1546 write!(file, "{}", toml_content).unwrap();
1547
1548 let errors = LintConfig::validate_file(file.path()).unwrap();
1549 assert!(
1550 errors.is_empty(),
1551 "rule '{rule_name}' is listed in KNOWN_RULE_NAMES but the validator \
1552 rejected it: {errors:?}"
1553 );
1554 }
1555 }
1556
1557 #[test]
1560 fn test_native_rule_names_subset_of_known_rules() {
1561 let known: HashSet<&str> = LintConfig::KNOWN_RULE_NAMES.iter().copied().collect();
1562 let missing: Vec<&str> = LintConfig::NATIVE_RULE_NAMES
1563 .iter()
1564 .copied()
1565 .filter(|name| !known.contains(name))
1566 .collect();
1567 assert!(
1568 missing.is_empty(),
1569 "NATIVE_RULE_NAMES entries missing from KNOWN_RULE_NAMES: {missing:?}"
1570 );
1571 }
1572
1573 #[test]
1577 fn test_validate_rejects_unknown_rule_name() {
1578 let toml_content = "[rules.no-such-rule-zzz]\nenabled = true\n";
1579 let mut file = NamedTempFile::new().unwrap();
1580 write!(file, "{}", toml_content).unwrap();
1581
1582 let errors = LintConfig::validate_file(file.path()).unwrap();
1583 assert_eq!(
1584 errors.len(),
1585 1,
1586 "expected exactly one error, got: {errors:?}"
1587 );
1588 match &errors[0] {
1589 ValidationError::UnknownRule { name, .. } => {
1590 assert_eq!(name, "no-such-rule-zzz");
1591 }
1592 other => panic!("expected UnknownRule, got: {other:?}"),
1593 }
1594 }
1595
1596 #[test]
1599 fn test_known_rules_has_no_duplicates() {
1600 let mut seen: HashSet<&str> = HashSet::new();
1601 for name in LintConfig::KNOWN_RULE_NAMES {
1602 assert!(
1603 seen.insert(name),
1604 "duplicate entry in KNOWN_RULE_NAMES: '{name}'"
1605 );
1606 }
1607 }
1608
1609 #[test]
1610 fn test_json_schema_rule_config_has_all_fields() {
1611 let schema = LintConfig::json_schema();
1612
1613 let rule_config_def = schema
1616 .pointer("/$defs/RuleConfig")
1617 .expect("RuleConfig definition missing from schema");
1618
1619 let props = rule_config_def
1620 .get("properties")
1621 .unwrap()
1622 .as_object()
1623 .unwrap();
1624
1625 let expected_fields = [
1626 "enabled",
1627 "skip_version_check",
1628 "indent_size",
1629 "allowed_protocols",
1630 "weak_ciphers",
1631 "required_exclusions",
1632 "additional_contexts",
1633 "max_block_lines",
1634 "excluded_directives",
1635 "additional_directives",
1636 ];
1637
1638 for field in &expected_fields {
1639 assert!(
1640 props.contains_key(*field),
1641 "RuleConfig schema missing field '{field}'"
1642 );
1643 }
1644 }
1645
1646 #[test]
1647 fn test_target_nginx_version_parsed() {
1648 let toml_content = r#"
1649target_nginx_version = "1.31.0"
1650"#;
1651 let config = LintConfig::parse(toml_content).unwrap();
1652 assert_eq!(config.target_nginx_version(), Some("1.31.0"));
1653 }
1654
1655 #[test]
1656 fn test_target_nginx_version_default_none() {
1657 let config = LintConfig::default();
1658 assert!(config.target_nginx_version().is_none());
1659 }
1660
1661 #[test]
1662 fn test_skip_version_check_per_rule() {
1663 let toml_content = r#"
1664[rules.nginx-rift]
1665enabled = true
1666skip_version_check = true
1667"#;
1668 let config = LintConfig::parse(toml_content).unwrap();
1669 assert!(config.rule_skip_version_check("nginx-rift"));
1670 assert!(!config.rule_skip_version_check("server-tokens-enabled"));
1671 }
1672
1673 #[test]
1674 fn test_rule_explicitly_configured() {
1675 let toml_content = r#"
1676[rules.indent]
1677enabled = true
1678"#;
1679 let config = LintConfig::parse(toml_content).unwrap();
1680 assert!(config.rule_explicitly_configured("indent"));
1681 assert!(!config.rule_explicitly_configured("server-tokens-enabled"));
1682 }
1683
1684 #[test]
1685 fn test_validator_accepts_target_nginx_version() {
1686 let toml_content = r#"
1687target_nginx_version = "1.31.0"
1688"#;
1689 let mut file = NamedTempFile::new().unwrap();
1690 write!(file, "{}", toml_content).unwrap();
1691
1692 let errors = LintConfig::validate_file(file.path()).unwrap();
1693 assert!(
1694 errors.is_empty(),
1695 "target_nginx_version should be a valid top-level field, got: {errors:?}"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_validator_accepts_skip_version_check() {
1701 for rule_name in LintConfig::KNOWN_RULE_NAMES {
1702 let toml_content =
1703 format!("[rules.{rule_name}]\nenabled = true\nskip_version_check = true\n");
1704 let mut file = NamedTempFile::new().unwrap();
1705 write!(file, "{}", toml_content).unwrap();
1706
1707 let errors = LintConfig::validate_file(file.path()).unwrap();
1708 assert!(
1709 errors.is_empty(),
1710 "skip_version_check should be valid for rule '{rule_name}', got: {errors:?}"
1711 );
1712 }
1713 }
1714}