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:pathpseudo-header. Absolute-form (http://host/path), authority-form (CONNECT), andOPTIONS *are out of scope. - Targets the Linux, non-debug build of nginx (
NGX_WIN32/NGX_DEBUGoff). - Supports
no_stdenvironments withalloc. Normalized paths allocate only when their bytes differ from the input.
Usage
use Cow;
use parse_origin_form;
// `parse_origin_form(target, merge_slashes)` parses an origin-form request target.
// `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!;
assert_eq!;
assert_eq!;
assert_eq!;
// The path corresponds to nginx's `$uri`; the query is returned separately in `args`,
// corresponding to nginx's `$args`.
let n = parse_origin_form.unwrap;
assert_eq!;
assert_eq!;
// No query component -> args is None.
assert_eq!;
// A "simple" path that needs no normalization borrows the input — no allocation.
assert!;
// Paths nginx rejects return Err (e.g. escaping above the root).
assert!;
The default percent-encoding feature provides PATH_ESCAPE_SET and enables
the optional percent-encoding dependency. Disable it when only parsing is
needed:
= { = "...", = false }
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.Nonemeans 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 nginxngx_http_parse_uri/ngx_http_parse_complex_uri. The C is generated verbatim from a pinned official nginx release fetched from nginx.org bytools/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 bothmerge_slashesvalues. It also checksPATH_ESCAPE_SETagainst 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, "/"+4 printable bytes) + random inputs
Before random generation, the fuzzer exhaustively checks / followed by every
1-, 2-, and 3-byte suffix (~16.7M cases), and then / followed by every 4-byte
suffix of printable ASCII (0x20..=0x7e, 95^4 ≈ 81.5M 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.
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.