nginx-lint-plugin 0.17.0

Plugin SDK for nginx-lint
Documentation

nginx-lint-plugin

API Docs

Plugin SDK for building custom nginx-lint rules as WASM plugins.

Overview

This crate provides everything needed to create lint rules for nginx configuration files. Plugins are compiled to WASM and loaded by the nginx-lint host at runtime.

Quick Start

Create a new plugin project:

cargo new --lib my-nginx-rule
cd my-nginx-rule

Add dependencies to Cargo.toml:

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
nginx-lint-plugin = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[features]
default = ["wit-export"]
wit-export = ["nginx-lint-plugin/wit-export"]

Implement the plugin in src/lib.rs:

use nginx_lint_plugin::prelude::*;

#[derive(Default)]
pub struct NoAutoindexPlugin;

impl Plugin for NoAutoindexPlugin {
    fn spec(&self) -> PluginSpec {
        PluginSpec::new(
            "no-autoindex",
            "security",
            "Disallow autoindex directive for security",
        )
        .with_severity("warning")
        .with_why("Directory listing can expose sensitive files to attackers.")
        .with_bad_example("location / {\n    autoindex on;\n}")
        .with_good_example("location / {\n    autoindex off;\n}")
    }

    fn check(&self, config: &Config, _path: &str) -> Vec<LintError> {
        let mut errors = Vec::new();
        let err = self.spec().error_builder();

        for ctx in config.all_directives_with_context() {
            if ctx.directive.is("autoindex") && ctx.directive.first_arg_is("on") {
                errors.push(
                    err.warning_at("autoindex should be 'off'", ctx.directive)
                       .with_fix(ctx.directive.replace_with("autoindex off;")),
                );
            }
        }

        errors
    }
}

nginx_lint_plugin::export_component_plugin!(NoAutoindexPlugin);

Build for WASM:

cargo build --target wasm32-unknown-unknown --release

Key Concepts

Plugin Trait

Every plugin must implement [Plugin], which requires two methods:

  • spec() - Returns metadata about the rule (name, category, description, examples)
  • check() - Inspects the parsed nginx config and returns lint errors

Config Traversal

The SDK provides two ways to iterate over directives:

// Simple iteration over all directives
for directive in config.all_directives() {
    if directive.is("worker_connections") {
        // ...
    }
}

// Context-aware iteration (knows parent blocks)
for ctx in config.all_directives_with_context() {
    if ctx.is_inside("http") && ctx.directive.is("server_tokens") {
        // This directive is inside an http block
    }

    if ctx.parent_is("server") {
        // Direct child of a server block
    }
}

Error Reporting

Use ErrorBuilder (created via PluginSpec::error_builder()) to create errors:

let err = self.spec().error_builder();

// Warning at a directive's location
err.warning_at("message", directive);

// Error at a specific line/column
err.error("message", line, column);

Autofix Support

Attach fixes to errors for automatic correction:

// Replace a directive
err.warning_at("use 'off'", directive)
    .with_fix(directive.replace_with("autoindex off;"));

// Delete a line
err.warning_at("remove this", directive)
    .with_fix(directive.delete_line());

// Insert after a directive
err.warning_at("missing directive", directive)
    .with_fix(directive.insert_after("add_header X-Frame-Options DENY;"));

Include Context

When nginx-lint processes include directives, included files receive context about where they were included from. Use ConfigExt methods to check this:

use nginx_lint_plugin::prelude::*;

// Check if file is included from within http context
if config.is_included_from_http() {
    // This file was included inside an http { } block
}

// Check if inside http > server context
if config.is_included_from_http_server() {
    // Included from within server { } inside http { }
}

Performance: Reading Only What Your Rule Needs

By default, check() gets the entire parsed config, and the host has to send every directive across the WASM boundary and the SDK has to reconstruct the whole tree — even if your rule only reads one or two directive names. For large config files this dominates the per-check cost.

If your rule only ever inspects a fixed, known set of directive names, declare them with relevant_directives():

impl Plugin for NoAutoindexPlugin {
    fn relevant_directives(&self) -> Option<&'static [&'static str]> {
        Some(&["autoindex"])
    }

    // ... spec(), check() unchanged
}

The host then sends a config pruned to just those directives (plus the ancestor blocks needed for is_inside() to keep working), instead of the whole file. This is purely additive: omitting it (the default) behaves exactly as before, and it never changes what your rule can see — check() still receives a normal Config with all_directives_with_context() etc. working the same way.

One important exception: if your rule warns when a directive is missing inside some block (e.g. "this http block has no server_tokens"), you must include that enclosing block's own name ("http") in the list, not just the directive you're checking for. Otherwise a block with none of the listed directives inside it has nothing to keep it in the pruned config, and the host drops it entirely — along with the evidence your rule needs to report the block exists but is missing something. If your rule only reports on directives it finds (the common case), you don't need to list any ancestor block names — a matched directive's ancestors are always kept automatically so is_inside() and similar checks keep working.

Don't declare this if check() reads comments or blank lines (ConfigItem::Comment/ConfigItem::BlankLine): the pruned config never includes them, regardless of relevant_directives().

Testing

The SDK provides PluginTestRunner and TestCase for testing plugins:

#[cfg(test)]
mod tests {
    use super::*;
    use nginx_lint_plugin::testing::{PluginTestRunner, TestCase};

    #[test]
    fn test_detects_bad_config() {
        let runner = PluginTestRunner::new(NoAutoindexPlugin);

        // Assert error count
        runner.assert_errors("http {\n    autoindex on;\n}", 1);

        // Assert no errors for good config
        runner.assert_no_errors("http {\n    autoindex off;\n}");
    }

    #[test]
    fn test_with_builder() {
        TestCase::new("http {\n    autoindex on;\n}")
            .expect_error_count(1)
            .expect_error_on_line(2)
            .expect_message_contains("autoindex")
            .expect_has_fix()
            .run(&NoAutoindexPlugin);
    }

    #[test]
    fn test_with_fixtures() {
        let runner = PluginTestRunner::new(NoAutoindexPlugin);
        runner.test_fixtures(nginx_lint_plugin::fixtures_dir!());
    }

    #[test]
    fn test_examples() {
        let runner = PluginTestRunner::new(NoAutoindexPlugin);
        runner.test_examples(
            include_str!("../examples/bad.conf"),
            include_str!("../examples/good.conf"),
        );
    }
}

Fixture Directory Structure

tests/fixtures/
└── 001_basic/
    ├── error/nginx.conf      # Config that should trigger errors
    └── expected/nginx.conf   # Config after applying fixes

Modules

Module Description
types Core types: Plugin, PluginSpec, LintError, Fix, Config extensions
helpers Utility functions: is_domain_name(), extract_host_from_url(), etc.
testing Test utilities: PluginTestRunner, TestCase, fixtures_dir!()
native NativePluginRule adapter for running plugins without WASM overhead
prelude Convenient re-exports for use nginx_lint_plugin::prelude::*

License

MIT