parse-net 0.9.3

Grammar for Internet message fields: RFC 9110 field syntax, media types, RFC 9651 Structured Fields, HTTP and RFC 5322 dates, negotiation, entity tags, ranges, Content-Disposition, Cache-Control, Forwarded, auth challenges, and the email lexical layer. no_std, no allocation, sans-IO, zero dependencies.
Documentation
# parse-net v0.9.1 - Extension parameters and B words

**Date:** 2026-11-05
**Compare:** [`v0.9.0...v0.9.1`](https://github.com/jamesgober/parse-net/compare/v0.9.0...v0.9.1)

**Two gaps consumers met adopting `0.9.0`.** ws-net found that `param::parse` refuses a parameter without a value, which `Sec-WebSocket-Extensions` allows: permessage-deflate (RFC 7692) sends `client_max_window_bits` and `server_no_context_takeover` with none. smtp-net needed the RFC 2047 `B` encoding, which waited on encode-lib. `0.9.1` adds `param::parse_optional` and `mail::encode_words_b`, and `decode_words` reads `B` words. No `0.9.0` call changes its signature.

**One widening.** `decode_words` reads `B` words it refused with `InvalidEncodedWord`. Every other input reads as it did.

## Added

- `param::parse_optional(s, limits)` reads `*( OWS ";" OWS [ token [ "=" ( token / quoted-string ) ] ] )`, the `extension-param` of RFC 6455 section 9.1, and returns `OptionalParams` and the bytes used, as `param::parse` does.
  - `OptionalParams::find(name)` returns the whole `OptionalParam`, whose `value` is `None` for a name sent alone; `contains(name)` asks whether the name is there at all; `iter`, `len`, `is_empty`, and `raw` are as on `Params`, and `Debug` prints names only.
  - Every other rule is `param::parse`'s, from the same walker: empty parameters, the bytes used, no whitespace around `=`, the duplicate policy (a bare `a` repeats `a=1`), and the 64-name limit under `Refuse`. `=` with no value is still `MissingValue`. Whether a quoted value unescapes to a token, as RFC 6455 asks, is the caller's check (D-079).
- `mail::encode_words_b(text, room_first_line, out)` writes `=?UTF-8?B?...?=` words of at most 75 bytes, split only between characters and folded as `encode_words` folds; text `encode_words` writes as it is goes out as it is. For text that is mostly not ASCII it is the shorter form: a 39-byte Japanese subject is one 64-byte word, against two words and 144 bytes in `Q`.

```rust
use parse_net::{mail, param, Limits, SliceSink};

let (p, _) = param::parse_optional(b"; client_max_window_bits; server_max_window_bits=10", &Limits::DEFAULT)?;
assert!(p.find("client_max_window_bits").is_some_and(|x| x.value.is_none()));
assert!(p.find("server_max_window_bits").and_then(|x| x.value).is_some_and(|v| v.eq_bytes(b"10")));

let mut buf = [0u8; 128];
let mut out = SliceSink::new(&mut buf);
mail::encode_words_b("\u{4f1a}\u{8b70}\u{306e}\u{304a}\u{77e5}\u{3089}\u{305b}", 64, &mut out).expect("room");
let mut text = [0u8; 64];
assert_eq!(mail::decode_words(out.written(), &Limits::DEFAULT, &mut text)?, "\u{4f1a}\u{8b70}\u{306e}\u{304a}\u{77e5}\u{3089}\u{305b}");
# Ok::<(), parse_net::ParseError>(())
```

## Changed

- `decode_words` reads `B` words, strictly, so that every byte string has one encoding: whole groups of four, `=` only as the last one or two characters with zero bits under it, nothing outside the alphabet, no whitespace inside the word, and at most 75 bytes to a word, which bounds the output to 48 bytes a word. Every decoded byte meets the Q rules: no control character (C0, DEL, C1), whole UTF-8 characters, ASCII for US-ASCII. A word that breaks one is `InvalidEncodedWord`, `ControlInDecoded`, or `InvalidUtf8`, at the offset of the group of four that holds it.
- The base64 is a private codec for one word at a time; there is no public base64 API. This reverses D-002 ("no private codec is added") for `B` words, for smtp-net and ws-net, and is recorded in `dev/READINESS-1.0.md` for James (D-080). The `encode-lib` feature stays, as a documented no-op, so builds that name it keep working; `basic_decode_into`, `ByteSeq::decode_into`, and `sfv::Writer::bytes` still wait for encode-lib.

## Performance

A full benchmark run on a quiet machine, the first since 0.7 (load average 0.7 to 2.0 on 32 threads):

- Every compared row is 25% to 81% faster than its competitor. `response_cache_control` is the narrowest at 25%; the loaded 0.9.0 run read 33%, and `headers` gained more from the quiet machine.
- Both estimates are met: `client_address` 37.6 ns against 60 ns, `encode_words` 176 ns against 200 ns.
- `decode_words` on one `B` word: 145 ns, against 373 ns for `mailparse`, 61% faster. `encode_words_b` on the Japanese subject: 103 ns, against 131 ns for `Q`.
- `param::parse_optional` and `find` on a permessage-deflate offer: 29.2 ns.
- `param::parse` and the Q paths stay within 1.5% of 0.9.0 in instructions, the size of the moves code layout makes in functions that did not change.

## Testing

- `param::parse_optional`: the RFC 7692 offers and the edge cases around a bare name; property tests against a plain reference reader, against `param::parse` (where `parse` succeeds, the same parameters; where it fails for anything but a missing value, the same error at the same offset), and for `iter`, `find`, and `contains`.
- `B` words: the RFC 4648 vectors both ways and the RFC 2047 section 8 `B` word; strictness at the offset of each refused character; the 75-byte limit; every control character of the threat test in `B` form; property tests that whatever decodes re-encodes to the same text, and that `encode_words_b` output unfolds and decodes back, in words of at most 75 bytes, as `encode_words` output does.
- Complexity cases for bare names and `B` words, counting-allocator coverage, and benchmark rows.
- Three new fuzz targets: `param_optional`, `mail_words_b` (the round trip and the decoder's output bound), and `diff_b64`, which reads `B` words against the `base64` crate's canonical engine and runs weekly in CI. All 37 targets ran 24 hours clean on 239c4d5, each in one piece with no clock-step resume; the new targets made 69.5 million (`param_optional`), 317 million (`mail_words_b`), and 1.32 billion (`diff_b64`) runs.

## What's next

`0.9.x`: ws-net on `parse_optional`, smtp-net on `encode_words_b`, web-net on parse-net, then the release candidates. `1.0.0` is James's decision; `dev/READINESS-1.0.md` lists what stands open, D-080 among it.

**Full Changelog:** https://github.com/jamesgober/parse-net/compare/v0.9.0...v0.9.1