1use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer};
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum IndentSize {
18 #[default]
20 Auto,
21 Fixed(usize),
23}
24
25impl fmt::Display for IndentSize {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 IndentSize::Auto => write!(f, "auto"),
29 IndentSize::Fixed(n) => write!(f, "{}", n),
30 }
31 }
32}
33
34impl<'de> Deserialize<'de> for IndentSize {
35 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36 where
37 D: Deserializer<'de>,
38 {
39 use serde::de::{self, Visitor};
40
41 struct IndentSizeVisitor;
42
43 impl<'de> Visitor<'de> for IndentSizeVisitor {
44 type Value = IndentSize;
45
46 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
47 formatter.write_str("a positive integer or \"auto\"")
48 }
49
50 fn visit_u64<E>(self, value: u64) -> Result<IndentSize, E>
51 where
52 E: de::Error,
53 {
54 Ok(IndentSize::Fixed(value as usize))
55 }
56
57 fn visit_i64<E>(self, value: i64) -> Result<IndentSize, E>
58 where
59 E: de::Error,
60 {
61 if value > 0 {
62 Ok(IndentSize::Fixed(value as usize))
63 } else {
64 Err(de::Error::custom("indent_size must be positive"))
65 }
66 }
67
68 fn visit_str<E>(self, value: &str) -> Result<IndentSize, E>
69 where
70 E: de::Error,
71 {
72 if value.eq_ignore_ascii_case("auto") {
73 Ok(IndentSize::Auto)
74 } else {
75 Err(de::Error::custom(
76 "expected \"auto\" or a positive integer for indent_size",
77 ))
78 }
79 }
80 }
81
82 deserializer.deserialize_any(IndentSizeVisitor)
83 }
84}
85
86impl JsonSchema for IndentSize {
87 fn schema_name() -> std::borrow::Cow<'static, str> {
88 "IndentSize".into()
89 }
90
91 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
92 serde_json::from_value(serde_json::json!({
93 "description": "Indentation size: a positive integer or \"auto\" for auto-detection",
94 "default": "auto",
95 "oneOf": [
96 { "type": "integer", "minimum": 1 },
97 { "type": "string", "enum": ["auto"] }
98 ]
99 }))
100 .unwrap()
101 }
102}
103
104pub const DEFAULT_CONFIG_TEMPLATE: &str = r#"# nginx-lint configuration file
106# This file was generated by `nginx-lint config init`
107# See https://github.com/walf443/nginx-lint for more documentation
108
109# Color output settings
110[color]
111# Color mode: "auto", "always", or "never"
112ui = "auto"
113# Severity colors (available: black, red, green, yellow, blue, magenta, cyan, white,
114# bright_black, bright_red, bright_green, bright_yellow, bright_blue,
115# bright_magenta, bright_cyan, bright_white)
116error = "red"
117warning = "yellow"
118
119# =============================================================================
120# Include Resolution Settings
121# =============================================================================
122[include]
123
124# Base directory for resolving relative include paths (similar to nginx -p prefix).
125# When set, all relative include paths are resolved from this directory
126# instead of the directory containing the config file with the include directive.
127# prefix = "/etc/nginx"
128
129# Path mappings applied to include patterns before resolving them.
130# Mappings are applied in declaration order, each receiving the output of the
131# previous one (chained). Useful when the config references a directory that
132# differs from where the actual files live (e.g. sites-enabled → sites-available).
133#
134# Example (for Debian nginx package):
135
136# [[include.path_map]]
137# from = "/etc/nginx/"
138# to = ""
139#
140# [[include.path_map]]
141# from = "sites-enabled"
142# to = "sites-available"
143#
144# [[include.path_map]]
145# from = "modules-enabled"
146# to = "modules-available"
147
148# =============================================================================
149# Style Rules
150# =============================================================================
151
152[rules.indent]
153enabled = true
154# Indentation size: number or "auto" for auto-detection (default: "auto")
155# indent_size = 4
156indent_size = "auto"
157
158[rules.trailing-whitespace]
159enabled = true
160
161[rules.space-before-semicolon]
162enabled = true
163
164[rules.block-lines]
165enabled = true
166# Maximum number of lines allowed in a block (default: 100)
167# max_block_lines = 100
168
169# =============================================================================
170# Syntax Rules
171# =============================================================================
172
173[rules.duplicate-directive]
174enabled = true
175
176[rules.unmatched-braces]
177enabled = true
178
179[rules.unclosed-quote]
180enabled = true
181
182[rules.missing-semicolon]
183enabled = true
184
185[rules.invalid-directive-context]
186enabled = true
187# Additional valid parent contexts for directives (for extension modules like nginx-rtmp-module)
188# Example for nginx-rtmp-module:
189# additional_contexts = { server = ["rtmp"], upstream = ["rtmp"] }
190
191[rules.include-path-exists]
192enabled = true
193
194# =============================================================================
195# Security Rules
196# =============================================================================
197
198[rules.deprecated-ssl-protocol]
199enabled = true
200# Allowed protocols for auto-fix (default: ["TLSv1.2", "TLSv1.3"])
201allowed_protocols = ["TLSv1.2", "TLSv1.3"]
202
203[rules.server-tokens-enabled]
204enabled = true
205
206[rules.autoindex-enabled]
207enabled = true
208
209[rules.weak-ssl-ciphers]
210enabled = true
211# Weak cipher patterns to detect
212weak_ciphers = [
213 "NULL",
214 "EXPORT",
215 "DES",
216 "RC4",
217 "MD5",
218 "aNULL",
219 "eNULL",
220 "ADH",
221 "AECDH",
222 "PSK",
223 "SRP",
224 "CAMELLIA",
225]
226# Required exclusion patterns
227required_exclusions = ["!aNULL", "!eNULL", "!EXPORT", "!DES", "!RC4", "!MD5"]
228
229# =============================================================================
230# Best Practices
231# =============================================================================
232
233[rules.gzip-not-enabled]
234# Disabled by default: gzip is not always appropriate (CDN, CPU constraints, BREACH attack)
235enabled = false
236
237[rules.missing-error-log]
238# Disabled by default: error_log is typically set at top level in main config
239enabled = false
240
241[rules.proxy-pass-domain]
242enabled = true
243
244[rules.upstream-server-no-resolve]
245enabled = true
246
247[rules.directive-inheritance]
248enabled = true
249# Exclude specific directives from checking
250# excluded_directives = ["grpc_set_header", "uwsgi_param"]
251# Add custom directives to check (name is required, case_insensitive and multi_key default to false)
252# additional_directives = [
253# { name = "proxy_set_cookie", case_insensitive = true },
254# ]
255
256[rules.root-in-location]
257enabled = true
258
259[rules.alias-location-slash-mismatch]
260enabled = true
261
262[rules.proxy-pass-with-uri]
263enabled = true
264
265[rules.proxy-keepalive]
266enabled = true
267
268[rules.try-files-with-proxy]
269enabled = true
270
271[rules.if-is-evil-in-location]
272enabled = true
273
274# =============================================================================
275# Parser Settings
276# =============================================================================
277
278[parser]
279# Additional block directives for extension modules
280# These are added to the built-in list (http, server, location, etc.)
281# Example for nginx-rtmp-module:
282# block_directives = ["rtmp", "application"]
283"#;
284
285#[derive(Debug, Default, Deserialize, JsonSchema)]
292pub struct LintConfig {
293 #[serde(default)]
295 pub rules: HashMap<String, RuleConfig>,
296 #[serde(default)]
298 pub color: ColorConfig,
299 #[serde(default)]
301 pub parser: ParserConfig,
302 #[serde(default)]
304 pub include: IncludeConfig,
305}
306
307#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
309pub struct ParserConfig {
310 #[serde(default)]
313 pub block_directives: Vec<String>,
314}
315
316#[derive(Debug, Clone, Deserialize, JsonSchema)]
333pub struct PathMapping {
334 pub from: String,
336 pub to: String,
338}
339
340#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
342pub struct IncludeConfig {
343 #[serde(default)]
346 pub path_map: Vec<PathMapping>,
347 pub prefix: Option<String>,
351}
352
353#[derive(Debug, Clone, Deserialize, JsonSchema)]
355pub struct ColorConfig {
356 #[serde(default)]
358 pub ui: ColorMode,
359 #[serde(default = "default_error_color")]
361 pub error: Color,
362 #[serde(default = "default_warning_color")]
364 pub warning: Color,
365}
366
367impl Default for ColorConfig {
368 fn default() -> Self {
369 Self {
370 ui: ColorMode::Auto,
371 error: Color::Red,
372 warning: Color::Yellow,
373 }
374 }
375}
376
377fn default_error_color() -> Color {
378 Color::Red
379}
380
381fn default_warning_color() -> Color {
382 Color::Yellow
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
387pub enum Color {
388 Black,
389 Red,
390 Green,
391 Yellow,
392 Blue,
393 Magenta,
394 Cyan,
395 #[default]
396 White,
397 BrightBlack,
398 BrightRed,
399 BrightGreen,
400 BrightYellow,
401 BrightBlue,
402 BrightMagenta,
403 BrightCyan,
404 BrightWhite,
405}
406
407impl JsonSchema for Color {
408 fn schema_name() -> std::borrow::Cow<'static, str> {
409 "Color".into()
410 }
411
412 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
413 serde_json::from_value(serde_json::json!({
414 "type": "string",
415 "enum": [
416 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
417 "bright_black", "bright_red", "bright_green", "bright_yellow",
418 "bright_blue", "bright_magenta", "bright_cyan", "bright_white"
419 ]
420 }))
421 .unwrap()
422 }
423}
424
425impl<'de> Deserialize<'de> for Color {
426 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427 where
428 D: serde::Deserializer<'de>,
429 {
430 use serde::de::Error;
431
432 let s = String::deserialize(deserializer)?;
433 match s.to_lowercase().as_str() {
434 "black" => Ok(Color::Black),
435 "red" => Ok(Color::Red),
436 "green" => Ok(Color::Green),
437 "yellow" => Ok(Color::Yellow),
438 "blue" => Ok(Color::Blue),
439 "magenta" => Ok(Color::Magenta),
440 "cyan" => Ok(Color::Cyan),
441 "white" => Ok(Color::White),
442 "bright_black" | "brightblack" => Ok(Color::BrightBlack),
443 "bright_red" | "brightred" => Ok(Color::BrightRed),
444 "bright_green" | "brightgreen" => Ok(Color::BrightGreen),
445 "bright_yellow" | "brightyellow" => Ok(Color::BrightYellow),
446 "bright_blue" | "brightblue" => Ok(Color::BrightBlue),
447 "bright_magenta" | "brightmagenta" => Ok(Color::BrightMagenta),
448 "bright_cyan" | "brightcyan" => Ok(Color::BrightCyan),
449 "bright_white" | "brightwhite" => Ok(Color::BrightWhite),
450 _ => Err(D::Error::custom(format!(
451 "invalid color '{}', expected one of: black, red, green, yellow, blue, magenta, cyan, white, \
452 bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white",
453 s
454 ))),
455 }
456 }
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
461pub enum ColorMode {
462 #[default]
464 Auto,
465 Always,
467 Never,
469}
470
471impl JsonSchema for ColorMode {
472 fn schema_name() -> std::borrow::Cow<'static, str> {
473 "ColorMode".into()
474 }
475
476 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
477 serde_json::from_value(serde_json::json!({
478 "type": "string",
479 "description": "Color mode: \"auto\" respects NO_COLOR env and terminal detection, \"always\" forces colors, \"never\" disables colors",
480 "default": "auto",
481 "enum": ["auto", "always", "never"]
482 }))
483 .unwrap()
484 }
485}
486
487impl<'de> Deserialize<'de> for ColorMode {
488 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
489 where
490 D: serde::Deserializer<'de>,
491 {
492 use serde::de::Error;
493
494 let s = String::deserialize(deserializer)?;
495 match s.as_str() {
496 "auto" => Ok(ColorMode::Auto),
497 "always" => Ok(ColorMode::Always),
498 "never" => Ok(ColorMode::Never),
499 _ => Err(D::Error::custom(format!(
500 "invalid color mode '{}', expected 'auto', 'always', or 'never'",
501 s
502 ))),
503 }
504 }
505}
506
507#[derive(Debug, Clone, Deserialize, JsonSchema)]
511pub struct AdditionalDirective {
512 pub name: String,
514 #[serde(default)]
516 pub case_insensitive: bool,
517 #[serde(default)]
519 pub multi_key: bool,
520}
521
522#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
528pub struct RuleConfig {
529 #[serde(default = "default_true")]
531 pub enabled: bool,
532 pub indent_size: Option<IndentSize>,
534 pub allowed_protocols: Option<Vec<String>>,
536 pub weak_ciphers: Option<Vec<String>>,
538 pub required_exclusions: Option<Vec<String>>,
540 pub additional_contexts: Option<HashMap<String, Vec<String>>>,
543 pub max_block_lines: Option<usize>,
545 pub excluded_directives: Option<Vec<String>>,
547 pub additional_directives: Option<Vec<AdditionalDirective>>,
549}
550
551fn default_true() -> bool {
552 true
553}
554
555impl LintConfig {
556 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
558 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
559 path: path.to_path_buf(),
560 source: e,
561 })?;
562
563 toml::from_str(&content).map_err(|e| ConfigError::ParseError {
564 path: path.to_path_buf(),
565 source: e,
566 })
567 }
568
569 pub fn parse(content: &str) -> Result<Self, String> {
571 toml::from_str(content).map_err(|e| e.to_string())
572 }
573
574 pub fn find_and_load(dir: &Path) -> Option<(Self, std::path::PathBuf)> {
578 let mut current = dir.to_path_buf();
579
580 loop {
581 let config_path = current.join(".nginx-lint.toml");
582 if config_path.exists() {
583 return Self::from_file(&config_path)
584 .ok()
585 .map(|cfg| (cfg, config_path));
586 }
587
588 if !current.pop() {
589 break;
590 }
591 }
592
593 None
594 }
595
596 pub const DISABLED_BY_DEFAULT: &'static [&'static str] = &[
598 "gzip-not-enabled", "missing-error-log", ];
601
602 pub fn is_rule_enabled(&self, name: &str) -> bool {
604 self.rules
605 .get(name)
606 .map(|r| r.enabled)
607 .unwrap_or_else(|| !Self::DISABLED_BY_DEFAULT.contains(&name))
608 }
609
610 pub fn get_rule_config(&self, name: &str) -> Option<&RuleConfig> {
612 self.rules.get(name)
613 }
614
615 pub fn color_mode(&self) -> ColorMode {
617 self.color.ui
618 }
619
620 pub fn additional_block_directives(&self) -> &[String] {
622 &self.parser.block_directives
623 }
624
625 pub fn include_path_mappings(&self) -> &[PathMapping] {
627 &self.include.path_map
628 }
629
630 pub fn json_schema() -> serde_json::Value {
635 let generator = schemars::SchemaGenerator::default();
636 let schema = generator.into_root_schema_for::<LintConfig>();
637 serde_json::to_value(schema).unwrap()
638 }
639
640 pub fn include_prefix(&self) -> Option<&str> {
642 self.include.prefix.as_deref()
643 }
644
645 pub fn additional_contexts(&self) -> Option<&HashMap<String, Vec<String>>> {
647 self.rules
648 .get("invalid-directive-context")
649 .and_then(|r| r.additional_contexts.as_ref())
650 }
651
652 pub fn directive_inheritance_excluded(&self) -> Option<&[String]> {
654 self.rules
655 .get("directive-inheritance")
656 .and_then(|r| r.excluded_directives.as_deref())
657 }
658
659 pub fn directive_inheritance_additional(&self) -> Option<&[AdditionalDirective]> {
661 self.rules
662 .get("directive-inheritance")
663 .and_then(|r| r.additional_directives.as_deref())
664 }
665
666 pub fn validate_file(path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
668 let content = fs::read_to_string(path).map_err(|e| ConfigError::IoError {
669 path: path.to_path_buf(),
670 source: e,
671 })?;
672
673 Self::validate_content(&content, path)
674 }
675
676 fn validate_content(content: &str, path: &Path) -> Result<Vec<ValidationError>, ConfigError> {
678 let value: toml::Value = toml::from_str(content).map_err(|e| ConfigError::ParseError {
679 path: path.to_path_buf(),
680 source: e,
681 })?;
682
683 let mut errors = Vec::new();
684
685 if let toml::Value::Table(root) = value {
686 let known_top_level: HashSet<&str> = ["rules", "color", "parser", "include"]
688 .into_iter()
689 .collect();
690
691 for key in root.keys() {
692 if !known_top_level.contains(key.as_str()) {
693 let line = find_key_line(content, None, key);
694 errors.push(ValidationError::UnknownField {
695 path: key.clone(),
696 line,
697 suggestion: suggest_field(key, &known_top_level),
698 });
699 }
700 }
701
702 if let Some(toml::Value::Table(color)) = root.get("color") {
704 let known_color_keys: HashSet<&str> =
705 ["ui", "error", "warning"].into_iter().collect();
706
707 for key in color.keys() {
708 if !known_color_keys.contains(key.as_str()) {
709 let line = find_key_line(content, Some("color"), key);
710 errors.push(ValidationError::UnknownField {
711 path: format!("color.{}", key),
712 line,
713 suggestion: suggest_field(key, &known_color_keys),
714 });
715 }
716 }
717 }
718
719 if let Some(toml::Value::Table(parser)) = root.get("parser") {
721 let known_parser_keys: HashSet<&str> = ["block_directives"].into_iter().collect();
722
723 for key in parser.keys() {
724 if !known_parser_keys.contains(key.as_str()) {
725 let line = find_key_line(content, Some("parser"), key);
726 errors.push(ValidationError::UnknownField {
727 path: format!("parser.{}", key),
728 line,
729 suggestion: suggest_field(key, &known_parser_keys),
730 });
731 }
732 }
733 }
734
735 if let Some(toml::Value::Table(include)) = root.get("include") {
737 let known_include_keys: HashSet<&str> =
738 ["path_map", "prefix"].into_iter().collect();
739
740 for key in include.keys() {
741 if !known_include_keys.contains(key.as_str()) {
742 let line = find_key_line(content, Some("include"), key);
743 errors.push(ValidationError::UnknownField {
744 path: format!("include.{}", key),
745 line,
746 suggestion: suggest_field(key, &known_include_keys),
747 });
748 }
749 }
750 }
751
752 if let Some(toml::Value::Table(rules)) = root.get("rules") {
754 let known_rules: HashSet<&str> = [
755 "duplicate-directive",
756 "unmatched-braces",
757 "unclosed-quote",
758 "missing-semicolon",
759 "invalid-directive-context",
760 "deprecated-ssl-protocol",
761 "server-tokens-enabled",
762 "autoindex-enabled",
763 "weak-ssl-ciphers",
764 "indent",
765 "trailing-whitespace",
766 "space-before-semicolon",
767 "block-lines",
768 "gzip-not-enabled",
769 "missing-error-log",
770 "proxy-pass-domain",
771 "upstream-server-no-resolve",
772 "root-in-location",
773 "alias-location-slash-mismatch",
774 "proxy-pass-with-uri",
775 "proxy-keepalive",
776 "try-files-with-proxy",
777 "if-is-evil-in-location",
778 "directive-inheritance",
779 "include-path-exists",
780 ]
781 .into_iter()
782 .collect();
783
784 for (rule_name, rule_value) in rules {
785 if !known_rules.contains(rule_name.as_str()) {
786 let line = find_key_line(content, Some("rules"), rule_name);
787 errors.push(ValidationError::UnknownRule {
788 name: rule_name.clone(),
789 line,
790 suggestion: suggest_field(rule_name, &known_rules),
791 });
792 continue;
793 }
794
795 if let toml::Value::Table(rule_config) = rule_value {
797 let known_rule_options = get_known_rule_options(rule_name);
798 let section = format!("rules.{}", rule_name);
799
800 for key in rule_config.keys() {
801 if !known_rule_options.contains(key.as_str()) {
802 let line = find_key_line(content, Some(§ion), key);
803 errors.push(ValidationError::UnknownRuleOption {
804 rule: rule_name.clone(),
805 option: key.clone(),
806 line,
807 suggestion: suggest_field(key, &known_rule_options),
808 });
809 }
810 }
811 }
812 }
813 }
814 }
815
816 Ok(errors)
817 }
818}
819
820fn find_key_line(content: &str, section: Option<&str>, key: &str) -> Option<usize> {
822 let lines: Vec<&str> = content.lines().collect();
823
824 if section.is_none() {
826 let section_header = format!("[{}]", key);
827 for (i, line) in lines.iter().enumerate() {
828 if line.trim() == section_header {
829 return Some(i + 1);
830 }
831 }
832 return None;
833 }
834
835 let target_section = section.unwrap();
836 let mut in_section = false;
837
838 for (i, line) in lines.iter().enumerate() {
839 let trimmed = line.trim();
840
841 if trimmed.starts_with('[') && trimmed.ends_with(']') {
843 let section_name = &trimmed[1..trimmed.len() - 1];
844
845 let full_section = format!("{}.{}", target_section, key);
847 if section_name == full_section {
848 return Some(i + 1);
849 }
850
851 in_section = section_name == target_section
852 || section_name.starts_with(&format!("{}.", target_section));
853 continue;
854 }
855
856 if in_section && let Some((k, _)) = trimmed.split_once('=') {
858 let k = k.trim();
859 if k == key {
860 return Some(i + 1);
861 }
862 }
863 }
864
865 None
866}
867
868fn get_known_rule_options(rule_name: &str) -> HashSet<&'static str> {
870 let mut options: HashSet<&str> = ["enabled"].into_iter().collect();
871
872 match rule_name {
873 "indent" => {
874 options.insert("indent_size");
875 }
876 "deprecated-ssl-protocol" => {
877 options.insert("allowed_protocols");
878 }
879 "weak-ssl-ciphers" => {
880 options.insert("weak_ciphers");
881 options.insert("required_exclusions");
882 }
883 "block-lines" => {
884 options.insert("max_block_lines");
885 }
886 "directive-inheritance" => {
887 options.insert("excluded_directives");
888 options.insert("additional_directives");
889 }
890 _ => {}
891 }
892
893 options
894}
895
896fn suggest_field(input: &str, known: &HashSet<&str>) -> Option<String> {
898 let input_lower = input.to_lowercase();
899
900 known
902 .iter()
903 .filter(|&&k| {
904 let k_lower = k.to_lowercase();
905 k_lower.contains(&input_lower)
907 || input_lower.contains(&k_lower)
908 || levenshtein_distance(&input_lower, &k_lower) <= 2
909 })
910 .min_by_key(|&&k| levenshtein_distance(&input.to_lowercase(), &k.to_lowercase()))
911 .map(|&s| s.to_string())
912}
913
914fn levenshtein_distance(a: &str, b: &str) -> usize {
916 let a_chars: Vec<char> = a.chars().collect();
917 let b_chars: Vec<char> = b.chars().collect();
918 let a_len = a_chars.len();
919 let b_len = b_chars.len();
920
921 if a_len == 0 {
922 return b_len;
923 }
924 if b_len == 0 {
925 return a_len;
926 }
927
928 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
929
930 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
931 row[0] = i;
932 }
933 for (j, cell) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
934 *cell = j;
935 }
936
937 for i in 1..=a_len {
938 for j in 1..=b_len {
939 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
940 matrix[i][j] = (matrix[i - 1][j] + 1)
941 .min(matrix[i][j - 1] + 1)
942 .min(matrix[i - 1][j - 1] + cost);
943 }
944 }
945
946 matrix[a_len][b_len]
947}
948
949#[derive(Debug, Clone)]
955pub enum ValidationError {
956 UnknownField {
958 path: String,
960 line: Option<usize>,
962 suggestion: Option<String>,
964 },
965 UnknownRule {
967 name: String,
969 line: Option<usize>,
971 suggestion: Option<String>,
973 },
974 UnknownRuleOption {
976 rule: String,
978 option: String,
980 line: Option<usize>,
982 suggestion: Option<String>,
984 },
985}
986
987impl std::fmt::Display for ValidationError {
988 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989 match self {
990 ValidationError::UnknownField {
991 path,
992 line,
993 suggestion,
994 } => {
995 if let Some(l) = line {
996 write!(f, "line {}: ", l)?;
997 }
998 write!(f, "unknown field '{}'", path)?;
999 if let Some(s) = suggestion {
1000 write!(f, ", did you mean '{}'?", s)?;
1001 }
1002 Ok(())
1003 }
1004 ValidationError::UnknownRule {
1005 name,
1006 line,
1007 suggestion,
1008 } => {
1009 if let Some(l) = line {
1010 write!(f, "line {}: ", l)?;
1011 }
1012 write!(f, "unknown rule '{}'", name)?;
1013 if let Some(s) = suggestion {
1014 write!(f, ", did you mean '{}'?", s)?;
1015 }
1016 Ok(())
1017 }
1018 ValidationError::UnknownRuleOption {
1019 rule,
1020 option,
1021 line,
1022 suggestion,
1023 } => {
1024 if let Some(l) = line {
1025 write!(f, "line {}: ", l)?;
1026 }
1027 write!(f, "unknown option '{}' for rule '{}'", option, rule)?;
1028 if let Some(s) = suggestion {
1029 write!(f, ", did you mean '{}'?", s)?;
1030 }
1031 Ok(())
1032 }
1033 }
1034 }
1035}
1036
1037#[derive(Debug)]
1039pub enum ConfigError {
1040 IoError {
1042 path: std::path::PathBuf,
1044 source: std::io::Error,
1046 },
1047 ParseError {
1049 path: std::path::PathBuf,
1051 source: toml::de::Error,
1053 },
1054}
1055
1056impl std::fmt::Display for ConfigError {
1057 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1058 match self {
1059 ConfigError::IoError { path, source } => {
1060 write!(
1061 f,
1062 "Failed to read config file '{}': {}",
1063 path.display(),
1064 source
1065 )
1066 }
1067 ConfigError::ParseError { path, source } => {
1068 write!(
1069 f,
1070 "Failed to parse config file '{}': {}",
1071 path.display(),
1072 source
1073 )
1074 }
1075 }
1076 }
1077}
1078
1079impl std::error::Error for ConfigError {
1080 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1081 match self {
1082 ConfigError::IoError { source, .. } => Some(source),
1083 ConfigError::ParseError { source, .. } => Some(source),
1084 }
1085 }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091 use std::io::Write;
1092 use tempfile::NamedTempFile;
1093
1094 #[test]
1095 fn test_default_config() {
1096 let config = LintConfig::default();
1097 assert!(config.is_rule_enabled("any-rule"));
1098 }
1099
1100 #[test]
1101 fn test_disabled_by_default_rules() {
1102 let config = LintConfig::default();
1103 assert!(!config.is_rule_enabled("gzip-not-enabled"));
1105 assert!(!config.is_rule_enabled("missing-error-log"));
1106 assert!(config.is_rule_enabled("server-tokens-enabled"));
1108 }
1109
1110 #[test]
1111 fn test_parse_config() {
1112 let toml_content = r#"
1113[rules.indent]
1114enabled = true
1115indent_size = 2
1116
1117[rules.server-tokens-enabled]
1118enabled = false
1119"#;
1120 let mut file = NamedTempFile::new().unwrap();
1121 write!(file, "{}", toml_content).unwrap();
1122
1123 let config = LintConfig::from_file(file.path()).unwrap();
1124
1125 assert!(config.is_rule_enabled("indent"));
1126 assert!(!config.is_rule_enabled("server-tokens-enabled"));
1127 assert!(config.is_rule_enabled("unknown-rule"));
1128
1129 let indent_config = config.get_rule_config("indent").unwrap();
1130 assert_eq!(indent_config.indent_size, Some(IndentSize::Fixed(2)));
1131 }
1132
1133 #[test]
1134 fn test_empty_config() {
1135 let toml_content = "";
1136 let mut file = NamedTempFile::new().unwrap();
1137 write!(file, "{}", toml_content).unwrap();
1138
1139 let config = LintConfig::from_file(file.path()).unwrap();
1140 assert!(config.is_rule_enabled("any-rule"));
1141 }
1142
1143 #[test]
1144 fn test_indent_size_auto() {
1145 let toml_content = r#"
1146[rules.indent]
1147enabled = true
1148indent_size = "auto"
1149"#;
1150 let mut file = NamedTempFile::new().unwrap();
1151 write!(file, "{}", toml_content).unwrap();
1152
1153 let config = LintConfig::from_file(file.path()).unwrap();
1154 let indent_config = config.get_rule_config("indent").unwrap();
1155 assert_eq!(indent_config.indent_size, Some(IndentSize::Auto));
1156 }
1157
1158 #[test]
1159 fn test_color_config_default() {
1160 let config = LintConfig::default();
1161 assert_eq!(config.color_mode(), ColorMode::Auto);
1162 }
1163
1164 #[test]
1165 fn test_color_config_auto() {
1166 let toml_content = r#"
1167[color]
1168ui = "auto"
1169"#;
1170 let mut file = NamedTempFile::new().unwrap();
1171 write!(file, "{}", toml_content).unwrap();
1172
1173 let config = LintConfig::from_file(file.path()).unwrap();
1174 assert_eq!(config.color_mode(), ColorMode::Auto);
1175 }
1176
1177 #[test]
1178 fn test_color_config_never() {
1179 let toml_content = r#"
1180[color]
1181ui = "never"
1182"#;
1183 let mut file = NamedTempFile::new().unwrap();
1184 write!(file, "{}", toml_content).unwrap();
1185
1186 let config = LintConfig::from_file(file.path()).unwrap();
1187 assert_eq!(config.color_mode(), ColorMode::Never);
1188 }
1189
1190 #[test]
1191 fn test_color_config_always() {
1192 let toml_content = r#"
1193[color]
1194ui = "always"
1195"#;
1196 let mut file = NamedTempFile::new().unwrap();
1197 write!(file, "{}", toml_content).unwrap();
1198
1199 let config = LintConfig::from_file(file.path()).unwrap();
1200 assert_eq!(config.color_mode(), ColorMode::Always);
1201 }
1202
1203 #[test]
1204 fn test_color_config_default_colors() {
1205 let config = LintConfig::default();
1206 assert_eq!(config.color.error, Color::Red);
1207 assert_eq!(config.color.warning, Color::Yellow);
1208 }
1209
1210 #[test]
1211 fn test_color_config_custom_colors() {
1212 let toml_content = r#"
1213[color]
1214error = "magenta"
1215warning = "cyan"
1216"#;
1217 let mut file = NamedTempFile::new().unwrap();
1218 write!(file, "{}", toml_content).unwrap();
1219
1220 let config = LintConfig::from_file(file.path()).unwrap();
1221 assert_eq!(config.color.error, Color::Magenta);
1222 assert_eq!(config.color.warning, Color::Cyan);
1223 }
1224
1225 #[test]
1226 fn test_color_config_bright_colors() {
1227 let toml_content = r#"
1228[color]
1229error = "bright_red"
1230warning = "bright_yellow"
1231"#;
1232 let mut file = NamedTempFile::new().unwrap();
1233 write!(file, "{}", toml_content).unwrap();
1234
1235 let config = LintConfig::from_file(file.path()).unwrap();
1236 assert_eq!(config.color.error, Color::BrightRed);
1237 assert_eq!(config.color.warning, Color::BrightYellow);
1238 }
1239
1240 #[test]
1241 fn test_block_lines_max_block_lines_parsing() {
1242 let toml_content = r#"
1243[rules.block-lines]
1244enabled = true
1245max_block_lines = 50
1246"#;
1247 let mut file = NamedTempFile::new().unwrap();
1248 write!(file, "{}", toml_content).unwrap();
1249
1250 let config = LintConfig::from_file(file.path()).unwrap();
1251 assert!(config.is_rule_enabled("block-lines"));
1252 let rule_config = config.get_rule_config("block-lines").unwrap();
1253 assert_eq!(rule_config.max_block_lines, Some(50));
1254 }
1255
1256 #[test]
1257 fn test_block_lines_default_no_max() {
1258 let toml_content = r#"
1259[rules.block-lines]
1260enabled = true
1261"#;
1262 let mut file = NamedTempFile::new().unwrap();
1263 write!(file, "{}", toml_content).unwrap();
1264
1265 let config = LintConfig::from_file(file.path()).unwrap();
1266 let rule_config = config.get_rule_config("block-lines").unwrap();
1267 assert_eq!(rule_config.max_block_lines, None);
1268 }
1269
1270 #[test]
1271 fn test_block_lines_validation_rejects_unknown_option() {
1272 let toml_content = r#"
1273[rules.block-lines]
1274enabled = true
1275unknown_option = 42
1276"#;
1277 let mut file = NamedTempFile::new().unwrap();
1278 write!(file, "{}", toml_content).unwrap();
1279
1280 let errors = LintConfig::validate_file(file.path()).unwrap();
1281 assert_eq!(errors.len(), 1);
1282 match &errors[0] {
1283 ValidationError::UnknownRuleOption { rule, option, .. } => {
1284 assert_eq!(rule, "block-lines");
1285 assert_eq!(option, "unknown_option");
1286 }
1287 other => panic!("expected UnknownRuleOption, got: {:?}", other),
1288 }
1289 }
1290
1291 #[test]
1292 fn test_include_path_map_empty_by_default() {
1293 let config = LintConfig::default();
1294 assert!(config.include_path_mappings().is_empty());
1295 }
1296
1297 #[test]
1298 fn test_include_path_map_single_entry() {
1299 let toml_content = r#"
1300[[include.path_map]]
1301from = "sites-enabled"
1302to = "sites-available"
1303"#;
1304 let config = LintConfig::parse(toml_content).unwrap();
1305 let mappings = config.include_path_mappings();
1306 assert_eq!(mappings.len(), 1);
1307 assert_eq!(mappings[0].from, "sites-enabled");
1308 assert_eq!(mappings[0].to, "sites-available");
1309 }
1310
1311 #[test]
1312 fn test_include_path_map_multiple_entries_preserve_order() {
1313 let toml_content = r#"
1314[[include.path_map]]
1315from = "sites-enabled"
1316to = "sites-available"
1317
1318[[include.path_map]]
1319from = "/etc/nginx"
1320to = "/usr/local/nginx"
1321"#;
1322 let config = LintConfig::parse(toml_content).unwrap();
1323 let mappings = config.include_path_mappings();
1324 assert_eq!(mappings.len(), 2);
1325 assert_eq!(mappings[0].from, "sites-enabled");
1326 assert_eq!(mappings[0].to, "sites-available");
1327 assert_eq!(mappings[1].from, "/etc/nginx");
1328 assert_eq!(mappings[1].to, "/usr/local/nginx");
1329 }
1330
1331 #[test]
1332 fn test_include_validation_rejects_unknown_field() {
1333 let toml_content = r#"
1334[include]
1335unknown_key = "value"
1336"#;
1337 let mut file = NamedTempFile::new().unwrap();
1338 write!(file, "{}", toml_content).unwrap();
1339
1340 let errors = LintConfig::validate_file(file.path()).unwrap();
1341 assert_eq!(errors.len(), 1);
1342 match &errors[0] {
1343 ValidationError::UnknownField { path, .. } => {
1344 assert_eq!(path, "include.unknown_key");
1345 }
1346 other => panic!("expected UnknownField, got: {:?}", other),
1347 }
1348 }
1349
1350 #[test]
1351 fn test_include_prefix_none_by_default() {
1352 let config = LintConfig::default();
1353 assert!(config.include_prefix().is_none());
1354 }
1355
1356 #[test]
1357 fn test_include_prefix_parsed() {
1358 let toml_content = r#"
1359[include]
1360prefix = "/etc/nginx"
1361"#;
1362 let config = LintConfig::parse(toml_content).unwrap();
1363 assert_eq!(config.include_prefix(), Some("/etc/nginx"));
1364 }
1365
1366 #[test]
1367 fn test_include_prefix_with_path_map() {
1368 let toml_content = r#"
1369[include]
1370prefix = "."
1371
1372[[include.path_map]]
1373from = "sites-enabled"
1374to = "sites-available"
1375"#;
1376 let config = LintConfig::parse(toml_content).unwrap();
1377 assert_eq!(config.include_prefix(), Some("."));
1378 assert_eq!(config.include_path_mappings().len(), 1);
1379 }
1380
1381 #[test]
1382 fn test_include_prefix_validation_accepted() {
1383 let toml_content = r#"
1384[include]
1385prefix = "/etc/nginx"
1386"#;
1387 let mut file = NamedTempFile::new().unwrap();
1388 write!(file, "{}", toml_content).unwrap();
1389
1390 let errors = LintConfig::validate_file(file.path()).unwrap();
1391 assert!(
1392 errors.is_empty(),
1393 "prefix should be a valid include field, got errors: {:?}",
1394 errors
1395 );
1396 }
1397
1398 #[test]
1399 fn test_json_schema_is_valid() {
1400 let schema = LintConfig::json_schema();
1401
1402 assert_eq!(
1404 schema.get("$schema").and_then(|v| v.as_str()),
1405 Some("https://json-schema.org/draft/2020-12/schema")
1406 );
1407
1408 let props = schema.get("properties").unwrap().as_object().unwrap();
1410 assert!(props.contains_key("rules"), "missing 'rules' property");
1411 assert!(props.contains_key("color"), "missing 'color' property");
1412 assert!(props.contains_key("parser"), "missing 'parser' property");
1413 assert!(props.contains_key("include"), "missing 'include' property");
1414 }
1415
1416 #[test]
1417 fn test_json_schema_rule_config_has_all_fields() {
1418 let schema = LintConfig::json_schema();
1419
1420 let rule_config_def = schema
1423 .pointer("/$defs/RuleConfig")
1424 .expect("RuleConfig definition missing from schema");
1425
1426 let props = rule_config_def
1427 .get("properties")
1428 .unwrap()
1429 .as_object()
1430 .unwrap();
1431
1432 let expected_fields = [
1433 "enabled",
1434 "indent_size",
1435 "allowed_protocols",
1436 "weak_ciphers",
1437 "required_exclusions",
1438 "additional_contexts",
1439 "max_block_lines",
1440 "excluded_directives",
1441 "additional_directives",
1442 ];
1443
1444 for field in &expected_fields {
1445 assert!(
1446 props.contains_key(*field),
1447 "RuleConfig schema missing field '{field}'"
1448 );
1449 }
1450 }
1451}