url-parse-nginx 0.2.1

A faithful, 1-to-1 Rust port of nginx's URL path parser (ngx_http_parse_uri / ngx_http_parse_complex_uri) for origin-form paths.
Documentation

url-parse-nginx

A faithful, 1-to-1 Rust port of nginx's URL path normalization.

url-parse-nginx reproduces exactly what nginx does when it turns a raw request path into the normalized $uri: percent-decoding (%XX), resolution of . and .. segments, and // collapsing. It is a close port of two functions from nginx's src/http/ngx_http_parse.c:

  • ngx_http_parse_uri() — validates an origin-form path and detects whether normalization is needed.
  • ngx_http_parse_complex_uri() — performs the normalization.

The goal is byte-for-byte agreement with nginx, so that Rust code (proxies, routers, WAFs, security tooling) can reason about a path the same way nginx will. Agreement is not just claimed but continuously checked by a differential fuzzer that runs the real nginx C code against this port (see below).

Scope

  • Origin-form paths only (starting with /) — i.e. the semantics of the HTTP/2 and HTTP/3 :path pseudo-header. Absolute-form (http://host/path), authority-form (CONNECT), and OPTIONS * are out of scope.
  • Targets the Linux, non-debug build of nginx (NGX_WIN32 / NGX_DEBUG off).

Usage

use std::borrow::Cow;
use url_parse_nginx::parse_origin_form;

// merge_slashes = true matches nginx's default `merge_slashes on`.
// The result is a `Parsed { path: Cow<[u8]>, args: Option<&[u8]> }`.
// Deref the path (&*) to compare against a byte slice.
assert_eq!(&*parse_origin_form(b"/a/./b/../c", true).unwrap().path, b"/c");
assert_eq!(&*parse_origin_form(b"/%66oo", true).unwrap().path, b"/foo");
assert_eq!(&*parse_origin_form(b"/a//b", true).unwrap().path, b"/a/b");
assert_eq!(&*parse_origin_form(b"/a//b", false).unwrap().path, b"/a//b");

// The path corresponds to nginx's initial $uri; the query is returned
// separately in `args`, corresponding to the initial $args.
let n = parse_origin_form(b"/foo/../bar?x=1", true).unwrap();
assert_eq!(&*n.path, b"/bar");
assert_eq!(n.args, Some(&b"x=1"[..]));

// No query component -> args is None.
assert_eq!(parse_origin_form(b"/foo", true).unwrap().args, None);

// A "simple" path that needs no normalization borrows the input — no allocation.
assert!(matches!(parse_origin_form(b"/foo/bar", true).unwrap().path, Cow::Borrowed(_)));

// Paths nginx rejects return Err (e.g. escaping above the root).
assert!(parse_origin_form(b"/../", true).is_err());

parse_origin_form returns:

  • Ok(Parsed { path, args }):
    • path: Cow<[u8]> — the normalized path corresponding to nginx's initial $uri, with the query string excluded. A path that needs no normalization borrows the input unchanged (Cow::Borrowed) with no allocation; a normalized path returns an owned buffer (Cow::Owned).
    • args: Option<&[u8]> — the query string corresponding to nginx's initial $args: the bytes after the first ?, up to a # fragment or the end of the target. It always borrows the input and is never normalized. None means nginx found no query arguments; Some(b"") marks the empty query before a fragment in a target such as /a?#f.
  • Err(ParseError) — the request target could not be parsed.

How the equivalence is verified

The repository contains developer tooling (not shipped in the published crate):

  • nginx-reference/ — a tiny C shared object that calls the real nginx ngx_http_parse_uri / ngx_http_parse_complex_uri. The C is generated verbatim from a pinned official nginx release fetched from nginx.org by tools/extract.sh.
  • fuzz/ — a differential fuzzer that feeds the same inputs to nginx (C) and to this crate and asserts identical results (normalized path, query string including absent versus present-but-empty, and accept/reject), across both merge_slashes values. It also checks PATH_ESCAPE_SET against nginx's percent-encoder for all 256 possible byte values.
  • bench/ — a micro-benchmark comparing this crate's parse speed against the real nginx C code (see below).
# exhaustive corpus ("/", "/"+1..3 arbitrary bytes) + random inputs
cd fuzz
cargo run --release -- <iterations> <seed>

Before random generation, the fuzzer exhaustively checks / followed by every 1-, 2-, and 3-byte suffix (~16.7M cases). Random fuzzing then extends coverage to longer inputs.

Benchmark

bench/ times the Rust port against the real nginx C code on long (~1 KiB) URLs, chosen so that the fixed per-call allocation cost does not dominate the measured parse time. It cross-checks that C and Rust agree on each input before timing, then reports the best (minimum) average ns/op over several rounds.

cd bench
cargo run --release

Both sides are compiled at -O3 for a fair comparison. Four inputs exercise the fast path (no normalization → the Rust port borrows the input) and the three normalization paths (%XX decoding, ./.. resolution, // merging).

One representative run on an AMD Ryzen 9 5900X (x86-64) with rustc 1.97.1 produced:

case bytes C ns/op Rust ns/op speedup
simple (no normalization) 1001 561.9 517.3 1.09x
percent-decode 1001 1945.7 2242.5 0.87x
dot-dot resolution 1000 1928.2 2779.9 0.69x
slash merge 1000 1823.7 2264.6 0.81x

speedup is C ns/op divided by Rust ns/op, so values above 1.0x mean the Rust port is faster. These figures are illustrative: absolute timings and ratios vary with the CPU, compiler version, system load, and code layout. Run the benchmark locally when comparing changes.

Relationship to nginx and license

This crate is a derivative work of nginx and reuses nginx source (a Rust port in src/lib.rs, and verbatim C in the fuzz harness). nginx is licensed under the 2-clause BSD license, so this crate is distributed under the same license and retains the original nginx copyright notice.

See LICENSE and NOTICE for details, and nginx-reference/tools/extract.sh for the exact pinned nginx version.