nginx-lint-plugin 0.17.0

Plugin SDK for nginx-lint
Documentation
# nginx-lint-plugin

[![API Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://walf443.github.io/nginx-lint/api/nginx_lint_plugin/)

Plugin SDK for building custom [nginx-lint](https://github.com/walf443/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:

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

Add dependencies to `Cargo.toml`:

```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`:

```rust
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:

```bash
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:

```rust
// 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:

```rust
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:

```rust
// 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:

```rust
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()`:

```rust
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:

```rust
#[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