Percent-encoding

rusty-js-percent-encoding is Cruft's percent-encoding codec behind encodeURIComponent and decodeURIComponent. It encodes bytes against a named encode set for each URL position, and offers separate strict and lenient decoders so the caller chooses deliberately how a malformed % triplet is handled rather than the crate guessing.

Percent-encoding sits behind encodeURIComponent, decodeURIComponent, and the crates.io percent-encoding crate: a string goes in, %-escaped ASCII comes back. Two questions hide inside that call: which characters it escapes (the encode set), and what it does with a % that is not followed by two hex digits (a malformed triplet). Those two questions are the whole security surface of percent-encoding, and the answers differ between encoding a query string and decoding a URL path from an attacker. Cruft's rusty-js-percent-encoding replaces the crates.io percent-encoding crate, and this page is about how it answers those two questions today.

Alpha, do not rely on it in production. rusty-js-percent-encoding 0.0.10 (Cruft 0.0.10) is a stand-in for the crates.io percent-encoding crate. It has not been externally audited and has near-zero interop testing beyond its in-tree unit tests. Treat every capability below as young except where a specific test or fuzz target is cited. Do not put it on an adversarial-input path in production.

Encoding targets a named set

encodeURIComponent reads like one function with one behavior, but percent-encoding always encodes a byte sequence against a chosen encode set: the set of bytes that must be escaped in this position of a URL. A byte in the query has different escaping rules than the same byte in userinfo or a fragment. The crates.io percent-encoding crate makes this explicit with its AsciiSet constants, and so does Cruft.

rusty-js-percent-encoding exposes an EncodeSet (a [bool; 256] table) and named constants that match the WHATWG URL fragment/path/userinfo/query positions plus the RFC 3986 classes: UNRESERVED, RESERVED, CONTROLS, FRAGMENT, PATH, USERINFO, SPECIAL_QUERY, and COMPONENT. encode(bytes, set) walks the input and escapes any byte that is >= 0x80 or is in the set, emitting uppercase hex. The unreserved, path, userinfo, and multibyte-UTF-8 cases are covered directly: encode(b"AZaz09-._~", &UNRESERVED) round-trips unescaped, encode("x\u{2025}y") yields x%E2%80%A5y, and userinfo escapes :/@[.

There is no single encode. Picking the wrong EncodeSet for the URL position being written into produces a syntactically valid but semantically wrong URL. That is a caller responsibility, not something the crate can check.

Strict and lenient decode are different security postures

When the decoder meets % not followed by two hex digits, there are two defensible answers, and they are different security decisions, so rusty-js-percent-encoding provides two separate functions rather than one that guesses.

  • decode(input) -> Result<Vec<u8>, PercentError> is strict. A % with fewer than two following bytes returns PercentError::TruncatedTriplet; a % followed by non-hex returns PercentError::InvalidHex. Nothing is silently swallowed. decode("%") and decode("%G0") return those two errors respectively, and decode("A%20B%2FC%7E") yields A B/C~.
  • decode_lenient(input) -> Vec<u8> is permissive, matching the WHATWG "percent-decode never fails" behavior: a malformed % is passed through as a literal % byte and scanning continues. decode_lenient("a%2"), "%Xy", "100%", and "%G0" all pass the stray % through unchanged.

This distinction is the one that matters for security. Lenient decoding is what browsers do, but pass-through-on-malformed is exactly the behavior that enables %-smuggling if a downstream layer decodes a second time. Choosing strict versus lenient is a decision the caller must make deliberately; the crate refuses to make it silently by giving two named entry points rather than one.

What it does not do: encoded-slash traversal

This crate does not protect against encoded-slash path traversal (%2f decoding to / and escaping a path segment), and by design it should not. Percent-decoding faithfully turns %2F into a / byte; deciding whether a decoded / is allowed to split a path segment is the URL layer's responsibility, not the codec's. This crate is an RFC 3986 §2.1 transducer over bytes. It has no notion of URL structure, no host parsing, no path normalization, and no IDNA. Any of that belongs in the url layer that sits on top of this one.

It also does not decode into String. decode and decode_lenient return Vec<u8>, because a percent-decoded byte stream is not guaranteed to be valid UTF-8. Turning those bytes into text (and deciding what to do with invalid sequences) is again the caller's or the URL layer's decision.

Security and correctness: memory-safe, small surface

The crate has no unsafe. decode uses std::str::from_utf8 on the two candidate hex bytes and maps any error to InvalidHex rather than unwrapping; decode_lenient validates each hex byte with is_ascii_hexdigit before combining nibbles with an inline hex_value helper. Indexing is bounds-guarded by the i + 2 >= bytes.len() truncation checks before any triplet slice. The encode set tables are built at compile time with const fn table constructors, so the classification is data, not branching logic. The hex layer is delegated to the sibling rusty-js-basen base16 surface, so this crate owns URL-shape logic and leans on basen for the nibble math.

Robustness: panics removed, fuzz target in place

Two robustness concerns once touched this crate: two latent .expect() calls in decode_lenient that were guarded by invariants but could arm a panic under a future refactor, and the fact that no codec had a fuzz target despite all of them decoding untrusted input. Both are now closed. The panic-shaped .expect() paths were removed: decode_lenient validates and combines nibbles inline with no unwrap or expect anywhere in the file. And a fuzz target now exists for percent-decoding: it feeds arbitrary bytes (via String::from_utf8_lossy) through decode and decode_lenient, and round-trips encode under the COMPONENT set back through decode. The scaffold compile-checks; a long, coverage-logged campaign against a corpus has not been run.

Limitations

The crate is small and memory-safe, and its unit tests back the specific vectors cited above. The maturity gap against crates.io percent-encoding is real and worth sizing:

  • Interop and conformance breadth is unproven. The tests are a handful of chosen vectors, not the WHATWG URL test suite or a differential comparison against browsers. Whether the PATH / USERINFO sets match WHATWG in general is untested; only the exact strings asserted in the unit tests are covered. The crates.io crate is years-proven across the Rust ecosystem; this one is days-proven inside Cruft.
  • The fuzz target is new. It exists and compile-checks, but a fuzz target that has not accumulated meaningful CPU-hours is a scaffold, not a coverage proof. Treat "fuzzed" here as wired for fuzzing, not fuzz-hardened.
  • Encode-set selection is unchecked. The crate cannot tell you that you picked FRAGMENT where you needed USERINFO. That correctness burden is entirely the caller's.
  • It is a codec. Every URL-structural protection (%2f traversal, host validation, normalization) lives one layer up and is out of scope here by design.