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# Cache directory for nginx-lint. Cacheable artifacts are stored in
115# subdirectories beneath it (e.g. the WASM plugin compilation cache under
116# "plugins/"). Defaults to the per-user cache directory (e.g.
117# ~/.cache/nginx-lint on Linux). A relative path is resolved against the
118# directory containing this file.
119# cache_dir = ".nginx-lint-cache"
120
121# Color output settings
122[color]
123# Color mode: "auto", "always", or "never"
124ui = "auto"
125# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
126# bright_black, bright_red, bright_green, bright_yellow, bright_blue,
127# bright_magenta, bright_cyan, bright_white)
128error = "red"
129warning = "yellow"
130
131# =============================================================================
132# Include Resolution Settings
133# =============================================================================
134[include]
135
136# Base directory for resolving relative include paths (similar to nginx -p prefix).
137# When set, all relative include paths are resolved from this directory
138# instead of the directory containing the config file with the include directive.
139# prefix = "/etc/nginx"
140
141# Path mappings applied to include patterns before resolving them.
142# Mappings are applied in declaration order, each receiving the output of the
143# previous one (chained). Useful when the config references a directory that
144# differs from where the actual files live (e.g. sites-enabled → sites-available).
145#
146# Example (for Debian nginx package):
147
148# [[include.path_map]]
149# from = "/etc/nginx/"
150# to = ""
151#
152# [[include.path_map]]
153# from = "sites-enabled"
154# to = "sites-available"
155#
156# [[include.path_map]]
157# from = "modules-enabled"
158# to = "modules-available"
159
160# =============================================================================
161# Style Rules
162# =============================================================================
163
164[rules.indent]
165enabled = true
166# Indentation size: number or "auto" for auto-detection (default: "auto")
167# indent_size = 4
168indent_size = "auto"
169
170[rules.trailing-whitespace]
171enabled = true
172
173[rules.space-before-semicolon]
174enabled = true
175
176[rules.block-lines]
177enabled = true
178# Maximum number of lines allowed in a block (default: 100)
179# max_block_lines = 100
180
181# =============================================================================
182# Syntax Rules
183# =============================================================================
184
185[rules.duplicate-directive]
186enabled = true
187
188[rules.unmatched-braces]
189enabled = true
190
191[rules.unclosed-quote]
192enabled = true
193
194[rules.missing-semicolon]
195enabled = true
196
197[rules.invalid-directive-context]
198enabled = true
199# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
200# Example for nginx-rtmp-module:
201# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
202
203[rules.include-path-exists]
204enabled = true
205
206# =============================================================================
207# Security Rules
208# =============================================================================
209
210[rules.deprecated-ssl-protocol]
211enabled = true
212# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
213allowed_protocols = ["TLSv1.2", "TLSv1.3"]
214
215[rules.server-tokens-enabled]
216enabled = true
217
218[rules.autoindex-enabled]
219enabled = true
220
221[rules.weak-ssl-ciphers]
222enabled = true
223# Weak cipher patterns to detect
224weak_ciphers = [
225 "NULL",
226 "EXPORT",
227 "DES",
228 "RC4",
229 "MD5",
230 "aNULL",
231 "eNULL",
232 "ADH",
233 "AECDH",
234 "PSK",
235 "SRP",
236 "CAMELLIA",
237]
238# Required exclusion patterns
239required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
240
241[rules.nginx-rift]
242# CVE-2026-42945 / CVE-2026-9256: detects the rewrite-with-`?` +
243# capture-consumer pattern that triggers a heap buffer overflow on
244# nginx 0.6.27 .. 1.30.1 (CVE-2026-42945 fixed in 1.30.1 / 1.31.0; the
245# redirect-path CVE-2026-9256 those releases left open is fixed in
246# 1.30.2 / 1.31.1). The rule declares its applicable nginx version
247# range, so setting `target_nginx_version >= 1.30.2` above disables it
248# automatically. To run it anyway (e.g. on a mixed fleet), add
249# `skip_version_check = true` here.
250enabled = true
251# skip_version_check = true
252
253# =============================================================================
254# Best Practices
255# =============================================================================
256
257[rules.gzip-not-enabled]
258# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
259enabled = false
260
261[rules.missing-error-log]
262# Disabled by default: error_log is typically set at top level in main config
263enabled = false
264
265[rules.proxy-pass-domain]
266enabled = true
267
268[rules.upstream-server-no-resolve]
269enabled = true
270
271[rules.directive-inheritance]
272enabled = true
273# Exclude specific directives from checking
274# excluded_directives = ["grpc_set_header", "uwsgi_param"]
275# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
276# additional_directives = [
277# { name = "proxy_set_cookie", case_insensitive = true },
278# ]
279
280[rules.root-in-location]
281enabled = true
282
283[rules.alias-location-slash-mismatch]
284enabled = true
285
286[rules.proxy-pass-with-uri]
287enabled = true
288
289[rules.proxy-keepalive]
290enabled = true
291
292[rules.try-files-with-proxy]
293enabled = true
294
295[rules.if-is-evil-in-location]
296enabled = true
297
298# =============================================================================
299# Parser Settings
300# =============================================================================
301
302[parser]
303# Additional block directives for extension modules
304# These are added to the built-in list (http, server, location, etc.)
305# Example for nginx-rtmp-module:
306# block_directives = ["rtmp", "application"]
307"#;
308
309#[derive(Debug, Default, Deserialize, JsonSchema)]
316pub struct LintConfig {
317 #[serde(default)]
319 pub rules: HashMap<String, RuleConfig>,
320 #[serde(default)]
322 pub color: ColorConfig,
323 #[serde(default)]
325 pub parser: ParserConfig,
326 #[serde(default)]
328 pub include: IncludeConfig,
329 #[serde(default)]
336 pub target_nginx_version: Option<String>,
337 #[serde(default)]
345 pub cache_dir: Option<String>,
346}
347
348#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
350pub struct ParserConfig {
351 #[serde(default)]
354 pub block_directives: Vec<String>,
355}
356
357#[derive(Debug, Clone, Deserialize, JsonSchema)]
374pub struct PathMapping {
375 pub from: String,
377 pub to: String,
379}
380
381#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
383pub struct IncludeConfig {
384 #[serde(default)]
387 pub path_map: Vec<PathMapping>,
388 pub prefix: Option<String>,
392}
393
394#[derive(Debug, Clone, Deserialize, JsonSchema)]
396pub struct ColorConfig {
397 #[serde(default)]
399 pub ui: ColorMode,
400 #[serde(default = "default_error_color")]
402 pub error: Color,
403 #[serde(default = "default_warning_color")]
405 pub warning: Color,
406}
407
408impl Default for ColorConfig {
409 fn default() -> Self {
410 Self {
411 ui: ColorMode::Auto,
412 error: Color::Red,
413 warning: Color::Yellow,
414 }
415 }
416}
417
418fn default_error_color() -> Color {
419 Color::Red
420}
421
422fn default_warning_color() -> Color {
423 Color::Yellow
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
428pub enum Color {
429 Black,
430 Red,
431 Green,
432 Yellow,
433 Blue,
434 Magenta,
435 Cyan,
436 #[default]
437 White,
438 BrightBlack,
439 BrightRed,
440 BrightGreen,
441 BrightYellow,
442 BrightBlue,
443 BrightMagenta,
444 BrightCyan,
445 BrightWhite,
446}
447
448impl JsonSchema for Color {
449 fn schema_name() -> std::borrow::Cow<'static, str> {
450 "Color".into()
451 }
452
453 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
454 serde_json::from_value(serde_json::json!({
455 "type": "string",
456 "enum": [
457 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
458 "bright_black", "bright_red", "bright_green", "bright_yellow",
459 "bright_blue", "bright_magenta", "bright_cyan", "bright_white"
460 ]
461 }))
462 .unwrap()
463 }
464}
465
466impl<'de> Deserialize<'de> for Color {
467 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
468 where
469 D: serde::Deserializer<'de>,
470 {
471 use serde::de::Error;
472
473 let s = String::deserialize(deserializer)?;
474 match s.to_lowercase().as_str() {
475 "black" => Ok(Color::Black),
476 "red" => Ok(Color::Red),
477 "green" => Ok(Color::Green),
478 "yellow" => Ok(Color::Yellow),
479 "blue" => Ok(Color::Blue),
480 "magenta" => Ok(Color::Magenta),
481 "cyan" => Ok(Color::Cyan),
482 "white" => Ok(Color::White),
483 "bright_black" | "brightblack" => Ok(Color::BrightBlack),
484 "bright_red" | "brightred" => Ok(Color::BrightRed),
485 "bright_green" | "brightgreen" => Ok(Color::BrightGreen),
486 "bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
487 "bright_blue" | "brightblue" => Ok(Color::BrightBlue),
488 "bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
489 "bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
490 "bright_white" | "brightwhite" => Ok(Color::BrightWhite),
491 _ => Err(D::Error::custom(format!(
492 "invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
493 bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
494 s
495 ))),
496 }
497 }
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
502pub enum ColorMode {
503 #[default]
505 Auto,
506 Always,
508 Never,
510}
511
512impl JsonSchema for ColorMode {
513 fn schema_name() -> std::borrow::Cow<'static, str> {
514 "ColorMode".into()
515 }
516
517 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
518 serde_json::from_value(serde_json::json!({
519 "type": "string",
520 "description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
521 "default": "auto",
522 "enum": ["auto", "always", "never"]
523 }))
524 .unwrap()
525 }
526}
527
528impl<'de> Deserialize<'de> for ColorMode {
529 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
530 where
531 D: serde::Deserializer<'de>,
532 {
533 use serde::de::Error;
534
535 let s = String::deserialize(deserializer)?;
536 match s.as_str() {
537 "auto" => Ok(ColorMode::Auto),
538 "always" => Ok(ColorMode::Always),
539 "never" => Ok(ColorMode::Never),
540 _ => Err(D::Error::custom(format!(
541 "invalid color mode '{}', expected 'auto', 'always', or 'never'",
542 s
543 ))),
544 }
545 }
546}
547
548#[derive(Debug, Clone, Deserialize, JsonSchema)]
552pub struct AdditionalDirective {
553 pub name: String,
555 #[serde(default)]
557 pub case_insensitive: bool,
558 #[serde(default)]
560 pub multi_key: bool,
561}
562
563#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
569pub struct RuleConfig {
570 #[serde(default = "default_true")]
572 pub enabled: bool,
573 #[serde(default)]
578 pub skip_version_check: bool,
579 pub indent_size: Option<IndentSize>,
581 pub allowed_protocols: Option<Vec<String>>,
583 pub weak_ciphers: Option<Vec<String>>,
585 pub required_exclusions: Option<Vec<String>>,
587 pub additional_contexts: Option<HashMap<String, Vec<String>>>,
590 pub max_block_lines: Option<usize>,
592 pub excluded_directives: Option<Vec<String>>,
594 pub additional_directives: Option<Vec<AdditionalDirective>>,
596}
597
598fn default_true() -> bool {
599 true
600}
601
602impl LintConfig {
603 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
605 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
606 path: path.to_path_buf(),
607 source: e,
608 })?;
609
610 toml::from_str(&content).map_err(|e| ConfigError::ParseError {
611 path: path.to_path_buf(),
612 source: e,
613 })
614 }
615
616 pub fn parse(content: &str) -> Result<Self, String> {
618 toml::from_str(content).map_err(|e| e.to_string())
619 }
620
621 pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
625 let mut current = dir.to_path_buf();
626
627 loop {
628 let config_path = current.join(".nginx-lint.toml");
629 if config_path.exists() {
630 return Self::from_file(&config_path)
631 .ok()
632 .map(|cfg| (cfg, config_path));
633 }
634
635 if !current.pop() {
636 break;
637 }
638 }
639
640 None
641 }
642
643 pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
645 "gzip-not-enabled", "missing-error-log", ];
648
649 pub const NATIVE_RULE_NAMES: &'static [&'static str] = &[
656 "unmatched-braces",
657 "unclosed-quote",
658 "missing-semicolon",
659 "indent",
660 "include-path-exists",
661 ];
662
663 pub const KNOWN_RULE_NAMES: &'static [&'static str] = &[
675 "unmatched-braces",
677 "unclosed-quote",
678 "missing-semicolon",
679 "indent",
680 "include-path-exists",
681 "server-tokens-enabled",
684 "autoindex-enabled",
685 "gzip-not-enabled",
686 "duplicate-directive",
687 "space-before-semicolon",
688 "trailing-whitespace",
689 "block-lines",
690 "proxy-pass-domain",
691 "upstream-server-no-resolve",
692 "directive-inheritance",
693 "root-in-location",
694 "alias-location-slash-mismatch",
695 "proxy-pass-with-uri",
696 "proxy-keepalive",
697 "try-files-with-proxy",
698 "if-is-evil-in-location",
699 "unreachable-location",
700 "missing-error-log",
701 "deprecated-ssl-protocol",
702 "weak-ssl-ciphers",
703 "invalid-directive-context",
704 "map-missing-default",
705 "ssl-on-deprecated",
706 "listen-http2-deprecated",
707 "proxy-missing-host-header",
708 "client-max-body-size-not-set",
709 "nginx-rift",
710 ];
711
712 pub fn is_rule_enabled(&self, name: &str) -> bool {
714 self.rules
715 .get(name)
716 .map(|r| r.enabled)
717 .unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
718 }
719
720 pub fn rule_explicitly_configured(&self, name: &str) -> bool {
725 self.rules.contains_key(name)
726 }
727
728 pub fn rule_skip_version_check(&self, name: &str) -> bool {
730 self.rules
731 .get(name)
732 .map(|r| r.skip_version_check)
733 .unwrap_or(false)
734 }
735
736 pub fn target_nginx_version(&self) -> Option<&str> {
738 self.target_nginx_version.as_deref()
739 }
740
741 pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
743 self.rules.get(name)
744 }
745
746 pub fn color_mode(&self) -> ColorMode {
748 self.color.ui
749 }
750
751 pub fn additional_block_directives(&self) -> &[String] {
753 &self.parser.block_directives
754 }
755
756 pub fn include_path_mappings(&self) -> &[PathMapping] {
758 &self.include.path_map
759 }
760
761 pub fn json_schema() -> serde_json::Value {
766 let generator = schemars::SchemaGenerator::default();
767 let schema = generator.into_root_schema_for::<LintConfig>();
768 serde_json::to_value(schema).unwrap()
769 }
770
771 pub fn include_prefix(&self) -> Option<&str> {
773 self.include.prefix.as_deref()
774 }
775
776 pub fn cache_dir(&self) -> Option<&str> {
779 self.cache_dir.as_deref()
780 }
781
782 pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
784 self.rules
785 .get("invalid-directive-context")
786 .and_then(|r| r.additional_contexts.as_ref())
787 }
788
789 pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
791 self.rules
792 .get("directive-inheritance")
793 .and_then(|r| r.excluded_directives.as_deref())
794 }
795
796 pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
798 self.rules
799 .get("directive-inheritance")
800 .and_then(|r| r.additional_directives.as_deref())
801 }
802
803 pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
805 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
806 path: path.to_path_buf(),
807 source: e,
808 })?;
809
810 Self::validate_content(&content, path)
811 }
812
813 fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
815 let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
816 path: path.to_path_buf(),
817 source: e,
818 })?;
819
820 let mut errors = Vec::new();
821
822 if let toml::Value::Table(root) = value {
823 let known_top_level: HashSet<&str> = [
825 "rules",
826 "color",
827 "parser",
828 "include",
829 "target_nginx_version",
830 "cache_dir",
831 ]
832 .into_iter()
833 .collect();
834
835 for key in root.keys() {
836 if !known_top_level.contains(key.as_str()) {
837 let line = find_key_line(content, None, key);
838 errors.push(ValidationError::UnknownField {
839 path: key.clone(),
840 line,
841 suggestion: suggest_field(key, &known_top_level),
842 });
843 }
844 }
845
846 if let Some(toml::Value::Table(color)) = root.get("color") {
848 let known_color_keys: HashSet<&str> =
849 ["ui", "error", "warning"].into_iter().collect();
850
851 for key in color.keys() {
852 if !known_color_keys.contains(key.as_str()) {
853 let line = find_key_line(content, Some("color"), key);
854 errors.push(ValidationError::UnknownField {
855 path: format!("color.{}", key),
856 line,
857 suggestion: suggest_field(key, &known_color_keys),
858 });
859 }
860 }
861 }
862
863 if let Some(toml::Value::Table(parser)) = root.get("parser") {
865 let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
866
867 for key in parser.keys() {
868 if !known_parser_keys.contains(key.as_str()) {
869 let line = find_key_line(content, Some("parser"), key);
870 errors.push(ValidationError::UnknownField {
871 path: format!("parser.{}", key),
872 line,
873 suggestion: suggest_field(key, &known_parser_keys),
874 });
875 }
876 }
877 }
878
879 if let Some(toml::Value::Table(include)) = root.get("include") {
881 let known_include_keys: HashSet<&str> =
882 ["path_map", "prefix"].into_iter().collect();
883
884 for key in include.keys() {
885 if !known_include_keys.contains(key.as_str()) {
886 let line = find_key_line(content, Some("include"), key);
887 errors.push(ValidationError::UnknownField {
888 path: format!("include.{}", key),
889 line,
890 suggestion: suggest_field(key, &known_include_keys),
891 });
892 }
893 }
894 }
895
896 if let Some(toml::Value::Table(rules)) = root.get("rules") {
898 let known_rules: HashSet<&str> = Self::KNOWN_RULE_NAMES.iter().copied().collect();
899
900 for (rule_name, rule_value) in rules {
901 if !known_rules.contains(rule_name.as_str()) {
902 let line = find_key_line(content, Some("rules"), rule_name);
903 errors.push(ValidationError::UnknownRule {
904 name: rule_name.clone(),
905 line,
906 suggestion: suggest_field(rule_name, &known_rules),
907 });
908 continue;
909 }
910
911 if let toml::Value::Table(rule_config) = rule_value {
913 let known_rule_options = get_known_rule_options(rule_name);
914 let section = format!("rules.{}", rule_name);
915
916 for key in rule_config.keys() {
917 if !known_rule_options.contains(key.as_str()) {
918 let line = find_key_line(content, Some(§ion), key);
919 errors.push(ValidationError::UnknownRuleOption {
920 rule: rule_name.clone(),
921 option: key.clone(),
922 line,
923 suggestion: suggest_field(key, &known_rule_options),
924 });
925 }
926 }
927 }
928 }
929 }
930 }
931
932 Ok(errors)
933 }
934}
935
936fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
938 let lines: Vec<&str> = content.lines().collect();
939
940 if section.is_none() {
942 let section_header = format!("[{}]", key);
943 for (i, line) in lines.iter().enumerate() {
944 if line.trim() == section_header {
945 return Some(i + 1);
946 }
947 }
948 return None;
949 }
950
951 let target_section = section.unwrap();
952 let mut in_section = false;
953
954 for (i, line) in lines.iter().enumerate() {
955 let trimmed = line.trim();
956
957 if trimmed.starts_with('[') && trimmed.ends_with(']') {
959 let section_name = &trimmed[1..trimmed.len() - 1];
960
961 let full_section = format!("{}.{}", target_section, key);
963 if section_name == full_section {
964 return Some(i + 1);
965 }
966
967 in_section = section_name == target_section
968 || section_name.starts_with(&format!("{}.", target_section));
969 continue;
970 }
971
972 if in_section && let Some((k, _)) = trimmed.split_once('=') {
974 let k = k.trim();
975 if k == key {
976 return Some(i + 1);
977 }
978 }
979 }
980
981 None
982}
983
984fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
986 let mut options: HashSet<&str> = ["enabled", "skip_version_check"].into_iter().collect();
987
988 match rule_name {
989 "indent" => {
990 options.insert("indent_size");
991 }
992 "deprecated-ssl-protocol" => {
993 options.insert("allowed_protocols");
994 }
995 "weak-ssl-ciphers" => {
996 options.insert("weak_ciphers");
997 options.insert("required_exclusions");
998 }
999 "block-lines" => {
1000 options.insert("max_block_lines");
1001 }
1002 "directive-inheritance" => {
1003 options.insert("excluded_directives");
1004 options.insert("additional_directives");
1005 }
1006 _ => {}
1007 }
1008
1009 options
1010}
1011
1012fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
1014 let input_lower = input.to_lowercase();
1015
1016 known
1018 .iter()
1019 .filter(|&&k| {
1020 let k_lower = k.to_lowercase();
1021 k_lower.contains(&input_lower)
1023 || input_lower.contains(&k_lower)
1024 || levenshtein_distance(&input_lower, &k_lower) <= 2
1025 })
1026 .min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
1027 .map(|&s| s.to_string())
1028}
1029
1030fn levenshtein_distance(a: &str, b: &str) -> usize {
1032 let a_chars: Vec<char> = a.chars().collect();
1033 let b_chars: Vec<char> = b.chars().collect();
1034 let a_len = a_chars.len();
1035 let b_len = b_chars.len();
1036
1037 if a_len == 0 {
1038 return b_len;
1039 }
1040 if b_len == 0 {
1041 return a_len;
1042 }
1043
1044 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
1045
1046 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
1047 row[0] = i;
1048 }
1049 for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
1050 *cell = j;
1051 }
1052
1053 for i in 1..=a_len {
1054 for j in 1..=b_len {
1055 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
1056 matrix[i][j] = (matrix[i - 1][j] + 1)
1057 .min(matrix[i][j - 1] + 1)
1058 .min(matrix[i - 1][j - 1] + cost);
1059 }
1060 }
1061
1062 matrix[a_len][b_len]
1063}
1064
1065#[derive(Debug, Clone)]
1071pub enum ValidationError {
1072 UnknownField {
1074 path: String,
1076 line: Option<usize>,
1078 suggestion: Option<String>,
1080 },
1081 UnknownRule {
1083 name: String,
1085 line: Option<usize>,
1087 suggestion: Option<String>,
1089 },
1090 UnknownRuleOption {
1092 rule: String,
1094 option: String,
1096 line: Option<usize>,
1098 suggestion: Option<String>,
1100 },
1101}
1102
1103impl std::fmt::Display for ValidationError {
1104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1105 match self {
1106 ValidationError::UnknownField {
1107 path,
1108 line,
1109 suggestion,
1110 } => {
1111 if let Some(l) = line {
1112 write!(f, "line {}: ", l)?;
1113 }
1114 write!(f, "unknown field '{}'", path)?;
1115 if let Some(s) = suggestion {
1116 write!(f, ", did you mean '{}'?", s)?;
1117 }
1118 Ok(())
1119 }
1120 ValidationError::UnknownRule {
1121 name,
1122 line,
1123 suggestion,
1124 } => {
1125 if let Some(l) = line {
1126 write!(f, "line {}: ", l)?;
1127 }
1128 write!(f, "unknown rule '{}'", name)?;
1129 if let Some(s) = suggestion {
1130 write!(f, ", did you mean '{}'?", s)?;
1131 }
1132 Ok(())
1133 }
1134 ValidationError::UnknownRuleOption {
1135 rule,
1136 option,
1137 line,
1138 suggestion,
1139 } => {
1140 if let Some(l) = line {
1141 write!(f, "line {}: ", l)?;
1142 }
1143 write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
1144 if let Some(s) = suggestion {
1145 write!(f, ", did you mean '{}'?", s)?;
1146 }
1147 Ok(())
1148 }
1149 }
1150 }
1151}
1152
1153#[derive(Debug)]
1155pub enum ConfigError {
1156 IoError {
1158 path: std::path::PathBuf,
1160 source: std::io::Error,
1162 },
1163 ParseError {
1165 path: std::path::PathBuf,
1167 source: toml::de::Error,
1169 },
1170}
1171
1172impl std::fmt::Display for ConfigError {
1173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1174 match self {
1175 ConfigError::IoError { path, source } => {
1176 write!(
1177 f,
1178 "Failed to read config file '{}': {}",
1179 path.display(),
1180 source
1181 )
1182 }
1183 ConfigError::ParseError { path, source } => {
1184 write!(
1185 f,
1186 "Failed to parse config file '{}': {}",
1187 path.display(),
1188 source
1189 )
1190 }
1191 }
1192 }
1193}
1194
1195impl std::error::Error for ConfigError {
1196 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1197 match self {
1198 ConfigError::IoError { source, .. } => Some(source),
1199 ConfigError::ParseError { source, .. } => Some(source),
1200 }
1201 }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207 use std::io::Write;
1208 use tempfile::NamedTempFile;
1209
1210 #[test]
1211 fn test_default_config() {
1212 let config = LintConfig::default();
1213 assert!(config.is_rule_enabled("any-rule"));
1214 }
1215
1216 #[test]
1217 fn test_disabled_by_default_rules() {
1218 let config = LintConfig::default();
1219 assert!(!config.is_rule_enabled("gzip-not-enabled"));
1221 assert!(!config.is_rule_enabled("missing-error-log"));
1222 assert!(config.is_rule_enabled("server-tokens-enabled"));
1224 }
1225
1226 #[test]
1227 fn test_parse_config() {
1228 let toml_content = r#"
1229[rules.indent]
1230enabled = true
1231indent_size = 2
1232
1233[rules.server-tokens-enabled]
1234enabled = false
1235"#;
1236 let mut file = NamedTempFile::new().unwrap();
1237 write!(file, "{}", toml_content).unwrap();
1238
1239 let config = LintConfig::from_file(file.path()).unwrap();
1240
1241 assert!(config.is_rule_enabled("indent"));
1242 assert!(!config.is_rule_enabled("server-tokens-enabled"));
1243 assert!(config.is_rule_enabled("unknown-rule"));
1244
1245 let indent_config = config.get_rule_config("indent").unwrap();
1246 assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
1247 }
1248
1249 #[test]
1250 fn test_empty_config() {
1251 let toml_content = "";
1252 let mut file = NamedTempFile::new().unwrap();
1253 write!(file, "{}", toml_content).unwrap();
1254
1255 let config = LintConfig::from_file(file.path()).unwrap();
1256 assert!(config.is_rule_enabled("any-rule"));
1257 }
1258
1259 #[test]
1260 fn test_indent_size_auto() {
1261 let toml_content = r#"
1262[rules.indent]
1263enabled = true
1264indent_size = "auto"
1265"#;
1266 let mut file = NamedTempFile::new().unwrap();
1267 write!(file, "{}", toml_content).unwrap();
1268
1269 let config = LintConfig::from_file(file.path()).unwrap();
1270 let indent_config = config.get_rule_config("indent").unwrap();
1271 assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
1272 }
1273
1274 #[test]
1275 fn test_color_config_default() {
1276 let config = LintConfig::default();
1277 assert_eq!(config.color_mode(), ColorMode::Auto);
1278 }
1279
1280 #[test]
1281 fn test_color_config_auto() {
1282 let toml_content = r#"
1283[color]
1284ui = "auto"
1285"#;
1286 let mut file = NamedTempFile::new().unwrap();
1287 write!(file, "{}", toml_content).unwrap();
1288
1289 let config = LintConfig::from_file(file.path()).unwrap();
1290 assert_eq!(config.color_mode(), ColorMode::Auto);
1291 }
1292
1293 #[test]
1294 fn test_color_config_never() {
1295 let toml_content = r#"
1296[color]
1297ui = "never"
1298"#;
1299 let mut file = NamedTempFile::new().unwrap();
1300 write!(file, "{}", toml_content).unwrap();
1301
1302 let config = LintConfig::from_file(file.path()).unwrap();
1303 assert_eq!(config.color_mode(), ColorMode::Never);
1304 }
1305
1306 #[test]
1307 fn test_color_config_always() {
1308 let toml_content = r#"
1309[color]
1310ui = "always"
1311"#;
1312 let mut file = NamedTempFile::new().unwrap();
1313 write!(file, "{}", toml_content).unwrap();
1314
1315 let config = LintConfig::from_file(file.path()).unwrap();
1316 assert_eq!(config.color_mode(), ColorMode::Always);
1317 }
1318
1319 #[test]
1320 fn test_color_config_default_colors() {
1321 let config = LintConfig::default();
1322 assert_eq!(config.color.error, Color::Red);
1323 assert_eq!(config.color.warning, Color::Yellow);
1324 }
1325
1326 #[test]
1327 fn test_color_config_custom_colors() {
1328 let toml_content = r#"
1329[color]
1330error = "magenta"
1331warning = "cyan"
1332"#;
1333 let mut file = NamedTempFile::new().unwrap();
1334 write!(file, "{}", toml_content).unwrap();
1335
1336 let config = LintConfig::from_file(file.path()).unwrap();
1337 assert_eq!(config.color.error, Color::Magenta);
1338 assert_eq!(config.color.warning, Color::Cyan);
1339 }
1340
1341 #[test]
1342 fn test_color_config_bright_colors() {
1343 let toml_content = r#"
1344[color]
1345error = "bright_red"
1346warning = "bright_yellow"
1347"#;
1348 let mut file = NamedTempFile::new().unwrap();
1349 write!(file, "{}", toml_content).unwrap();
1350
1351 let config = LintConfig::from_file(file.path()).unwrap();
1352 assert_eq!(config.color.error, Color::BrightRed);
1353 assert_eq!(config.color.warning, Color::BrightYellow);
1354 }
1355
1356 #[test]
1357 fn test_block_lines_max_block_lines_parsing() {
1358 let toml_content = r#"
1359[rules.block-lines]
1360enabled = true
1361max_block_lines = 50
1362"#;
1363 let mut file = NamedTempFile::new().unwrap();
1364 write!(file, "{}", toml_content).unwrap();
1365
1366 let config = LintConfig::from_file(file.path()).unwrap();
1367 assert!(config.is_rule_enabled("block-lines"));
1368 let rule_config = config.get_rule_config("block-lines").unwrap();
1369 assert_eq!(rule_config.max_block_lines, Some(50));
1370 }
1371
1372 #[test]
1373 fn test_block_lines_default_no_max() {
1374 let toml_content = r#"
1375[rules.block-lines]
1376enabled = true
1377"#;
1378 let mut file = NamedTempFile::new().unwrap();
1379 write!(file, "{}", toml_content).unwrap();
1380
1381 let config = LintConfig::from_file(file.path()).unwrap();
1382 let rule_config = config.get_rule_config("block-lines").unwrap();
1383 assert_eq!(rule_config.max_block_lines, None);
1384 }
1385
1386 #[test]
1387 fn test_block_lines_validation_rejects_unknown_option() {
1388 let toml_content = r#"
1389[rules.block-lines]
1390enabled = true
1391unknown_option = 42
1392"#;
1393 let mut file = NamedTempFile::new().unwrap();
1394 write!(file, "{}", toml_content).unwrap();
1395
1396 let errors = LintConfig::validate_file(file.path()).unwrap();
1397 assert_eq!(errors.len(), 1);
1398 match &errors[0] {
1399 ValidationError::UnknownRuleOption { rule, option, .. } => {
1400 assert_eq!(rule, "block-lines");
1401 assert_eq!(option, "unknown_option");
1402 }
1403 other => panic!("expected UnknownRuleOption, got: {:?}", other),
1404 }
1405 }
1406
1407 #[test]
1408 fn test_include_path_map_empty_by_default() {
1409 let config = LintConfig::default();
1410 assert!(config.include_path_mappings().is_empty());
1411 }
1412
1413 #[test]
1414 fn test_include_path_map_single_entry() {
1415 let toml_content = r#"
1416[[include.path_map]]
1417from = "sites-enabled"
1418to = "sites-available"
1419"#;
1420 let config = LintConfig::parse(toml_content).unwrap();
1421 let mappings = config.include_path_mappings();
1422 assert_eq!(mappings.len(), 1);
1423 assert_eq!(mappings[0].from, "sites-enabled");
1424 assert_eq!(mappings[0].to, "sites-available");
1425 }
1426
1427 #[test]
1428 fn test_include_path_map_multiple_entries_preserve_order() {
1429 let toml_content = r#"
1430[[include.path_map]]
1431from = "sites-enabled"
1432to = "sites-available"
1433
1434[[include.path_map]]
1435from = "/etc/nginx"
1436to = "/usr/local/nginx"
1437"#;
1438 let config = LintConfig::parse(toml_content).unwrap();
1439 let mappings = config.include_path_mappings();
1440 assert_eq!(mappings.len(), 2);
1441 assert_eq!(mappings[0].from, "sites-enabled");
1442 assert_eq!(mappings[0].to, "sites-available");
1443 assert_eq!(mappings[1].from, "/etc/nginx");
1444 assert_eq!(mappings[1].to, "/usr/local/nginx");
1445 }
1446
1447 #[test]
1448 fn test_include_validation_rejects_unknown_field() {
1449 let toml_content = r#"
1450[include]
1451unknown_key = "value"
1452"#;
1453 let mut file = NamedTempFile::new().unwrap();
1454 write!(file, "{}", toml_content).unwrap();
1455
1456 let errors = LintConfig::validate_file(file.path()).unwrap();
1457 assert_eq!(errors.len(), 1);
1458 match &errors[0] {
1459 ValidationError::UnknownField { path, .. } => {
1460 assert_eq!(path, "include.unknown_key");
1461 }
1462 other => panic!("expected UnknownField, got: {:?}", other),
1463 }
1464 }
1465
1466 #[test]
1467 fn test_include_prefix_none_by_default() {
1468 let config = LintConfig::default();
1469 assert!(config.include_prefix().is_none());
1470 }
1471
1472 #[test]
1473 fn test_cache_dir_none_by_default() {
1474 let config = LintConfig::default();
1475 assert!(config.cache_dir().is_none());
1476 }
1477
1478 #[test]
1479 fn test_cache_dir_parsed() {
1480 let config = LintConfig::parse(r#"cache_dir = ".nginx-lint-cache""#).unwrap();
1481 assert_eq!(config.cache_dir(), Some(".nginx-lint-cache"));
1482 }
1483
1484 #[test]
1485 fn test_cache_dir_validation_accepted() {
1486 let mut file = NamedTempFile::new().unwrap();
1487 write!(file, "cache_dir = \"/var/cache/nginx-lint\"").unwrap();
1488
1489 let errors = LintConfig::validate_file(file.path()).unwrap();
1490 assert!(
1491 errors.is_empty(),
1492 "cache_dir should be a valid top-level field, got errors: {:?}",
1493 errors
1494 );
1495 }
1496
1497 #[test]
1498 fn test_include_prefix_parsed() {
1499 let toml_content = r#"
1500[include]
1501prefix = "/etc/nginx"
1502"#;
1503 let config = LintConfig::parse(toml_content).unwrap();
1504 assert_eq!(config.include_prefix(), Some("/etc/nginx"));
1505 }
1506
1507 #[test]
1508 fn test_include_prefix_with_path_map() {
1509 let toml_content = r#"
1510[include]
1511prefix = "."
1512
1513[[include.path_map]]
1514from = "sites-enabled"
1515to = "sites-available"
1516"#;
1517 let config = LintConfig::parse(toml_content).unwrap();
1518 assert_eq!(config.include_prefix(), Some("."));
1519 assert_eq!(config.include_path_mappings().len(), 1);
1520 }
1521
1522 #[test]
1523 fn test_include_prefix_validation_accepted() {
1524 let toml_content = r#"
1525[include]
1526prefix = "/etc/nginx"
1527"#;
1528 let mut file = NamedTempFile::new().unwrap();
1529 write!(file, "{}", toml_content).unwrap();
1530
1531 let errors = LintConfig::validate_file(file.path()).unwrap();
1532 assert!(
1533 errors.is_empty(),
1534 "prefix should be a valid include field, got errors: {:?}",
1535 errors
1536 );
1537 }
1538
1539 #[test]
1540 fn test_json_schema_is_valid() {
1541 let schema = LintConfig::json_schema();
1542
1543 assert_eq!(
1545 schema.get("$schema").and_then(|v| v.as_str()),
1546 Some("https://json-schema.org/draft/2020-12/schema")
1547 );
1548
1549 let props = schema.get("properties").unwrap().as_object().unwrap();
1551 assert!(props.contains_key("rules"), "missing 'rules' property");
1552 assert!(props.contains_key("color"), "missing 'color' property");
1553 assert!(props.contains_key("parser"), "missing 'parser' property");
1554 assert!(props.contains_key("include"), "missing 'include' property");
1555 }
1556
1557 #[test]
1562 fn test_validate_accepts_previously_drifted_builtin_plugins() {
1563 let previously_missing = [
1565 "client-max-body-size-not-set",
1566 "listen-http2-deprecated",
1567 "map-missing-default",
1568 "proxy-missing-host-header",
1569 "ssl-on-deprecated",
1570 "unreachable-location",
1571 ];
1572
1573 for rule_name in previously_missing {
1574 let toml_content = format!("[rules.{rule_name}]\nenabled = false\n");
1575 let mut file = NamedTempFile::new().unwrap();
1576 write!(file, "{}", toml_content).unwrap();
1577
1578 let errors = LintConfig::validate_file(file.path()).unwrap();
1579 assert!(
1580 errors.is_empty(),
1581 "rule '{rule_name}' should be a known builtin plugin name, \
1582 but `validate_file` reported errors: {errors:?}"
1583 );
1584 }
1585 }
1586
1587 #[test]
1590 fn test_known_rules_constant_drives_validator() {
1591 for rule_name in LintConfig::KNOWN_RULE_NAMES {
1592 let toml_content = format!("[rules.{rule_name}]\nenabled = true\n");
1593 let mut file = NamedTempFile::new().unwrap();
1594 write!(file, "{}", toml_content).unwrap();
1595
1596 let errors = LintConfig::validate_file(file.path()).unwrap();
1597 assert!(
1598 errors.is_empty(),
1599 "rule '{rule_name}' is listed in KNOWN_RULE_NAMES but the validator \
1600 rejected it: {errors:?}"
1601 );
1602 }
1603 }
1604
1605 #[test]
1608 fn test_native_rule_names_subset_of_known_rules() {
1609 let known: HashSet<&str> = LintConfig::KNOWN_RULE_NAMES.iter().copied().collect();
1610 let missing: Vec<&str> = LintConfig::NATIVE_RULE_NAMES
1611 .iter()
1612 .copied()
1613 .filter(|name| !known.contains(name))
1614 .collect();
1615 assert!(
1616 missing.is_empty(),
1617 "NATIVE_RULE_NAMES entries missing from KNOWN_RULE_NAMES: {missing:?}"
1618 );
1619 }
1620
1621 #[test]
1625 fn test_validate_rejects_unknown_rule_name() {
1626 let toml_content = "[rules.no-such-rule-zzz]\nenabled = true\n";
1627 let mut file = NamedTempFile::new().unwrap();
1628 write!(file, "{}", toml_content).unwrap();
1629
1630 let errors = LintConfig::validate_file(file.path()).unwrap();
1631 assert_eq!(
1632 errors.len(),
1633 1,
1634 "expected exactly one error, got: {errors:?}"
1635 );
1636 match &errors[0] {
1637 ValidationError::UnknownRule { name, .. } => {
1638 assert_eq!(name, "no-such-rule-zzz");
1639 }
1640 other => panic!("expected UnknownRule, got: {other:?}"),
1641 }
1642 }
1643
1644 #[test]
1647 fn test_known_rules_has_no_duplicates() {
1648 let mut seen: HashSet<&str> = HashSet::new();
1649 for name in LintConfig::KNOWN_RULE_NAMES {
1650 assert!(
1651 seen.insert(name),
1652 "duplicate entry in KNOWN_RULE_NAMES: '{name}'"
1653 );
1654 }
1655 }
1656
1657 #[test]
1658 fn test_json_schema_rule_config_has_all_fields() {
1659 let schema = LintConfig::json_schema();
1660
1661 let rule_config_def = schema
1664 .pointer("/$defs/RuleConfig")
1665 .expect("RuleConfig definition missing from schema");
1666
1667 let props = rule_config_def
1668 .get("properties")
1669 .unwrap()
1670 .as_object()
1671 .unwrap();
1672
1673 let expected_fields = [
1674 "enabled",
1675 "skip_version_check",
1676 "indent_size",
1677 "allowed_protocols",
1678 "weak_ciphers",
1679 "required_exclusions",
1680 "additional_contexts",
1681 "max_block_lines",
1682 "excluded_directives",
1683 "additional_directives",
1684 ];
1685
1686 for field in &expected_fields {
1687 assert!(
1688 props.contains_key(*field),
1689 "RuleConfig schema missing field '{field}'"
1690 );
1691 }
1692 }
1693
1694 #[test]
1695 fn test_target_nginx_version_parsed() {
1696 let toml_content = r#"
1697target_nginx_version = "1.31.0"
1698"#;
1699 let config = LintConfig::parse(toml_content).unwrap();
1700 assert_eq!(config.target_nginx_version(), Some("1.31.0"));
1701 }
1702
1703 #[test]
1704 fn test_target_nginx_version_default_none() {
1705 let config = LintConfig::default();
1706 assert!(config.target_nginx_version().is_none());
1707 }
1708
1709 #[test]
1710 fn test_skip_version_check_per_rule() {
1711 let toml_content = r#"
1712[rules.nginx-rift]
1713enabled = true
1714skip_version_check = true
1715"#;
1716 let config = LintConfig::parse(toml_content).unwrap();
1717 assert!(config.rule_skip_version_check("nginx-rift"));
1718 assert!(!config.rule_skip_version_check("server-tokens-enabled"));
1719 }
1720
1721 #[test]
1722 fn test_rule_explicitly_configured() {
1723 let toml_content = r#"
1724[rules.indent]
1725enabled = true
1726"#;
1727 let config = LintConfig::parse(toml_content).unwrap();
1728 assert!(config.rule_explicitly_configured("indent"));
1729 assert!(!config.rule_explicitly_configured("server-tokens-enabled"));
1730 }
1731
1732 #[test]
1733 fn test_validator_accepts_target_nginx_version() {
1734 let toml_content = r#"
1735target_nginx_version = "1.31.0"
1736"#;
1737 let mut file = NamedTempFile::new().unwrap();
1738 write!(file, "{}", toml_content).unwrap();
1739
1740 let errors = LintConfig::validate_file(file.path()).unwrap();
1741 assert!(
1742 errors.is_empty(),
1743 "target_nginx_version should be a valid top-level field, got: {errors:?}"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_validator_accepts_skip_version_check() {
1749 for rule_name in LintConfig::KNOWN_RULE_NAMES {
1750 let toml_content =
1751 format!("[rules.{rule_name}]\nenabled = true\nskip_version_check = true\n");
1752 let mut file = NamedTempFile::new().unwrap();
1753 write!(file, "{}", toml_content).unwrap();
1754
1755 let errors = LintConfig::validate_file(file.path()).unwrap();
1756 assert!(
1757 errors.is_empty(),
1758 "skip_version_check should be valid for rule '{rule_name}', got: {errors:?}"
1759 );
1760 }
1761 }
1762}