nff/src/diagnostic.rs

1613 lines
57 KiB
Rust

//! Diagnostic system for nftables configuration files
//!
//! This module provides comprehensive diagnostic capabilities including:
//! - Syntax errors with precise location information
//! - Semantic validation warnings
//! - Style and best practice recommendations
//! - Language Server Protocol (LSP) compatible output
//! - JSON output for tooling integration
use crate::lexer::LexError;
use crate::parser::{ParseError, Parser};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::net::IpAddr;
use text_size::TextSize;
/// Diagnostic severity levels following LSP specification
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiagnosticSeverity {
/// Reports an error that prevents successful processing
Error = 1,
/// Reports a warning that should be addressed
Warning = 2,
/// Reports information that might be useful
Information = 3,
/// Reports a hint for potential improvements
Hint = 4,
}
impl fmt::Display for DiagnosticSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DiagnosticSeverity::Error => write!(f, "error"),
DiagnosticSeverity::Warning => write!(f, "warning"),
DiagnosticSeverity::Information => write!(f, "info"),
DiagnosticSeverity::Hint => write!(f, "hint"),
}
}
}
/// Diagnostic codes for categorizing issues
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiagnosticCode {
// Syntax errors
SyntaxError,
UnexpectedToken,
MissingToken,
UnterminatedString,
InvalidNumber,
InvalidToken,
// Semantic errors
UnknownTableFamily,
UnknownChainType,
UnknownHook,
InvalidPriority,
InvalidPolicy,
DuplicateTableName,
DuplicateChainName,
UndefinedVariable,
InvalidCidrNotation,
InvalidPortRange,
InvalidProtocol,
// Style warnings
MissingShebang,
InconsistentIndentation,
TrailingWhitespace,
TooManyEmptyLines,
LongLine,
PreferredAlternative,
// Best practices
ChainWithoutPolicy,
RuleWithoutAction,
OverlyPermissiveRule,
DuplicateRule,
ConflictingRules,
UnusedVariable,
UnusedSet,
DeprecatedSyntax,
MissingDocumentation,
SecurityRisk,
// Performance
InefficientRuleOrder,
LargeSetWithoutTimeout,
MissingCounters,
// Indentation and formatting
MixedIndentation,
IncorrectIndentationLevel,
MissingSpaceAfterComma,
ExtraWhitespace,
// nftables specific
ChainMissingHook,
InvalidTableFamily,
InvalidChainPriority,
MissingChainType,
RedundantRule,
UnnecessaryJump,
}
impl fmt::Display for DiagnosticCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let code = match self {
DiagnosticCode::SyntaxError => "NFT001",
DiagnosticCode::UnexpectedToken => "NFT002",
DiagnosticCode::MissingToken => "NFT003",
DiagnosticCode::UnterminatedString => "NFT004",
DiagnosticCode::InvalidNumber => "NFT005",
DiagnosticCode::InvalidToken => "NFT006",
DiagnosticCode::UnknownTableFamily => "NFT101",
DiagnosticCode::UnknownChainType => "NFT102",
DiagnosticCode::UnknownHook => "NFT103",
DiagnosticCode::InvalidPriority => "NFT104",
DiagnosticCode::InvalidPolicy => "NFT105",
DiagnosticCode::DuplicateTableName => "NFT106",
DiagnosticCode::DuplicateChainName => "NFT107",
DiagnosticCode::UndefinedVariable => "NFT108",
DiagnosticCode::InvalidCidrNotation => "NFT109",
DiagnosticCode::InvalidPortRange => "NFT110",
DiagnosticCode::InvalidProtocol => "NFT111",
DiagnosticCode::MissingShebang => "NFT201",
DiagnosticCode::InconsistentIndentation => "NFT202",
DiagnosticCode::TrailingWhitespace => "NFT203",
DiagnosticCode::TooManyEmptyLines => "NFT204",
DiagnosticCode::LongLine => "NFT205",
DiagnosticCode::PreferredAlternative => "NFT206",
DiagnosticCode::ChainWithoutPolicy => "NFT301",
DiagnosticCode::RuleWithoutAction => "NFT302",
DiagnosticCode::OverlyPermissiveRule => "NFT303",
DiagnosticCode::DuplicateRule => "NFT304",
DiagnosticCode::ConflictingRules => "NFT305",
DiagnosticCode::UnusedVariable => "NFT306",
DiagnosticCode::UnusedSet => "NFT307",
DiagnosticCode::DeprecatedSyntax => "NFT308",
DiagnosticCode::MissingDocumentation => "NFT309",
DiagnosticCode::SecurityRisk => "NFT310",
DiagnosticCode::InefficientRuleOrder => "NFT401",
DiagnosticCode::LargeSetWithoutTimeout => "NFT402",
DiagnosticCode::MissingCounters => "NFT403",
DiagnosticCode::MixedIndentation => "NFT501",
DiagnosticCode::IncorrectIndentationLevel => "NFT502",
DiagnosticCode::MissingSpaceAfterComma => "NFT503",
DiagnosticCode::ExtraWhitespace => "NFT504",
DiagnosticCode::ChainMissingHook => "NFT601",
DiagnosticCode::InvalidTableFamily => "NFT602",
DiagnosticCode::InvalidChainPriority => "NFT603",
DiagnosticCode::MissingChainType => "NFT604",
DiagnosticCode::RedundantRule => "NFT605",
DiagnosticCode::UnnecessaryJump => "NFT606",
};
write!(f, "{}", code)
}
}
/// Position information for diagnostics
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Position {
pub line: u32,
pub character: u32,
}
impl Position {
/// Creates a new position with line and character coordinates
///
/// # Parameters
/// * `line` - Zero-based line number
/// * `character` - Zero-based character offset in the line
pub fn new(line: u32, character: u32) -> Self {
Self { line, character }
}
/// Converts a text offset to line and character coordinates
///
/// # Parameters
/// * `text_size` - Byte offset in the source code
/// * `source` - Complete source text to analyze for line breaks
///
/// # Returns
/// A position with line and character coordinates corresponding to the text offset
pub fn from_text_size(text_size: TextSize, source: &str) -> Self {
let mut line = 0;
let mut character = 0;
let offset = text_size.into();
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
character = 0;
} else {
character += 1;
}
}
Self { line, character }
}
}
/// Range information for diagnostics
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Range {
pub start: Position,
pub end: Position,
}
impl Range {
/// Creates a new range with start and end positions
///
/// # Parameters
/// * `start` - Starting position (line and character)
/// * `end` - Ending position (line and character)
pub fn new(start: Position, end: Position) -> Self {
Self { start, end }
}
/// Creates a range that covers only a single position
///
/// Useful for diagnostics that point to a specific location rather than a range
///
/// # Parameters
/// * `position` - The position to create a single-point range for
pub fn single_position(position: Position) -> Self {
Self {
start: position.clone(),
end: position,
}
}
}
/// Related information for diagnostics
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticRelatedInformation {
pub location: Range,
pub message: String,
}
/// Code action that can fix a diagnostic
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeAction {
pub title: String,
pub kind: String,
pub edit: Option<WorkspaceEdit>,
}
/// Text edit for code actions
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextEdit {
pub range: Range,
pub new_text: String,
}
/// Workspace edit containing text changes
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceEdit {
pub changes: HashMap<String, Vec<TextEdit>>,
}
/// A single diagnostic issue
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
/// The range at which the message applies
pub range: Range,
/// The diagnostic's severity
pub severity: DiagnosticSeverity,
/// The diagnostic's code
pub code: DiagnosticCode,
/// A human-readable string describing the source of this diagnostic
pub source: String,
/// The diagnostic's message
pub message: String,
/// Additional metadata about the diagnostic
pub related_information: Vec<DiagnosticRelatedInformation>,
/// Code actions that can address this diagnostic
pub code_actions: Vec<CodeAction>,
/// Tags providing additional metadata
pub tags: Vec<String>,
}
impl Diagnostic {
/// Creates a new diagnostic with essential information
///
/// # Parameters
/// * `range` - The source code range the diagnostic applies to
/// * `severity` - The severity level of the diagnostic
/// * `code` - The diagnostic code indicating the type of issue
/// * `message` - A human-readable description of the diagnostic
///
/// # Returns
/// A new diagnostic with default values for other fields
pub fn new(
range: Range,
severity: DiagnosticSeverity,
code: DiagnosticCode,
message: String,
) -> Self {
Self {
range,
severity,
code,
source: "nff".to_string(),
message,
related_information: Vec::new(),
code_actions: Vec::new(),
tags: Vec::new(),
}
}
}
/// Collection of diagnostics for a file
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiagnosticCollection {
pub diagnostics: Vec<Diagnostic>,
pub file_path: String,
pub source_text: String,
}
impl DiagnosticCollection {
/// Creates a new diagnostic collection for a file
///
/// # Parameters
/// * `file_path` - Path to the file being analyzed
/// * `source_text` - The content of the file
pub fn new(file_path: String, source_text: String) -> Self {
Self {
diagnostics: Vec::new(),
file_path,
source_text,
}
}
/// Adds multiple diagnostics to the collection
///
/// # Parameters
/// * `diagnostics` - Vector of diagnostics to add
pub fn extend(&mut self, diagnostics: Vec<Diagnostic>) {
self.diagnostics.extend(diagnostics);
}
/// Returns an iterator over all error-level diagnostics in the collection
pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> {
self.diagnostics
.iter()
.filter(|d| d.severity == DiagnosticSeverity::Error)
}
/// Checks if the collection contains any error-level diagnostics
pub fn has_errors(&self) -> bool {
self.errors().count() > 0
}
/// Converts the diagnostic collection to JSON format
///
/// Useful for integrating with Language Server Protocol (LSP) clients
/// or other tools that consume structured diagnostic data.
///
/// # Returns
/// A JSON string representation of the diagnostic collection, or an error if serialization fails
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
/// Converts the diagnostic collection to a human-readable text format
///
/// Produces a formatted report suitable for display in a terminal,
/// with file locations, error codes, and code snippets for context.
///
/// # Returns
/// A formatted string containing all diagnostics with relevant context
pub fn to_human_readable(&self) -> String {
let mut output = String::new();
if self.diagnostics.is_empty() {
output.push_str("No issues found.\n");
return output;
}
output.push_str(&format!(
"Found {} issues in {}:\n\n",
self.diagnostics.len(),
self.file_path
));
for diagnostic in &self.diagnostics {
output.push_str(&format!(
"{}:{}:{}: {}: {} [{}]\n",
self.file_path,
diagnostic.range.start.line + 1,
diagnostic.range.start.character + 1,
diagnostic.severity,
diagnostic.message,
diagnostic.code
));
// Add code snippet context
if let Some(context) = self.get_context_lines(&diagnostic.range, 2) {
for line in context {
output.push_str(&format!(" {}\n", line));
}
output.push('\n');
}
}
output
}
/// Extracts source code lines around a diagnostic location
///
/// Provides context for a diagnostic by showing the lines of code surrounding
/// the issue, with line numbers and a marker pointing to the problematic line.
///
/// # Parameters
/// * `range` - The range in the source code to provide context for
/// * `context_lines` - Number of lines to include before and after the range
///
/// # Returns
/// A vector of formatted strings containing the context lines with line numbers,
/// or None if the range is invalid
fn get_context_lines(&self, range: &Range, context_lines: usize) -> Option<Vec<String>> {
let lines: Vec<&str> = self.source_text.lines().collect();
let start_line = range.start.line as usize;
let end_line = range.end.line as usize;
if start_line >= lines.len() {
return None;
}
let context_start = start_line.saturating_sub(context_lines);
let context_end = std::cmp::min(end_line + context_lines + 1, lines.len());
let mut result = Vec::new();
for (i, line) in lines[context_start..context_end].iter().enumerate() {
let line_num = context_start + i + 1;
if i + context_start == start_line {
result.push(format!("{:4}: {}", line_num, line));
} else {
result.push(format!(" {:4}: {}", line_num, line));
}
}
Some(result)
}
}
/// Configuration for diagnostic analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticConfig {
/// Enable style warnings
pub enable_style_warnings: bool,
/// Enable best practice checks
pub enable_best_practices: bool,
/// Enable performance hints
pub enable_performance_hints: bool,
/// Enable security warnings
pub enable_security_warnings: bool,
/// Maximum line length for style checks
pub max_line_length: usize,
/// Maximum consecutive empty lines
pub max_empty_lines: usize,
/// Preferred indentation style
pub preferred_indent: Option<String>,
}
impl Default for DiagnosticConfig {
fn default() -> Self {
Self {
enable_style_warnings: true,
enable_best_practices: true,
enable_performance_hints: true,
enable_security_warnings: true,
max_line_length: 120,
max_empty_lines: 2,
preferred_indent: Some("tabs".to_string()),
}
}
}
/// Trait for specialized diagnostic analyzers
pub trait AnalyzerModule {
fn analyze(&self, source: &str, config: &DiagnosticConfig) -> Vec<Diagnostic>;
fn name(&self) -> &'static str;
}
/// Lexical analysis module
pub struct LexicalAnalyzer;
impl AnalyzerModule for LexicalAnalyzer {
fn analyze(&self, source: &str, _config: &DiagnosticConfig) -> Vec<Diagnostic> {
use crate::lexer::NftablesLexer;
let mut diagnostics = Vec::new();
let mut lexer = NftablesLexer::new(source);
match lexer.tokenize() {
Ok(_) => {}
Err(lex_error) => {
let diagnostic = Self::lex_error_to_diagnostic(&lex_error, source);
diagnostics.push(diagnostic);
}
}
diagnostics
}
fn name(&self) -> &'static str {
"lexical"
}
}
impl LexicalAnalyzer {
/// Converts a lexical error to a diagnostic
///
/// Translates lexer-specific errors into the generic diagnostic format
/// with appropriate severity, code, and location information.
///
/// # Parameters
/// * `error` - The lexical error to convert
/// * `source` - Source code for position calculation
///
/// # Returns
/// A diagnostic describing the lexical error
pub fn lex_error_to_diagnostic(error: &LexError, source: &str) -> Diagnostic {
match error {
LexError::InvalidToken { position, text } => {
let pos = Position::from_text_size(TextSize::from(*position as u32), source);
let range = Range::new(
pos.clone(),
Position::new(pos.line, pos.character + text.len() as u32),
);
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidToken,
format!("Invalid token: '{}'", text),
)
}
LexError::UnterminatedString { position } => {
let pos = Position::from_text_size(TextSize::from(*position as u32), source);
let range = Range::single_position(pos);
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::UnterminatedString,
"Unterminated string literal".to_string(),
)
}
LexError::InvalidNumber { position, text } => {
let start_pos = Position::from_text_size(TextSize::from(*position as u32), source);
let end_pos =
Position::new(start_pos.line, start_pos.character + text.len() as u32);
let range = Range::new(start_pos, end_pos);
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidNumber,
format!("Invalid number: '{}'", text),
)
}
}
}
}
/// Syntax analysis module
pub struct SyntaxAnalyzer;
impl AnalyzerModule for SyntaxAnalyzer {
fn analyze(&self, source: &str, _config: &DiagnosticConfig) -> Vec<Diagnostic> {
use crate::lexer::NftablesLexer;
let mut diagnostics = Vec::new();
let mut lexer = NftablesLexer::new(source);
match lexer.tokenize() {
Ok(tokens) => {
let mut parser = Parser::new(tokens);
match parser.parse() {
Ok(_) => {}
Err(parse_error) => {
let diagnostic = Self::parse_error_to_diagnostic(&parse_error, source);
diagnostics.push(diagnostic);
}
}
}
Err(_) => {}
}
diagnostics
}
fn name(&self) -> &'static str {
"syntax"
}
}
impl SyntaxAnalyzer {
/// Converts a parse error to a diagnostic
///
/// Translates parser-specific errors into the generic diagnostic format
/// with appropriate severity, code, and meaningful error messages.
///
/// # Parameters
/// * `error` - The parse error to convert
/// * `_source` - Source code for position calculation
///
/// # Returns
/// A diagnostic describing the syntax error
fn parse_error_to_diagnostic(error: &ParseError, _source: &str) -> Diagnostic {
match error {
ParseError::UnexpectedToken {
line,
column,
expected,
found,
} => {
let pos = Position::new(*line as u32, *column as u32);
let range = Range::single_position(pos);
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::UnexpectedToken,
format!("Expected {}, found '{}'", expected, found),
)
}
ParseError::MissingToken { expected } => {
let range = Range::single_position(Position::new(0, 0));
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::MissingToken,
format!("Missing token: expected {}", expected),
)
}
ParseError::InvalidExpression { message } => {
let range = Range::single_position(Position::new(0, 0));
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::SyntaxError,
format!("Invalid expression: {}", message),
)
}
ParseError::InvalidStatement { message } => {
let range = Range::single_position(Position::new(0, 0));
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::SyntaxError,
format!("Invalid statement: {}", message),
)
}
ParseError::SemanticError { message } => {
let range = Range::single_position(Position::new(0, 0));
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::SyntaxError,
format!("Semantic error: {}", message),
)
}
ParseError::LexError(lex_error) => {
LexicalAnalyzer::lex_error_to_diagnostic(lex_error, _source)
}
ParseError::AnyhowError(anyhow_error) => {
let range = Range::single_position(Position::new(0, 0));
Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::SyntaxError,
format!("Parse error: {}", anyhow_error),
)
}
}
}
}
/// Style and formatting analysis module
pub struct StyleAnalyzer;
impl AnalyzerModule for StyleAnalyzer {
fn analyze(&self, source: &str, config: &DiagnosticConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if !config.enable_style_warnings {
return diagnostics;
}
if !source.starts_with("#!") {
let range = Range::new(Position::new(0, 0), Position::new(0, 0));
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::MissingShebang,
"Consider adding a shebang line (e.g., #!/usr/sbin/nft -f)".to_string(),
);
diagnostics.push(diagnostic);
}
diagnostics.extend(self.analyze_line_issues(source, config));
diagnostics.extend(self.analyze_whitespace_issues(source, config));
diagnostics.extend(self.analyze_indentation(source, config));
diagnostics
}
fn name(&self) -> &'static str {
"style"
}
}
impl StyleAnalyzer {
/// Analyzes line issues in nftables configuration
///
/// Detects:
/// - Lines exceeding maximum length
/// - Trailing whitespace at end of lines
fn analyze_line_issues(&self, source: &str, config: &DiagnosticConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
// Long lines
if line.len() > config.max_line_length {
let start = Position::new(line_num, config.max_line_length as u32);
let end = Position::new(line_num, line.len() as u32);
let range = Range::new(start, end);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::LongLine,
format!(
"Line too long ({} > {} characters)",
line.len(),
config.max_line_length
),
);
diagnostics.push(diagnostic);
}
// Trailing whitespace
if line.ends_with(' ') || line.ends_with('\t') {
let trimmed_len = line.trim_end().len();
let start = Position::new(line_num, trimmed_len as u32);
let end = Position::new(line_num, line.len() as u32);
let range = Range::new(start, end);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::TrailingWhitespace,
"Trailing whitespace".to_string(),
);
diagnostics.push(diagnostic);
}
}
diagnostics
}
/// Analyzes whitespace issues in nftables configuration
///
/// Detects:
/// - Too many consecutive empty lines
/// - Trailing empty lines at end of file
fn analyze_whitespace_issues(
&self,
source: &str,
config: &DiagnosticConfig,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let lines: Vec<&str> = source.lines().collect();
let mut empty_count = 0;
let mut empty_start = 0;
for (line_idx, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
if empty_count == 0 {
empty_start = line_idx;
}
empty_count += 1;
} else {
if empty_count > config.max_empty_lines {
let start = Position::new(empty_start as u32, 0);
let end = Position::new((empty_start + empty_count - 1) as u32, 0);
let range = Range::new(start, end);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::TooManyEmptyLines,
format!(
"Too many consecutive empty lines ({} > {})",
empty_count, config.max_empty_lines
),
);
diagnostics.push(diagnostic);
}
empty_count = 0;
}
}
// Check for trailing empty lines at the end of the file
if empty_count > config.max_empty_lines {
let start = Position::new(empty_start as u32, 0);
let end = Position::new((empty_start + empty_count - 1) as u32, 0);
let range = Range::new(start, end);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::TooManyEmptyLines,
format!(
"Too many consecutive empty lines at end of file ({} > {})",
empty_count, config.max_empty_lines
),
);
diagnostics.push(diagnostic);
}
diagnostics
}
/// Analyzes indentation consistency in nftables configuration
///
/// Checks for:
/// - Mixed tabs and spaces in a single line
/// - Inconsistent indentation styles across the file
/// - Adherence to preferred indentation style if specified
fn analyze_indentation(&self, source: &str, config: &DiagnosticConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut has_tabs = false;
let mut has_spaces = false;
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
if line.trim().is_empty() {
continue;
}
let leading_whitespace: String = line
.chars()
.take_while(|&c| c == ' ' || c == '\t')
.collect();
if leading_whitespace.contains('\t') {
has_tabs = true;
}
if leading_whitespace.contains(' ') {
has_spaces = true;
}
// Check for mixed indentation in a single line
if leading_whitespace.contains('\t') && leading_whitespace.contains(' ') {
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, leading_whitespace.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::MixedIndentation,
"Mixed tabs and spaces in indentation".to_string(),
);
diagnostics.push(diagnostic);
}
}
// Check for mixed indentation across the file
if has_tabs && has_spaces {
let range = Range::single_position(Position::new(0, 0));
let (severity, message) = if let Some(preferred) = &config.preferred_indent {
(
DiagnosticSeverity::Information,
format!("File uses mixed indentation; prefer {}", preferred),
)
} else {
(
DiagnosticSeverity::Warning,
"File uses mixed indentation (tabs and spaces)".to_string(),
)
};
let diagnostic = Diagnostic::new(
range,
severity,
DiagnosticCode::InconsistentIndentation,
message,
);
diagnostics.push(diagnostic);
}
diagnostics
}
}
/// Semantic analysis module for nftables-specific validation
pub struct SemanticAnalyzer;
impl AnalyzerModule for SemanticAnalyzer {
fn analyze(&self, source: &str, config: &DiagnosticConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
diagnostics.extend(self.validate_table_declarations(source));
diagnostics.extend(self.validate_chain_declarations_semantic(source));
diagnostics.extend(self.validate_cidr_notation(source));
if config.enable_best_practices {
diagnostics.extend(self.validate_chain_best_practices(source));
diagnostics.extend(self.check_for_redundant_rules(source));
}
if config.enable_performance_hints {
diagnostics.extend(self.check_performance_hints(source));
}
if config.enable_security_warnings {
diagnostics.extend(self.check_security_warnings(source));
}
diagnostics
}
fn name(&self) -> &'static str {
"semantic"
}
}
impl SemanticAnalyzer {
/// Validates table declarations in nftables configuration
///
/// Checks for:
/// - Valid table family (ip, ip6, inet, arp, bridge, netdev)
/// - Duplicate table names
fn validate_table_declarations(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut seen_tables = HashSet::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
if trimmed.starts_with("table ") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 3 {
let family = parts[1];
let name = parts[2];
match family {
"ip" | "ip6" | "inet" | "arp" | "bridge" | "netdev" => {}
_ => {
let start_col = line.find(family).unwrap_or(0);
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(line_num, (start_col + family.len()) as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidTableFamily,
format!("Unknown table family: '{}'", family),
);
diagnostics.push(diagnostic);
}
}
// Check for duplicate table names
let table_key = format!("{}:{}", family, name);
if seen_tables.contains(&table_key) {
let start_col = line.find(name).unwrap_or(0);
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(line_num, (start_col + name.len()) as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::DuplicateTableName,
format!("Duplicate table name: '{}'", name),
);
diagnostics.push(diagnostic);
} else {
seen_tables.insert(table_key);
}
}
}
}
diagnostics
}
/// Validates chain declarations for correct hook types
///
/// Checks for valid hooks in chain declarations:
/// - input, output, forward, prerouting, postrouting
/// - Reports unknown hooks as errors
fn validate_chain_declarations_semantic(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
if trimmed.starts_with("type ") && trimmed.contains("hook") {
if let Some(hook_pos) = trimmed.find("hook") {
let hook_part = &trimmed[hook_pos..];
let hook_words: Vec<&str> = hook_part.split_whitespace().collect();
if hook_words.len() >= 2 {
let hook = hook_words[1];
match hook {
"input" | "output" | "forward" | "prerouting" | "postrouting" => {}
_ => {
let start_col = line.find(hook).unwrap_or(0);
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(line_num, (start_col + hook.len()) as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::UnknownHook,
format!("Unknown hook: '{}'", hook),
);
diagnostics.push(diagnostic);
}
}
}
}
}
}
diagnostics
}
/// Checks for best practices in nftables chain definitions
///
/// Verifies that:
/// - Filter chains have explicit policies defined
fn validate_chain_best_practices(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
if trimmed.contains("type filter") && !trimmed.contains("policy") {
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, line.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::ChainWithoutPolicy,
"Filter chain should have an explicit policy".to_string(),
);
diagnostics.push(diagnostic);
}
}
diagnostics
}
/// Analyzes nftables configuration for performance optimizations
///
/// Checks for:
/// - Large sets without timeouts
/// - Rules without counters for monitoring
fn check_performance_hints(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
if trimmed.contains("set ") && trimmed.contains("{") && !trimmed.contains("timeout") {
if trimmed.len() > 100 {
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, line.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Hint,
DiagnosticCode::LargeSetWithoutTimeout,
"Consider adding a timeout to large sets for better performance"
.to_string(),
);
diagnostics.push(diagnostic);
}
}
// Check for missing counters (performance hint)
if (trimmed.contains(" accept") || trimmed.contains(" drop"))
&& !trimmed.contains("counter")
{
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, line.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Hint,
DiagnosticCode::MissingCounters,
"Consider adding counters to rules for monitoring and debugging".to_string(),
);
diagnostics.push(diagnostic);
}
}
diagnostics
}
/// Checks for security risks in nftables configuration
///
/// Identifies:
/// - Overly permissive rules that accept traffic from anywhere (0.0.0.0/0 or ::/0)
fn check_security_warnings(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
// Check for overly permissive rules (security warning)
if trimmed.contains(" accept")
&& (trimmed.contains("0.0.0.0/0") || trimmed.contains("::/0"))
{
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, line.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::SecurityRisk,
"Rule accepts traffic from anywhere - consider restricting source addresses"
.to_string(),
);
diagnostics.push(diagnostic);
}
}
diagnostics
}
/// Validates CIDR notation in nftables configuration
///
/// Checks for:
/// - Valid IP address format (both IPv4 and IPv6)
/// - Valid prefix length (0-32 for IPv4, 0-128 for IPv6)
/// - Valid numeric prefix format
fn validate_cidr_notation(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
// Look for potential CIDR notation patterns
let words: Vec<&str> = line.split_whitespace().collect();
for word in words {
if word.contains('/') && word.chars().any(|c| c.is_ascii_digit()) {
if let Some(slash_pos) = word.find('/') {
let (ip_part, prefix_part) = word.split_at(slash_pos);
let prefix_part = &prefix_part[1..];
match ip_part.parse::<IpAddr>() {
Ok(ip_addr) => match prefix_part.parse::<u8>() {
Ok(prefix) => {
let max_prefix = match ip_addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix > max_prefix {
if let Some(start_col) = line.find(word) {
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(
line_num,
(start_col + word.len()) as u32,
),
);
let ip_type = match ip_addr {
IpAddr::V4(_) => "IPv4",
IpAddr::V6(_) => "IPv6",
};
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidCidrNotation,
format!(
"Invalid CIDR prefix length: '{}' (max {} for {})",
prefix, max_prefix, ip_type
),
);
diagnostics.push(diagnostic);
}
}
}
Err(_) => {
if !prefix_part.is_empty() {
if let Some(start_col) = line.find(word) {
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(
line_num,
(start_col + word.len()) as u32,
),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidCidrNotation,
format!(
"Invalid CIDR prefix: '{}' must be a number",
prefix_part
),
);
diagnostics.push(diagnostic);
}
}
}
},
Err(_) => {
if (ip_part.contains('.')
&& ip_part.chars().any(|c| c.is_ascii_digit()))
|| (ip_part.contains(':')
&& ip_part
.chars()
.any(|c| c.is_ascii_digit() || c.is_ascii_hexdigit()))
{
if let Some(start_col) = line.find(word) {
let range = Range::new(
Position::new(line_num, start_col as u32),
Position::new(
line_num,
(start_col + word.len()) as u32,
),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::InvalidCidrNotation,
format!(
"Invalid IP address in CIDR notation: '{}'",
ip_part
),
);
diagnostics.push(diagnostic);
}
}
}
}
}
}
}
}
diagnostics
}
/// Identifies redundant rules in nftables configuration
///
/// Detects:
/// - Duplicate accept/drop/reject rules
fn check_for_redundant_rules(&self, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut seen_rules = HashSet::new();
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
let trimmed = line.trim();
if trimmed.contains(" accept")
|| trimmed.contains(" drop")
|| trimmed.contains(" reject")
{
if seen_rules.contains(trimmed) {
let range = Range::new(
Position::new(line_num, 0),
Position::new(line_num, line.len() as u32),
);
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
DiagnosticCode::RedundantRule,
"Duplicate rule found".to_string(),
);
diagnostics.push(diagnostic);
} else {
seen_rules.insert(trimmed.to_string());
}
}
}
diagnostics
}
}
/// Main diagnostic analyzer
pub struct DiagnosticAnalyzer {
config: DiagnosticConfig,
}
impl DiagnosticAnalyzer {
/// Creates a new diagnostic analyzer with the specified configuration
///
/// # Parameters
/// * `config` - Configuration settings for the analyzer
pub fn new(config: DiagnosticConfig) -> Self {
Self { config }
}
/// Analyzes source code with all standard analysis modules
///
/// This is the main entry point for a complete analysis of nftables configurations.
/// It runs lexical, syntax, style, and semantic analysis on the provided source.
///
/// # Parameters
/// * `source` - Source code to analyze
/// * `file_path` - Path to the file being analyzed
///
/// # Returns
/// A collection of all diagnostics found in the source
pub fn analyze(&self, source: &str, file_path: &str) -> DiagnosticCollection {
self.analyze_with_modules(
source,
file_path,
&["lexical", "syntax", "style", "semantic"],
)
}
/// Analyzes source code with specific analysis modules
///
/// Allows running only selected analysis modules for more targeted diagnostics.
///
/// # Parameters
/// * `source` - Source code to analyze
/// * `file_path` - Path to the file being analyzed
/// * `module_names` - Names of modules to run ("lexical", "syntax", "style", "semantic")
///
/// # Returns
/// A collection of diagnostics from the selected modules
pub fn analyze_with_modules(
&self,
source: &str,
file_path: &str,
module_names: &[&str],
) -> DiagnosticCollection {
let mut collection = DiagnosticCollection::new(file_path.to_string(), source.to_string());
let modules: Vec<Box<dyn AnalyzerModule>> = vec![
Box::new(LexicalAnalyzer),
Box::new(SyntaxAnalyzer),
Box::new(StyleAnalyzer),
Box::new(SemanticAnalyzer),
];
for module in modules {
if module_names.contains(&module.name()) {
let diagnostics = module.analyze(source, &self.config);
collection.extend(diagnostics);
}
}
collection
}
}
impl Default for DiagnosticAnalyzer {
fn default() -> Self {
Self::new(DiagnosticConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostic_creation() {
let range = Range::new(Position::new(0, 0), Position::new(0, 10));
let diagnostic = Diagnostic::new(
range,
DiagnosticSeverity::Error,
DiagnosticCode::SyntaxError,
"Test error".to_string(),
);
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
assert_eq!(diagnostic.code, DiagnosticCode::SyntaxError);
assert_eq!(diagnostic.message, "Test error");
}
#[test]
fn test_position_from_text_size() {
let source = "line 1\nline 2\nline 3";
let pos = Position::from_text_size(TextSize::from(8), source);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 1);
}
#[test]
fn test_style_analysis() {
let analyzer = DiagnosticAnalyzer::default();
let source = "table inet filter {\n chain input \n chain output\n}";
let diagnostics = analyzer.analyze(source, "test.nft");
// Should find missing shebang and trailing whitespace
assert!(!diagnostics.diagnostics.is_empty());
assert!(
diagnostics
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::MissingShebang)
);
assert!(
diagnostics
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::TrailingWhitespace)
);
}
#[test]
fn test_cidr_validation_ipv4_valid() {
let analyzer = SemanticAnalyzer;
let source = "ip saddr 192.168.1.0/24 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify valid IPv4 CIDR notation doesn't produce errors
assert!(
!diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
}
#[test]
fn test_cidr_validation_ipv4_invalid_prefix() {
let analyzer = SemanticAnalyzer;
let source = "ip saddr 192.168.1.0/33 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify detection of IPv4 prefix exceeding max (32)
assert!(diagnostics.iter().any(|d| {
d.code == DiagnosticCode::InvalidCidrNotation
&& d.message
.contains("Invalid CIDR prefix length: '33' (max 32 for IPv4)")
}));
}
#[test]
fn test_cidr_validation_ipv6_valid() {
let analyzer = SemanticAnalyzer;
let source = "ip6 saddr 2001:db8::/32 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify valid IPv6 CIDR notation doesn't produce errors
assert!(
!diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
}
#[test]
fn test_cidr_validation_ipv6_invalid_prefix() {
let analyzer = SemanticAnalyzer;
let source = "ip6 saddr 2001:db8::/129 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify detection of IPv6 prefix exceeding max (128)
assert!(diagnostics.iter().any(|d| {
d.code == DiagnosticCode::InvalidCidrNotation
&& d.message
.contains("Invalid CIDR prefix length: '129' (max 128 for IPv6)")
}));
}
#[test]
fn test_cidr_validation_invalid_ip_address() {
let analyzer = SemanticAnalyzer;
let source = "ip saddr 192.168.1.256/24 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify detection of invalid IP address format
assert!(diagnostics.iter().any(|d| {
d.code == DiagnosticCode::InvalidCidrNotation
&& d.message
.contains("Invalid IP address in CIDR notation: '192.168.1.256'")
}));
}
#[test]
fn test_cidr_validation_invalid_prefix_format() {
let analyzer = SemanticAnalyzer;
let source = "ip saddr 192.168.1.0/abc accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify detection of non-numeric CIDR prefix
assert!(diagnostics.iter().any(|d| {
d.code == DiagnosticCode::InvalidCidrNotation
&& d.message
.contains("Invalid CIDR prefix: 'abc' must be a number")
}));
}
#[test]
fn test_cidr_validation_ipv6_compressed_notation() {
let analyzer = SemanticAnalyzer;
let source = "ip6 saddr ::1/128 accept";
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify compressed IPv6 notation is properly handled
assert!(
!diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
}
#[test]
fn test_cidr_validation_multiple_cidrs() {
let analyzer = SemanticAnalyzer;
let source = r#"
ip saddr 192.168.1.0/24 accept
ip6 saddr 2001:db8::/32 accept
ip saddr 10.0.0.0/8 drop
ip6 saddr fe80::/64 accept
"#;
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify multiple valid CIDRs are properly parsed
assert!(
!diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
}
#[test]
fn test_cidr_validation_mixed_valid_invalid() {
let analyzer = SemanticAnalyzer;
let source = r#"
ip saddr 192.168.1.0/24 accept
ip saddr 192.168.1.0/33 drop
ip6 saddr 2001:db8::/32 accept
ip6 saddr 2001:db8::/129 drop
"#;
let diagnostics = analyzer.validate_cidr_notation(source);
// Verify detection of specific invalid prefixes in a mixed content
let cidr_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::InvalidCidrNotation)
.collect();
assert_eq!(cidr_errors.len(), 2);
// Check for IPv4 error
assert!(
cidr_errors
.iter()
.any(|d| d.message.contains("33") && d.message.contains("IPv4"))
);
// Check for IPv6 error
assert!(
cidr_errors
.iter()
.any(|d| d.message.contains("129") && d.message.contains("IPv6"))
);
}
#[test]
fn test_cidr_validation_edge_cases() {
let analyzer = SemanticAnalyzer;
// Test edge case: maximum allowed prefix lengths
let source_ipv4_max = "ip saddr 192.168.1.0/32 accept";
let diagnostics_ipv4 = analyzer.validate_cidr_notation(source_ipv4_max);
assert!(
!diagnostics_ipv4
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
let source_ipv6_max = "ip6 saddr 2001:db8::/128 accept";
let diagnostics_ipv6 = analyzer.validate_cidr_notation(source_ipv6_max);
assert!(
!diagnostics_ipv6
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
// Test edge case: minimum prefix (catch-all address)
let source_zero = "ip saddr 0.0.0.0/0 accept";
let diagnostics_zero = analyzer.validate_cidr_notation(source_zero);
assert!(
!diagnostics_zero
.iter()
.any(|d| d.code == DiagnosticCode::InvalidCidrNotation)
);
}
}