Use empty binary, not nil, for static table entries with no default value - #27
Conversation
…alue
HPAX.Table's @static_table stored `nil` as the value for the many
static entries that have no default (RFC 7541, Appendix A - e.g.
:authority, host, user-agent). Decoding a plain Indexed Header Field
Representation (section 6.1) against one of these entries - fully
legal per the RFC, and something real HTTP/2 clients (and h2spec) do
- returned that literal `nil` as the header value, silently violating
this module's own documented contract that decoded values are always
binaries (HPAX.header_value() :: binary()).
Any caller relying on that contract (e.g. calling byte_size/1 on
every decoded value) would crash on such a request. This also meant
the encoder could never emit a compact single-byte indexed reference
when asked to encode one of these headers with a literal empty-string
value, since the {:full, index} match required by lookup_by_header/3
was guarded with `is_binary(value)` and so never fired for the
nil-valued entries.
Storing "" instead of nil fixes both: decoded values are always
binaries as documented, and encoding a header whose value happens to
be "" for one of these names now correctly compresses to one byte.
Covers the encoder-side half of the previous commit's fix: headers
whose value matches a static table entry with no defined default
(e.g. "user-agent": "") can now be found via a {:full, index} match
and encoded as a single indexed-header-field byte, rather than always
falling back to a literal encoding because the {:full, _} lookup
clause was guarded with is_binary(value) and so never matched the
old nil-valued entries.
|
This also stands ready for review, split out from #26. I've had the robots go over all of hpax's dependent libraries per hex.pm to see if any of them may require updates to this (since it's technically a change in external behaviour) and all came up clean, other than Bandit (which as of recently now checks for decoded header size, and was bailing on the nils that were emitted here as a result). |
|
Isn't this technically changing behaviour, though? I understand it was spec'ed without |
|
That's exactly why I broke it out into a separate PR. On the encode side this is a clear win, since we were never encoding down to a single byte for many static entries. On the decode side it's less clear; on the one hand I kinda like the "" representation since that's closer to what you'd actually parse out of headers on the wire, but it is a change. Up to you (obviously) which was you want to go here. I can easily fix Bandit to work well with either decode style |
|
I can't remember if HPACK can represent absence vs empty values; given the PR, I assume they're indistinguishable? |
HPAX has never (either with or without this PR) been able to encode headers with a nil value (ie: The first of the differences is fairly minor, having to do with encoding a header that's in the static table, in the particular case where its value is "". On main, we get the following: With this PR: They both roundtrip to the same thing, but HPAX as it stands today will create a new row in the table to store this row per RFC7541§6.2.1 (Literal Header Field with Incremental Indexing). This PR optimizes this case to instead encode it per RFC751§6.1 (Indexed Header Field Representation), saving a byte on the wire and not consuming a context entry. It's a tiny optimization, and not really worth it on its own. The second difference is the more impactful one. If a peer happens to send a static table entry with a "" value in the minimally encoded form (ie: per RFC751§6.1 (Indexed Header Field Representation), HPAX will decode it as follows: On main: With this PR: There's a bit of an asymmetry there - we currently don't accept Again, it's a fairly minor corner in practice, though it IS a legal one. In particular, h2spec will send such headers in its |
|
Ok, I’m on board with this change. Thank you so much for (patiently) walking me through the change and for the explanation 🫶 |
|
Thanks for the merge! Could I bug you to push a new version with this change? |
Summary
HPAX.Table's@static_tablestoresnilas the value for the many static entries with no default (RFC 7541, Appendix A — e.g.:authority,host,user-agent). Decoding a plain Indexed Header Field Representation (RFC 7541 §6.1) against one of these — fully legal, and something real clients (and h2spec) do — returns that literalnil, silently violating this module's own documented contract that decoded values are always binaries (HPAX.header_value() :: binary()):Any caller relying on that documented contract (e.g. calling
byte_size/1on every decoded value) crashes on such a request. It also meant the encoder could never emit a compact single-byte indexed reference when asked to encode one of these headers with a literal""value, since the{:full, index}match inlookup_by_header/3is guarded withis_binary(value)and never fired for the nil-valued entries.Fix
Store
""instead ofnilfor these static table entries. Decoded values are now always binaries as documented, and encoding a header whose value is""for one of these names now correctly compresses to one byte.Test plan
""notnil""value now compresses to a single bytenil-based behavior