Skip to main content

nginx_lint_common/
config.rs

1//! Configuration management for nginx-lint.
2//!
3//! This module handles loading and validating the `.nginx-lint.toml`
4//! configuration file. The main entry point is [`LintConfig`], which can be
5//! loaded from a file with [`LintConfig::from_file`] or discovered
6//! automatically with [`LintConfig::find_and_load`].
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer};
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15/// Indent size configuration: either a fixed number or "auto" for auto-detection
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum IndentSize {
18    /// Auto-detect indent size from the first indented line
19    #[default]
20    Auto,
21    /// Fixed indent size (number of spaces)
22    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
104/// Default configuration template for nginx-lint
105pub 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/// Configuration for nginx-lint loaded from `.nginx-lint.toml`.
310///
311/// Use [`from_file`](Self::from_file) to load from a specific path, or
312/// [`find_and_load`](Self::find_and_load) to search the directory tree upward.
313/// A default `LintConfig` enables all rules except those listed in
314/// [`DISABLED_BY_DEFAULT`](Self::DISABLED_BY_DEFAULT).
315#[derive(Debug, Default, Deserialize, JsonSchema)]
316pub struct LintConfig {
317    /// Per-rule configuration keyed by rule name (e.g. `"indent"`, `"server-tokens-enabled"`).
318    #[serde(default)]
319    pub rules: HashMap<String, RuleConfig>,
320    /// Color output settings.
321    #[serde(default)]
322    pub color: ColorConfig,
323    /// Parser-level settings (e.g. additional block directives for extension modules).
324    #[serde(default)]
325    pub parser: ParserConfig,
326    /// Include resolution settings (e.g. path mappings for include directives).
327    #[serde(default)]
328    pub include: IncludeConfig,
329    /// Target nginx version (e.g. `"1.31.0"`).
330    ///
331    /// When set, rules whose declared version range does not include this
332    /// version are automatically skipped. Stored as a raw string so an
333    /// unparseable value does not fail config loading — the linter parses it
334    /// lazily and emits a single warning if invalid.
335    #[serde(default)]
336    pub target_nginx_version: Option<String>,
337    /// Cache directory for nginx-lint.
338    ///
339    /// Cacheable artifacts are stored in subdirectories beneath it (e.g.
340    /// the WASM plugin compilation cache under `plugins/`). Defaults to the
341    /// per-user cache directory (e.g. `~/.cache/nginx-lint` on Linux). A
342    /// relative path is resolved against the directory containing the
343    /// config file.
344    #[serde(default)]
345    pub cache_dir: Option<String>,
346}
347
348/// Parser configuration
349#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
350pub struct ParserConfig {
351    /// Additional block directives (extension modules, etc.)
352    /// These are added to the built-in list of block directives
353    #[serde(default)]
354    pub block_directives: Vec<String>,
355}
356
357/// A single path mapping rule for include directive resolution.
358///
359/// When an `include` directive's path contains path segment(s) that exactly
360/// match `from`, they are replaced with `to` before the path is resolved on
361/// disk.  Matching is performed at the path-component level (split by `/`),
362/// so `from = "sites-enabled"` matches `.../sites-enabled/...` but does NOT
363/// match `.../asites-enabled/...`.  Multi-segment values like
364/// `from = "nginx/sites-enabled"` match consecutive components.
365///
366/// This is useful when the production config references a directory
367/// (e.g. `sites-enabled`) that is only populated at runtime via symlinks,
368/// and you want nginx-lint to evaluate the actual source files
369/// (e.g. `sites-available`) instead.
370///
371/// Mappings are applied in declaration order and chained, so the output of one
372/// mapping is fed into the next.
373#[derive(Debug, Clone, Deserialize, JsonSchema)]
374pub struct PathMapping {
375    /// Path segment(s) to match in the include path (compared component-wise)
376    pub from: String,
377    /// Replacement path segment(s)
378    pub to: String,
379}
380
381/// Include resolution configuration.
382#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
383pub struct IncludeConfig {
384    /// Path mappings applied to include patterns before resolving them.
385    /// Applied in declaration order; each mapping receives the output of the previous one.
386    #[serde(default)]
387    pub path_map: Vec<PathMapping>,
388    /// Base directory for resolving relative include paths (similar to nginx `-p` prefix).
389    /// When set, all relative include paths are resolved from this directory
390    /// instead of the directory containing the config file with the include directive.
391    pub prefix: Option<String>,
392}
393
394/// Color output configuration
395#[derive(Debug, Clone, Deserialize, JsonSchema)]
396pub struct ColorConfig {
397    /// Color mode: "auto" (default), "always", or "never"
398    #[serde(default)]
399    pub ui: ColorMode,
400    /// Color for error messages (default: "red")
401    #[serde(default = "default_error_color")]
402    pub error: Color,
403    /// Color for warning messages (default: "yellow")
404    #[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/// Available colors for output
427#[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/// Color mode for output
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
502pub enum ColorMode {
503    /// Automatically detect (default) - respects NO_COLOR env and terminal detection
504    #[default]
505    Auto,
506    /// Always use colors
507    Always,
508    /// Never use colors
509    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/// An additional directive to check for inheritance issues.
549///
550/// Used in `[rules.directive-inheritance]` configuration.
551#[derive(Debug, Clone, Deserialize, JsonSchema)]
552pub struct AdditionalDirective {
553    /// The directive name (e.g., "proxy_set_cookie")
554    pub name: String,
555    /// Whether the first argument key comparison is case-insensitive (default: false)
556    #[serde(default)]
557    pub case_insensitive: bool,
558    /// If true, all numeric arguments are separate keys like error_page (default: false)
559    #[serde(default)]
560    pub multi_key: bool,
561}
562
563/// Configuration for a specific lint rule.
564///
565/// Every `[rules.<name>]` section in `.nginx-lint.toml` is deserialized into
566/// a `RuleConfig`. The only universal field is [`enabled`](Self::enabled);
567/// the remaining fields are rule-specific options.
568#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
569pub struct RuleConfig {
570    /// Whether this rule is active (`true` by default for most rules).
571    #[serde(default = "default_true")]
572    pub enabled: bool,
573    /// When true, run this rule regardless of the configured
574    /// [`target_nginx_version`](LintConfig::target_nginx_version) and the rule's
575    /// declared version range. Useful for opt-in overrides on rules that would
576    /// otherwise be filtered out as not applicable to the target version.
577    #[serde(default)]
578    pub skip_version_check: bool,
579    /// For indent rule: number or "auto" for auto-detection
580    pub indent_size: Option<IndentSize>,
581    /// For deprecated-ssl-protocol rule: allowed protocols (default: ["TLSv1.2", "TLSv1.3"])
582    pub allowed_protocols: Option<Vec<String>>,
583    /// For weak-ssl-ciphers rule: weak cipher patterns to detect
584    pub weak_ciphers: Option<Vec<String>>,
585    /// For weak-ssl-ciphers rule: required exclusion patterns
586    pub required_exclusions: Option<Vec<String>>,
587    /// For invalid-directive-context rule: additional valid parent contexts
588    /// Format: { "server" = ["rtmp"], "upstream" = ["rtmp"] }
589    pub additional_contexts: Option<HashMap<String, Vec<String>>>,
590    /// For block-lines rule: maximum number of lines allowed in a block
591    pub max_block_lines: Option<usize>,
592    /// For directive-inheritance rule: directives to exclude from checking
593    pub excluded_directives: Option<Vec<String>>,
594    /// For directive-inheritance rule: additional directives to check
595    pub additional_directives: Option<Vec<AdditionalDirective>>,
596}
597
598fn default_true() -> bool {
599    true
600}
601
602impl LintConfig {
603    /// Load configuration from a file
604    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    /// Parse configuration from a TOML string
617    pub fn parse(content: &str) -> Result<Self, String> {
618        toml::from_str(content).map_err(|e| e.to_string())
619    }
620
621    /// Find and load .nginx-lint.toml from the given directory or its parents.
622    ///
623    /// Returns the loaded config along with the path of the config file found.
624    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    /// Rules that are disabled by default
644    pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
645        "gzip-not-enabled", // gzip is not always appropriate (CDN, CPU constraints, security)
646        "missing-error-log", // error_log is typically set at top level in main config
647    ];
648
649    /// Native lint rules implemented directly in the top-level crate
650    /// (i.e. not packaged as plugins under `plugins/builtin/`).
651    ///
652    /// Exposed as a `pub const` so the drift-detection test in the top-level
653    /// crate (`tests/known_rules_drift_test.rs`) can distinguish "native rule"
654    /// from "stale builtin plugin entry" without duplicating this list.
655    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    /// All rule names recognised by `nginx-lint config validate`.
664    ///
665    /// This is the union of [`NATIVE_RULE_NAMES`](Self::NATIVE_RULE_NAMES) and
666    /// the builtin plugin names. The builtin plugin list lives in the
667    /// top-level crate (`nginx-lint`) as `BUILTIN_PLUGIN_NAMES`; because this
668    /// module is a downstream dependency it cannot reference that symbol
669    /// directly, so the two lists are kept in sync by a drift-detection unit
670    /// test in the top-level crate (`tests/known_rules_drift_test.rs`).
671    ///
672    /// Order: native rules first, then builtin plugins in the same order as
673    /// `BUILTIN_PLUGIN_NAMES` so review diffs against that file are obvious.
674    pub const KNOWN_RULE_NAMES: &'static [&'static str] = &[
675        // Native rules — must match `NATIVE_RULE_NAMES` above
676        "unmatched-braces",
677        "unclosed-quote",
678        "missing-semicolon",
679        "indent",
680        "include-path-exists",
681        // Builtin plugins — must match `BUILTIN_PLUGIN_NAMES` in
682        // `src/plugin/mod.rs` (same order for easier review)
683        "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    /// Check if a rule is enabled
713    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    /// Whether the user explicitly wrote a `[rules.<name>]` section for this
721    /// rule (irrespective of which options it contains). Used to distinguish
722    /// "explicitly enabled" from "enabled by default" when warning about
723    /// rules that fall outside the configured nginx version range.
724    pub fn rule_explicitly_configured(&self, name: &str) -> bool {
725        self.rules.contains_key(name)
726    }
727
728    /// Whether a rule has `skip_version_check = true` in its configuration.
729    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    /// The configured target nginx version as a raw string, if any.
737    pub fn target_nginx_version(&self) -> Option<&str> {
738        self.target_nginx_version.as_deref()
739    }
740
741    /// Get the configuration for a specific rule
742    pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
743        self.rules.get(name)
744    }
745
746    /// Get the color mode setting
747    pub fn color_mode(&self) -> ColorMode {
748        self.color.ui
749    }
750
751    /// Get additional block directives from config
752    pub fn additional_block_directives(&self) -> &[String] {
753        &self.parser.block_directives
754    }
755
756    /// Get include path mappings (applied in order to include patterns before resolving)
757    pub fn include_path_mappings(&self) -> &[PathMapping] {
758        &self.include.path_map
759    }
760
761    /// Generate the JSON Schema for the configuration file.
762    ///
763    /// The schema is derived from the Rust type definitions, so it automatically
764    /// stays in sync with the actual configuration structure.
765    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    /// Get include prefix (base directory for resolving relative include paths)
772    pub fn include_prefix(&self) -> Option<&str> {
773        self.include.prefix.as_deref()
774    }
775
776    /// Get the cache directory for nginx-lint (e.g. the WASM plugin
777    /// compilation cache is stored under `plugins/` beneath it)
778    pub fn cache_dir(&self) -> Option<&str> {
779        self.cache_dir.as_deref()
780    }
781
782    /// Get additional contexts for invalid-directive-context rule
783    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    /// Get excluded directives for directive-inheritance rule
790    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    /// Get additional directives for directive-inheritance rule
797    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    /// Validate a configuration file and return any errors
804    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    /// Validate configuration content and return any errors
814    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            // Known top-level keys
824            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            // Validate [color] section
847            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            // Validate [parser] section
864            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            // Validate [include] section
880            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            // Validate [rules.*] sections
897            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                    // Validate rule options
912                    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(&section), 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
936/// Find the line number where a key is defined in the TOML content
937fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
938    let lines: Vec<&str> = content.lines().collect();
939
940    // For top-level sections (section is None), look for [key]
941    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        // Check for section header [section] or [section.subsection]
958        if trimmed.starts_with('[') && trimmed.ends_with(']') {
959            let section_name = &trimmed[1..trimmed.len() - 1];
960
961            // Check if this is a [rules.rule-name] style section
962            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        // Check for key = value within the section
973        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
984/// Get known options for a specific rule
985fn 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
1012/// Suggest a similar field name if one exists
1013fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
1014    let input_lower = input.to_lowercase();
1015
1016    // Find the closest match using simple edit distance
1017    known
1018        .iter()
1019        .filter(|&&k| {
1020            let k_lower = k.to_lowercase();
1021            // Simple heuristic: check if strings are similar
1022            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
1030/// Simple Levenshtein distance implementation
1031fn 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/// Validation error for configuration files.
1066///
1067/// Returned by [`LintConfig::validate_file`] when the TOML file contains
1068/// unrecognised keys or rule names. Each variant includes an optional
1069/// `suggestion` produced by fuzzy matching.
1070#[derive(Debug, Clone)]
1071pub enum ValidationError {
1072    /// An unrecognised top-level or section-level field (e.g. `[colour]` instead of `[color]`).
1073    UnknownField {
1074        /// Dotted path to the field (e.g. `"color.ui_mode"`).
1075        path: String,
1076        /// 1-indexed line number where the field appears, if found.
1077        line: Option<usize>,
1078        /// A similar known field name, if one is close enough.
1079        suggestion: Option<String>,
1080    },
1081    /// A `[rules.<name>]` section where `<name>` is not a known rule.
1082    UnknownRule {
1083        /// The unrecognised rule name.
1084        name: String,
1085        /// 1-indexed line number where the rule section appears, if found.
1086        line: Option<usize>,
1087        /// A similar known rule name, if one is close enough.
1088        suggestion: Option<String>,
1089    },
1090    /// An unrecognised option inside a rule section (e.g. `indent_sizee` in `[rules.indent]`).
1091    UnknownRuleOption {
1092        /// The rule name containing the unknown option.
1093        rule: String,
1094        /// The unrecognised option key.
1095        option: String,
1096        /// 1-indexed line number where the option appears, if found.
1097        line: Option<usize>,
1098        /// A similar known option name, if one is close enough.
1099        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/// Error returned when loading or parsing a configuration file fails.
1154#[derive(Debug)]
1155pub enum ConfigError {
1156    /// The file could not be read (missing, permission denied, etc.).
1157    IoError {
1158        /// Path that was attempted.
1159        path: std::path::PathBuf,
1160        /// Underlying I/O error.
1161        source: std::io::Error,
1162    },
1163    /// The file was read but contains invalid TOML.
1164    ParseError {
1165        /// Path that was parsed.
1166        path: std::path::PathBuf,
1167        /// Underlying TOML parse error.
1168        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        // These rules should be disabled by default
1220        assert!(!config.is_rule_enabled("gzip-not-enabled"));
1221        assert!(!config.is_rule_enabled("missing-error-log"));
1222        // Other rules should still be enabled by default
1223        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        // Should have $schema key
1544        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        // Should have properties for all top-level config sections
1550        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    /// Regression test for https://github.com/walf443/nginx-lint/issues/172:
1558    /// previously, `config validate` rejected configs that referenced real builtin
1559    /// plugins whose names were missing from the validator's `known_rules`
1560    /// whitelist. These names must remain recognised.
1561    #[test]
1562    fn test_validate_accepts_previously_drifted_builtin_plugins() {
1563        // Names that were drifted out of `known_rules` before the fix for #172.
1564        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    /// Sanity check that `KNOWN_RULE_NAMES` actually drives the validator
1588    /// (rather than an inline list that can drift again).
1589    #[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    /// `NATIVE_RULE_NAMES` must be a subset of `KNOWN_RULE_NAMES`: editing either
1606    /// list independently would mean the validator forgets about a native rule.
1607    #[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    /// Negative case: a rule name that is in neither `NATIVE_RULE_NAMES` nor
1622    /// `BUILTIN_PLUGIN_NAMES` must still produce an `UnknownRule` error —
1623    /// otherwise the whitelist would silently degrade to "accept anything".
1624    #[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    /// `KNOWN_RULE_NAMES` must not contain duplicate entries — a duplicated name
1645    /// would silently mask a typo when the list is edited.
1646    #[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        // RuleConfig definition should contain all fields from the struct
1662        // schemars v1 uses "$defs" instead of "definitions"
1663        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}