use schemars::JsonSchema;
use serde::{Deserialize, Deserializer};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IndentSize {
#[default]
Auto,
Fixed(usize),
}
impl fmt::Display for IndentSize {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IndentSize::Auto => write!(f, "auto"),
IndentSize::Fixed(n) => write!(f, "{}", n),
}
}
}
impl<'de> Deserialize<'de> for IndentSize {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct IndentSizeVisitor;
impl<'de> Visitor<'de> for IndentSizeVisitor {
type Value = IndentSize;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a positive integer or \"auto\"")
}
fn visit_u64<E>(self, value: u64) -> Result<IndentSize, E>
where
E: de::Error,
{
Ok(IndentSize::Fixed(value as usize))
}
fn visit_i64<E>(self, value: i64) -> Result<IndentSize, E>
where
E: de::Error,
{
if value > 0 {
Ok(IndentSize::Fixed(value as usize))
} else {
Err(de::Error::custom("indent_size must be positive"))
}
}
fn visit_str<E>(self, value: &str) -> Result<IndentSize, E>
where
E: de::Error,
{
if value.eq_ignore_ascii_case("auto") {
Ok(IndentSize::Auto)
} else {
Err(de::Error::custom(
"expected \"auto\" or a positive integer for indent_size",
))
}
}
}
deserializer.deserialize_any(IndentSizeVisitor)
}
}
impl JsonSchema for IndentSize {
fn schema_name() -> std::borrow::Cow<'static, str> {
"IndentSize".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
serde_json::from_value(serde_json::json!({
"description": "Indentation size: a positive integer or \"auto\" for auto-detection",
"default": "auto",
"oneOf": [
{ "type": "integer", "minimum": 1 },
{ "type": "string", "enum": ["auto"] }
]
}))
.unwrap()
}
}
pub const DEFAULT_CONFIG_TEMPLATE: &str = r#"# nginx-lint configuration file
# This file was generated by `nginx-lint config init`
# See https://github.com/walf443/nginx-lint for more documentation
# Target nginx version your config is deployed against (e.g. "1.31.0").
# When set, rules that don't apply to this version are automatically skipped.
# Per-rule `skip_version_check = true` forces a rule to run regardless.
# target_nginx_version = "1.31.0"
# Color output settings
[color]
# Color mode: "auto", "always", or "never"
ui = "auto"
# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
# bright_black, bright_red, bright_green, bright_yellow, bright_blue,
# bright_magenta, bright_cyan, bright_white)
error = "red"
warning = "yellow"
# =============================================================================
# Include Resolution Settings
# =============================================================================
[include]
# Base directory for resolving relative include paths (similar to nginx -p prefix).
# When set, all relative include paths are resolved from this directory
# instead of the directory containing the config file with the include directive.
# prefix = "/etc/nginx"
# Path mappings applied to include patterns before resolving them.
# Mappings are applied in declaration order, each receiving the output of the
# previous one (chained). Useful when the config references a directory that
# differs from where the actual files live (e.g. sites-enabled → sites-available).
#
# Example (for Debian nginx package):
# [[include.path_map]]
# from = "/etc/nginx/"
# to = ""
#
# [[include.path_map]]
# from = "sites-enabled"
# to = "sites-available"
#
# [[include.path_map]]
# from = "modules-enabled"
# to = "modules-available"
# =============================================================================
# Style Rules
# =============================================================================
[rules.indent]
enabled = true
# Indentation size: number or "auto" for auto-detection (default: "auto")
# indent_size = 4
indent_size = "auto"
[rules.trailing-whitespace]
enabled = true
[rules.space-before-semicolon]
enabled = true
[rules.block-lines]
enabled = true
# Maximum number of lines allowed in a block (default: 100)
# max_block_lines = 100
# =============================================================================
# Syntax Rules
# =============================================================================
[rules.duplicate-directive]
enabled = true
[rules.unmatched-braces]
enabled = true
[rules.unclosed-quote]
enabled = true
[rules.missing-semicolon]
enabled = true
[rules.invalid-directive-context]
enabled = true
# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
# Example for nginx-rtmp-module:
# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
[rules.include-path-exists]
enabled = true
# =============================================================================
# Security Rules
# =============================================================================
[rules.deprecated-ssl-protocol]
enabled = true
# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
allowed_protocols = ["TLSv1.2", "TLSv1.3"]
[rules.server-tokens-enabled]
enabled = true
[rules.autoindex-enabled]
enabled = true
[rules.weak-ssl-ciphers]
enabled = true
# Weak cipher patterns to detect
weak_ciphers = [
"NULL",
"EXPORT",
"DES",
"RC4",
"MD5",
"aNULL",
"eNULL",
"ADH",
"AECDH",
"PSK",
"SRP",
"CAMELLIA",
]
# Required exclusion patterns
required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
[rules.nginx-rift]
# CVE-2026-42945 / CVE-2026-9256: detects the rewrite-with-`?` +
# capture-consumer pattern that triggers a heap buffer overflow on
# nginx 0.6.27 .. 1.30.1 (CVE-2026-42945 fixed in 1.30.1 / 1.31.0; the
# redirect-path CVE-2026-9256 those releases left open is fixed in
# 1.30.2 / 1.31.1). The rule declares its applicable nginx version
# range, so setting `target_nginx_version >= 1.30.2` above disables it
# automatically. To run it anyway (e.g. on a mixed fleet), add
# `skip_version_check = true` here.
enabled = true
# skip_version_check = true
# =============================================================================
# Best Practices
# =============================================================================
[rules.gzip-not-enabled]
# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
enabled = false
[rules.missing-error-log]
# Disabled by default: error_log is typically set at top level in main config
enabled = false
[rules.proxy-pass-domain]
enabled = true
[rules.upstream-server-no-resolve]
enabled = true
[rules.directive-inheritance]
enabled = true
# Exclude specific directives from checking
# excluded_directives = ["grpc_set_header", "uwsgi_param"]
# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
# additional_directives = [
# { name = "proxy_set_cookie", case_insensitive = true },
# ]
[rules.root-in-location]
enabled = true
[rules.alias-location-slash-mismatch]
enabled = true
[rules.proxy-pass-with-uri]
enabled = true
[rules.proxy-keepalive]
enabled = true
[rules.try-files-with-proxy]
enabled = true
[rules.if-is-evil-in-location]
enabled = true
# =============================================================================
# Parser Settings
# =============================================================================
[parser]
# Additional block directives for extension modules
# These are added to the built-in list (http, server, location, etc.)
# Example for nginx-rtmp-module:
# block_directives = ["rtmp", "application"]
"#;
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct LintConfig {
#[serde(default)]
pub rules: HashMap<String, RuleConfig>,
#[serde(default)]
pub color: ColorConfig,
#[serde(default)]
pub parser: ParserConfig,
#[serde(default)]
pub include: IncludeConfig,
#[serde(default)]
pub target_nginx_version: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct ParserConfig {
#[serde(default)]
pub block_directives: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct PathMapping {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct IncludeConfig {
#[serde(default)]
pub path_map: Vec<PathMapping>,
pub prefix: Option<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ColorConfig {
#[serde(default)]
pub ui: ColorMode,
#[serde(default = "default_error_color")]
pub error: Color,
#[serde(default = "default_warning_color")]
pub warning: Color,
}
impl Default for ColorConfig {
fn default() -> Self {
Self {
ui: ColorMode::Auto,
error: Color::Red,
warning: Color::Yellow,
}
}
}
fn default_error_color() -> Color {
Color::Red
}
fn default_warning_color() -> Color {
Color::Yellow
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Color {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
#[default]
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
impl JsonSchema for Color {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Color".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
serde_json::from_value(serde_json::json!({
"type": "string",
"enum": [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"bright_black", "bright_red", "bright_green", "bright_yellow",
"bright_blue", "bright_magenta", "bright_cyan", "bright_white"
]
}))
.unwrap()
}
}
impl<'de> Deserialize<'de> for Color {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"black" => Ok(Color::Black),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"white" => Ok(Color::White),
"bright_black" | "brightblack" => Ok(Color::BrightBlack),
"bright_red" | "brightred" => Ok(Color::BrightRed),
"bright_green" | "brightgreen" => Ok(Color::BrightGreen),
"bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
"bright_blue" | "brightblue" => Ok(Color::BrightBlue),
"bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
"bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
"bright_white" | "brightwhite" => Ok(Color::BrightWhite),
_ => Err(D::Error::custom(format!(
"invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
s
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
impl JsonSchema for ColorMode {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ColorMode".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
serde_json::from_value(serde_json::json!({
"type": "string",
"description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
"default": "auto",
"enum": ["auto", "always", "never"]
}))
.unwrap()
}
}
impl<'de> Deserialize<'de> for ColorMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let s = String::deserialize(deserializer)?;
match s.as_str() {
"auto" => Ok(ColorMode::Auto),
"always" => Ok(ColorMode::Always),
"never" => Ok(ColorMode::Never),
_ => Err(D::Error::custom(format!(
"invalid color mode '{}', expected 'auto', 'always', or 'never'",
s
))),
}
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AdditionalDirective {
pub name: String,
#[serde(default)]
pub case_insensitive: bool,
#[serde(default)]
pub multi_key: bool,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct RuleConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub skip_version_check: bool,
pub indent_size: Option<IndentSize>,
pub allowed_protocols: Option<Vec<String>>,
pub weak_ciphers: Option<Vec<String>>,
pub required_exclusions: Option<Vec<String>>,
pub additional_contexts: Option<HashMap<String, Vec<String>>>,
pub max_block_lines: Option<usize>,
pub excluded_directives: Option<Vec<String>>,
pub additional_directives: Option<Vec<AdditionalDirective>>,
}
fn default_true() -> bool {
true
}
impl LintConfig {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
path: path.to_path_buf(),
source: e,
})?;
toml::from_str(&content).map_err(|e| ConfigError::ParseError {
path: path.to_path_buf(),
source: e,
})
}
pub fn parse(content: &str) -> Result<Self, String> {
toml::from_str(content).map_err(|e| e.to_string())
}
pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
let mut current = dir.to_path_buf();
loop {
let config_path = current.join(".nginx-lint.toml");
if config_path.exists() {
return Self::from_file(&config_path)
.ok()
.map(|cfg| (cfg, config_path));
}
if !current.pop() {
break;
}
}
None
}
pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
"gzip-not-enabled", "missing-error-log", ];
pub const NATIVE_RULE_NAMES: &'static [&'static str] = &[
"unmatched-braces",
"unclosed-quote",
"missing-semicolon",
"indent",
"include-path-exists",
];
pub const KNOWN_RULE_NAMES: &'static [&'static str] = &[
"unmatched-braces",
"unclosed-quote",
"missing-semicolon",
"indent",
"include-path-exists",
"server-tokens-enabled",
"autoindex-enabled",
"gzip-not-enabled",
"duplicate-directive",
"space-before-semicolon",
"trailing-whitespace",
"block-lines",
"proxy-pass-domain",
"upstream-server-no-resolve",
"directive-inheritance",
"root-in-location",
"alias-location-slash-mismatch",
"proxy-pass-with-uri",
"proxy-keepalive",
"try-files-with-proxy",
"if-is-evil-in-location",
"unreachable-location",
"missing-error-log",
"deprecated-ssl-protocol",
"weak-ssl-ciphers",
"invalid-directive-context",
"map-missing-default",
"ssl-on-deprecated",
"listen-http2-deprecated",
"proxy-missing-host-header",
"client-max-body-size-not-set",
"nginx-rift",
];
pub fn is_rule_enabled(&self, name: &str) -> bool {
self.rules
.get(name)
.map(|r| r.enabled)
.unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
}
pub fn rule_explicitly_configured(&self, name: &str) -> bool {
self.rules.contains_key(name)
}
pub fn rule_skip_version_check(&self, name: &str) -> bool {
self.rules
.get(name)
.map(|r| r.skip_version_check)
.unwrap_or(false)
}
pub fn target_nginx_version(&self) -> Option<&str> {
self.target_nginx_version.as_deref()
}
pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
self.rules.get(name)
}
pub fn color_mode(&self) -> ColorMode {
self.color.ui
}
pub fn additional_block_directives(&self) -> &[String] {
&self.parser.block_directives
}
pub fn include_path_mappings(&self) -> &[PathMapping] {
&self.include.path_map
}
pub fn json_schema() -> serde_json::Value {
let generator = schemars::SchemaGenerator::default();
let schema = generator.into_root_schema_for::<LintConfig>();
serde_json::to_value(schema).unwrap()
}
pub fn include_prefix(&self) -> Option<&str> {
self.include.prefix.as_deref()
}
pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
self.rules
.get("invalid-directive-context")
.and_then(|r| r.additional_contexts.as_ref())
}
pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
self.rules
.get("directive-inheritance")
.and_then(|r| r.excluded_directives.as_deref())
}
pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
self.rules
.get("directive-inheritance")
.and_then(|r| r.additional_directives.as_deref())
}
pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
path: path.to_path_buf(),
source: e,
})?;
Self::validate_content(&content, path)
}
fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
path: path.to_path_buf(),
source: e,
})?;
let mut errors = Vec::new();
if let toml::Value::Table(root) = value {
let known_top_level: HashSet<&str> = [
"rules",
"color",
"parser",
"include",
"target_nginx_version",
]
.into_iter()
.collect();
for key in root.keys() {
if !known_top_level.contains(key.as_str()) {
let line = find_key_line(content, None, key);
errors.push(ValidationError::UnknownField {
path: key.clone(),
line,
suggestion: suggest_field(key, &known_top_level),
});
}
}
if let Some(toml::Value::Table(color)) = root.get("color") {
let known_color_keys: HashSet<&str> =
["ui", "error", "warning"].into_iter().collect();
for key in color.keys() {
if !known_color_keys.contains(key.as_str()) {
let line = find_key_line(content, Some("color"), key);
errors.push(ValidationError::UnknownField {
path: format!("color.{}", key),
line,
suggestion: suggest_field(key, &known_color_keys),
});
}
}
}
if let Some(toml::Value::Table(parser)) = root.get("parser") {
let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
for key in parser.keys() {
if !known_parser_keys.contains(key.as_str()) {
let line = find_key_line(content, Some("parser"), key);
errors.push(ValidationError::UnknownField {
path: format!("parser.{}", key),
line,
suggestion: suggest_field(key, &known_parser_keys),
});
}
}
}
if let Some(toml::Value::Table(include)) = root.get("include") {
let known_include_keys: HashSet<&str> =
["path_map", "prefix"].into_iter().collect();
for key in include.keys() {
if !known_include_keys.contains(key.as_str()) {
let line = find_key_line(content, Some("include"), key);
errors.push(ValidationError::UnknownField {
path: format!("include.{}", key),
line,
suggestion: suggest_field(key, &known_include_keys),
});
}
}
}
if let Some(toml::Value::Table(rules)) = root.get("rules") {
let known_rules: HashSet<&str> = Self::KNOWN_RULE_NAMES.iter().copied().collect();
for (rule_name, rule_value) in rules {
if !known_rules.contains(rule_name.as_str()) {
let line = find_key_line(content, Some("rules"), rule_name);
errors.push(ValidationError::UnknownRule {
name: rule_name.clone(),
line,
suggestion: suggest_field(rule_name, &known_rules),
});
continue;
}
if let toml::Value::Table(rule_config) = rule_value {
let known_rule_options = get_known_rule_options(rule_name);
let section = format!("rules.{}", rule_name);
for key in rule_config.keys() {
if !known_rule_options.contains(key.as_str()) {
let line = find_key_line(content, Some(§ion), key);
errors.push(ValidationError::UnknownRuleOption {
rule: rule_name.clone(),
option: key.clone(),
line,
suggestion: suggest_field(key, &known_rule_options),
});
}
}
}
}
}
}
Ok(errors)
}
}
fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
let lines: Vec<&str> = content.lines().collect();
if section.is_none() {
let section_header = format!("[{}]", key);
for (i, line) in lines.iter().enumerate() {
if line.trim() == section_header {
return Some(i + 1);
}
}
return None;
}
let target_section = section.unwrap();
let mut in_section = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section_name = &trimmed[1..trimmed.len() - 1];
let full_section = format!("{}.{}", target_section, key);
if section_name == full_section {
return Some(i + 1);
}
in_section = section_name == target_section
|| section_name.starts_with(&format!("{}.", target_section));
continue;
}
if in_section && let Some((k, _)) = trimmed.split_once('=') {
let k = k.trim();
if k == key {
return Some(i + 1);
}
}
}
None
}
fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
let mut options: HashSet<&str> = ["enabled", "skip_version_check"].into_iter().collect();
match rule_name {
"indent" => {
options.insert("indent_size");
}
"deprecated-ssl-protocol" => {
options.insert("allowed_protocols");
}
"weak-ssl-ciphers" => {
options.insert("weak_ciphers");
options.insert("required_exclusions");
}
"block-lines" => {
options.insert("max_block_lines");
}
"directive-inheritance" => {
options.insert("excluded_directives");
options.insert("additional_directives");
}
_ => {}
}
options
}
fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
let input_lower = input.to_lowercase();
known
.iter()
.filter(|&&k| {
let k_lower = k.to_lowercase();
k_lower.contains(&input_lower)
|| input_lower.contains(&k_lower)
|| levenshtein_distance(&input_lower, &k_lower) <= 2
})
.min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
.map(|&s| s.to_string())
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let a_len = a_chars.len();
let b_len = b_chars.len();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
row[0] = i;
}
for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
*cell = j;
}
for i in 1..=a_len {
for j in 1..=b_len {
let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
matrix[a_len][b_len]
}
#[derive(Debug, Clone)]
pub enum ValidationError {
UnknownField {
path: String,
line: Option<usize>,
suggestion: Option<String>,
},
UnknownRule {
name: String,
line: Option<usize>,
suggestion: Option<String>,
},
UnknownRuleOption {
rule: String,
option: String,
line: Option<usize>,
suggestion: Option<String>,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::UnknownField {
path,
line,
suggestion,
} => {
if let Some(l) = line {
write!(f, "line {}: ", l)?;
}
write!(f, "unknown field '{}'", path)?;
if let Some(s) = suggestion {
write!(f, ", did you mean '{}'?", s)?;
}
Ok(())
}
ValidationError::UnknownRule {
name,
line,
suggestion,
} => {
if let Some(l) = line {
write!(f, "line {}: ", l)?;
}
write!(f, "unknown rule '{}'", name)?;
if let Some(s) = suggestion {
write!(f, ", did you mean '{}'?", s)?;
}
Ok(())
}
ValidationError::UnknownRuleOption {
rule,
option,
line,
suggestion,
} => {
if let Some(l) = line {
write!(f, "line {}: ", l)?;
}
write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
if let Some(s) = suggestion {
write!(f, ", did you mean '{}'?", s)?;
}
Ok(())
}
}
}
}
#[derive(Debug)]
pub enum ConfigError {
IoError {
path: std::path::PathBuf,
source: std::io::Error,
},
ParseError {
path: std::path::PathBuf,
source: toml::de::Error,
},
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::IoError { path, source } => {
write!(
f,
"Failed to read config file '{}': {}",
path.display(),
source
)
}
ConfigError::ParseError { path, source } => {
write!(
f,
"Failed to parse config file '{}': {}",
path.display(),
source
)
}
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::IoError { source, .. } => Some(source),
ConfigError::ParseError { source, .. } => Some(source),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = LintConfig::default();
assert!(config.is_rule_enabled("any-rule"));
}
#[test]
fn test_disabled_by_default_rules() {
let config = LintConfig::default();
assert!(!config.is_rule_enabled("gzip-not-enabled"));
assert!(!config.is_rule_enabled("missing-error-log"));
assert!(config.is_rule_enabled("server-tokens-enabled"));
}
#[test]
fn test_parse_config() {
let toml_content = r#"
[rules.indent]
enabled = true
indent_size = 2
[rules.server-tokens-enabled]
enabled = false
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert!(config.is_rule_enabled("indent"));
assert!(!config.is_rule_enabled("server-tokens-enabled"));
assert!(config.is_rule_enabled("unknown-rule"));
let indent_config = config.get_rule_config("indent").unwrap();
assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
}
#[test]
fn test_empty_config() {
let toml_content = "";
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert!(config.is_rule_enabled("any-rule"));
}
#[test]
fn test_indent_size_auto() {
let toml_content = r#"
[rules.indent]
enabled = true
indent_size = "auto"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
let indent_config = config.get_rule_config("indent").unwrap();
assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
}
#[test]
fn test_color_config_default() {
let config = LintConfig::default();
assert_eq!(config.color_mode(), ColorMode::Auto);
}
#[test]
fn test_color_config_auto() {
let toml_content = r#"
[color]
ui = "auto"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert_eq!(config.color_mode(), ColorMode::Auto);
}
#[test]
fn test_color_config_never() {
let toml_content = r#"
[color]
ui = "never"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert_eq!(config.color_mode(), ColorMode::Never);
}
#[test]
fn test_color_config_always() {
let toml_content = r#"
[color]
ui = "always"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert_eq!(config.color_mode(), ColorMode::Always);
}
#[test]
fn test_color_config_default_colors() {
let config = LintConfig::default();
assert_eq!(config.color.error, Color::Red);
assert_eq!(config.color.warning, Color::Yellow);
}
#[test]
fn test_color_config_custom_colors() {
let toml_content = r#"
[color]
error = "magenta"
warning = "cyan"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert_eq!(config.color.error, Color::Magenta);
assert_eq!(config.color.warning, Color::Cyan);
}
#[test]
fn test_color_config_bright_colors() {
let toml_content = r#"
[color]
error = "bright_red"
warning = "bright_yellow"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert_eq!(config.color.error, Color::BrightRed);
assert_eq!(config.color.warning, Color::BrightYellow);
}
#[test]
fn test_block_lines_max_block_lines_parsing() {
let toml_content = r#"
[rules.block-lines]
enabled = true
max_block_lines = 50
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
assert!(config.is_rule_enabled("block-lines"));
let rule_config = config.get_rule_config("block-lines").unwrap();
assert_eq!(rule_config.max_block_lines, Some(50));
}
#[test]
fn test_block_lines_default_no_max() {
let toml_content = r#"
[rules.block-lines]
enabled = true
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let config = LintConfig::from_file(file.path()).unwrap();
let rule_config = config.get_rule_config("block-lines").unwrap();
assert_eq!(rule_config.max_block_lines, None);
}
#[test]
fn test_block_lines_validation_rejects_unknown_option() {
let toml_content = r#"
[rules.block-lines]
enabled = true
unknown_option = 42
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert_eq!(errors.len(), 1);
match &errors[0] {
ValidationError::UnknownRuleOption { rule, option, .. } => {
assert_eq!(rule, "block-lines");
assert_eq!(option, "unknown_option");
}
other => panic!("expected UnknownRuleOption, got: {:?}", other),
}
}
#[test]
fn test_include_path_map_empty_by_default() {
let config = LintConfig::default();
assert!(config.include_path_mappings().is_empty());
}
#[test]
fn test_include_path_map_single_entry() {
let toml_content = r#"
[[include.path_map]]
from = "sites-enabled"
to = "sites-available"
"#;
let config = LintConfig::parse(toml_content).unwrap();
let mappings = config.include_path_mappings();
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].from, "sites-enabled");
assert_eq!(mappings[0].to, "sites-available");
}
#[test]
fn test_include_path_map_multiple_entries_preserve_order() {
let toml_content = r#"
[[include.path_map]]
from = "sites-enabled"
to = "sites-available"
[[include.path_map]]
from = "/etc/nginx"
to = "/usr/local/nginx"
"#;
let config = LintConfig::parse(toml_content).unwrap();
let mappings = config.include_path_mappings();
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].from, "sites-enabled");
assert_eq!(mappings[0].to, "sites-available");
assert_eq!(mappings[1].from, "/etc/nginx");
assert_eq!(mappings[1].to, "/usr/local/nginx");
}
#[test]
fn test_include_validation_rejects_unknown_field() {
let toml_content = r#"
[include]
unknown_key = "value"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert_eq!(errors.len(), 1);
match &errors[0] {
ValidationError::UnknownField { path, .. } => {
assert_eq!(path, "include.unknown_key");
}
other => panic!("expected UnknownField, got: {:?}", other),
}
}
#[test]
fn test_include_prefix_none_by_default() {
let config = LintConfig::default();
assert!(config.include_prefix().is_none());
}
#[test]
fn test_include_prefix_parsed() {
let toml_content = r#"
[include]
prefix = "/etc/nginx"
"#;
let config = LintConfig::parse(toml_content).unwrap();
assert_eq!(config.include_prefix(), Some("/etc/nginx"));
}
#[test]
fn test_include_prefix_with_path_map() {
let toml_content = r#"
[include]
prefix = "."
[[include.path_map]]
from = "sites-enabled"
to = "sites-available"
"#;
let config = LintConfig::parse(toml_content).unwrap();
assert_eq!(config.include_prefix(), Some("."));
assert_eq!(config.include_path_mappings().len(), 1);
}
#[test]
fn test_include_prefix_validation_accepted() {
let toml_content = r#"
[include]
prefix = "/etc/nginx"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert!(
errors.is_empty(),
"prefix should be a valid include field, got errors: {:?}",
errors
);
}
#[test]
fn test_json_schema_is_valid() {
let schema = LintConfig::json_schema();
assert_eq!(
schema.get("$schema").and_then(|v| v.as_str()),
Some("https://json-schema.org/draft/2020-12/schema")
);
let props = schema.get("properties").unwrap().as_object().unwrap();
assert!(props.contains_key("rules"), "missing 'rules' property");
assert!(props.contains_key("color"), "missing 'color' property");
assert!(props.contains_key("parser"), "missing 'parser' property");
assert!(props.contains_key("include"), "missing 'include' property");
}
#[test]
fn test_validate_accepts_previously_drifted_builtin_plugins() {
let previously_missing = [
"client-max-body-size-not-set",
"listen-http2-deprecated",
"map-missing-default",
"proxy-missing-host-header",
"ssl-on-deprecated",
"unreachable-location",
];
for rule_name in previously_missing {
let toml_content = format!("[rules.{rule_name}]\nenabled = false\n");
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert!(
errors.is_empty(),
"rule '{rule_name}' should be a known builtin plugin name, \
but `validate_file` reported errors: {errors:?}"
);
}
}
#[test]
fn test_known_rules_constant_drives_validator() {
for rule_name in LintConfig::KNOWN_RULE_NAMES {
let toml_content = format!("[rules.{rule_name}]\nenabled = true\n");
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert!(
errors.is_empty(),
"rule '{rule_name}' is listed in KNOWN_RULE_NAMES but the validator \
rejected it: {errors:?}"
);
}
}
#[test]
fn test_native_rule_names_subset_of_known_rules() {
let known: HashSet<&str> = LintConfig::KNOWN_RULE_NAMES.iter().copied().collect();
let missing: Vec<&str> = LintConfig::NATIVE_RULE_NAMES
.iter()
.copied()
.filter(|name| !known.contains(name))
.collect();
assert!(
missing.is_empty(),
"NATIVE_RULE_NAMES entries missing from KNOWN_RULE_NAMES: {missing:?}"
);
}
#[test]
fn test_validate_rejects_unknown_rule_name() {
let toml_content = "[rules.no-such-rule-zzz]\nenabled = true\n";
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert_eq!(
errors.len(),
1,
"expected exactly one error, got: {errors:?}"
);
match &errors[0] {
ValidationError::UnknownRule { name, .. } => {
assert_eq!(name, "no-such-rule-zzz");
}
other => panic!("expected UnknownRule, got: {other:?}"),
}
}
#[test]
fn test_known_rules_has_no_duplicates() {
let mut seen: HashSet<&str> = HashSet::new();
for name in LintConfig::KNOWN_RULE_NAMES {
assert!(
seen.insert(name),
"duplicate entry in KNOWN_RULE_NAMES: '{name}'"
);
}
}
#[test]
fn test_json_schema_rule_config_has_all_fields() {
let schema = LintConfig::json_schema();
let rule_config_def = schema
.pointer("/$defs/RuleConfig")
.expect("RuleConfig definition missing from schema");
let props = rule_config_def
.get("properties")
.unwrap()
.as_object()
.unwrap();
let expected_fields = [
"enabled",
"skip_version_check",
"indent_size",
"allowed_protocols",
"weak_ciphers",
"required_exclusions",
"additional_contexts",
"max_block_lines",
"excluded_directives",
"additional_directives",
];
for field in &expected_fields {
assert!(
props.contains_key(*field),
"RuleConfig schema missing field '{field}'"
);
}
}
#[test]
fn test_target_nginx_version_parsed() {
let toml_content = r#"
target_nginx_version = "1.31.0"
"#;
let config = LintConfig::parse(toml_content).unwrap();
assert_eq!(config.target_nginx_version(), Some("1.31.0"));
}
#[test]
fn test_target_nginx_version_default_none() {
let config = LintConfig::default();
assert!(config.target_nginx_version().is_none());
}
#[test]
fn test_skip_version_check_per_rule() {
let toml_content = r#"
[rules.nginx-rift]
enabled = true
skip_version_check = true
"#;
let config = LintConfig::parse(toml_content).unwrap();
assert!(config.rule_skip_version_check("nginx-rift"));
assert!(!config.rule_skip_version_check("server-tokens-enabled"));
}
#[test]
fn test_rule_explicitly_configured() {
let toml_content = r#"
[rules.indent]
enabled = true
"#;
let config = LintConfig::parse(toml_content).unwrap();
assert!(config.rule_explicitly_configured("indent"));
assert!(!config.rule_explicitly_configured("server-tokens-enabled"));
}
#[test]
fn test_validator_accepts_target_nginx_version() {
let toml_content = r#"
target_nginx_version = "1.31.0"
"#;
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert!(
errors.is_empty(),
"target_nginx_version should be a valid top-level field, got: {errors:?}"
);
}
#[test]
fn test_validator_accepts_skip_version_check() {
for rule_name in LintConfig::KNOWN_RULE_NAMES {
let toml_content =
format!("[rules.{rule_name}]\nenabled = true\nskip_version_check = true\n");
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", toml_content).unwrap();
let errors = LintConfig::validate_file(file.path()).unwrap();
assert!(
errors.is_empty(),
"skip_version_check should be valid for rule '{rule_name}', got: {errors:?}"
);
}
}
}