Skip to main content

armature_h1/
header.rs

1//! Header name interning and the header list type.
2//!
3//! Well-known field names collapse to a discriminant, so a lookup is an integer
4//! comparison rather than the case-insensitive string comparison a conventional
5//! header map performs.
6
7use crate::ByteStr;
8use bytes::Bytes;
9use smallvec::SmallVec;
10
11/// An interned HTTP field name.
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub enum HeaderId {
14    /// `host`
15    Host,
16    /// `content-length`
17    ContentLength,
18    /// `content-type`
19    ContentType,
20    /// `transfer-encoding`
21    TransferEncoding,
22    /// `connection`
23    Connection,
24    /// `expect`
25    Expect,
26    /// `upgrade`
27    Upgrade,
28    /// `date`
29    Date,
30    /// `server`
31    Server,
32    /// `accept`
33    Accept,
34    /// `accept-encoding`
35    AcceptEncoding,
36    /// `accept-language`
37    AcceptLanguage,
38    /// `authorization`
39    Authorization,
40    /// `cache-control`
41    CacheControl,
42    /// `cookie`
43    Cookie,
44    /// `set-cookie`
45    SetCookie,
46    /// `etag`
47    Etag,
48    /// `if-none-match`
49    IfNoneMatch,
50    /// `if-modified-since`
51    IfModifiedSince,
52    /// `last-modified`
53    LastModified,
54    /// `location`
55    Location,
56    /// `referer`
57    Referer,
58    /// `user-agent`
59    UserAgent,
60    /// `vary`
61    Vary,
62    /// `allow`
63    Allow,
64    /// `trailer`
65    Trailer,
66    /// `te`
67    Te,
68    /// `content-encoding`
69    ContentEncoding,
70    /// `content-language`
71    ContentLanguage,
72    /// `range`
73    Range,
74    /// `if-match`
75    IfMatch,
76    /// `if-unmodified-since`
77    IfUnmodifiedSince,
78    /// `origin`
79    Origin,
80    /// Any other valid field name, lowercased at parse time.
81    Other(ByteStr),
82}
83
84impl HeaderId {
85    /// Every well-known variant, for round-trip testing and diagnostics.
86    pub const WELL_KNOWN: &'static [HeaderId] = &[
87        HeaderId::Host,
88        HeaderId::ContentLength,
89        HeaderId::ContentType,
90        HeaderId::TransferEncoding,
91        HeaderId::Connection,
92        HeaderId::Expect,
93        HeaderId::Upgrade,
94        HeaderId::Date,
95        HeaderId::Server,
96        HeaderId::Accept,
97        HeaderId::AcceptEncoding,
98        HeaderId::AcceptLanguage,
99        HeaderId::Authorization,
100        HeaderId::CacheControl,
101        HeaderId::Cookie,
102        HeaderId::SetCookie,
103        HeaderId::Etag,
104        HeaderId::IfNoneMatch,
105        HeaderId::IfModifiedSince,
106        HeaderId::LastModified,
107        HeaderId::Location,
108        HeaderId::Referer,
109        HeaderId::UserAgent,
110        HeaderId::Vary,
111        HeaderId::Allow,
112        HeaderId::Trailer,
113        HeaderId::Te,
114        HeaderId::ContentEncoding,
115        HeaderId::ContentLanguage,
116        HeaderId::Range,
117        HeaderId::IfMatch,
118        HeaderId::IfUnmodifiedSince,
119        HeaderId::Origin,
120    ];
121
122    /// Match a field name against the well-known set, case-insensitively.
123    ///
124    /// Returns `None` when the name is not well-known; the caller then builds
125    /// [`HeaderId::Other`] from the read buffer.
126    ///
127    /// Dispatching on length first bounds each call to one integer comparison
128    /// plus a short case-insensitive compare. Field names are case-insensitive
129    /// per RFC 9110 section 5.1.
130    #[inline]
131    pub fn from_bytes(name: &[u8]) -> Option<HeaderId> {
132        macro_rules! m {
133            ($($lit:literal => $variant:expr),+ $(,)?) => {{
134                $(if name.eq_ignore_ascii_case($lit) { return Some($variant); })+
135                None
136            }};
137        }
138
139        match name.len() {
140            2 => m!(b"te" => HeaderId::Te),
141            4 => m!(
142                b"host" => HeaderId::Host,
143                b"date" => HeaderId::Date,
144                b"vary" => HeaderId::Vary,
145                b"etag" => HeaderId::Etag,
146            ),
147            5 => m!(b"allow" => HeaderId::Allow, b"range" => HeaderId::Range),
148            6 => m!(
149                b"accept" => HeaderId::Accept,
150                b"expect" => HeaderId::Expect,
151                b"cookie" => HeaderId::Cookie,
152                b"server" => HeaderId::Server,
153                b"origin" => HeaderId::Origin,
154            ),
155            7 => m!(
156                b"upgrade" => HeaderId::Upgrade,
157                b"trailer" => HeaderId::Trailer,
158                b"referer" => HeaderId::Referer,
159            ),
160            8 => m!(b"if-match" => HeaderId::IfMatch, b"location" => HeaderId::Location),
161            10 => m!(
162                b"connection" => HeaderId::Connection,
163                b"set-cookie" => HeaderId::SetCookie,
164                b"user-agent" => HeaderId::UserAgent,
165            ),
166            12 => m!(b"content-type" => HeaderId::ContentType),
167            13 => m!(
168                b"authorization" => HeaderId::Authorization,
169                b"cache-control" => HeaderId::CacheControl,
170                b"if-none-match" => HeaderId::IfNoneMatch,
171                b"last-modified" => HeaderId::LastModified,
172            ),
173            14 => m!(b"content-length" => HeaderId::ContentLength),
174            15 => m!(
175                b"accept-encoding" => HeaderId::AcceptEncoding,
176                b"accept-language" => HeaderId::AcceptLanguage,
177            ),
178            16 => m!(
179                b"content-encoding" => HeaderId::ContentEncoding,
180                b"content-language" => HeaderId::ContentLanguage,
181            ),
182            17 => m!(
183                b"transfer-encoding" => HeaderId::TransferEncoding,
184                b"if-modified-since" => HeaderId::IfModifiedSince,
185            ),
186            19 => m!(b"if-unmodified-since" => HeaderId::IfUnmodifiedSince),
187            _ => None,
188        }
189    }
190
191    /// The canonical lowercase field name.
192    #[inline]
193    pub fn as_str(&self) -> &str {
194        match self {
195            HeaderId::Host => "host",
196            HeaderId::ContentLength => "content-length",
197            HeaderId::ContentType => "content-type",
198            HeaderId::TransferEncoding => "transfer-encoding",
199            HeaderId::Connection => "connection",
200            HeaderId::Expect => "expect",
201            HeaderId::Upgrade => "upgrade",
202            HeaderId::Date => "date",
203            HeaderId::Server => "server",
204            HeaderId::Accept => "accept",
205            HeaderId::AcceptEncoding => "accept-encoding",
206            HeaderId::AcceptLanguage => "accept-language",
207            HeaderId::Authorization => "authorization",
208            HeaderId::CacheControl => "cache-control",
209            HeaderId::Cookie => "cookie",
210            HeaderId::SetCookie => "set-cookie",
211            HeaderId::Etag => "etag",
212            HeaderId::IfNoneMatch => "if-none-match",
213            HeaderId::IfModifiedSince => "if-modified-since",
214            HeaderId::LastModified => "last-modified",
215            HeaderId::Location => "location",
216            HeaderId::Referer => "referer",
217            HeaderId::UserAgent => "user-agent",
218            HeaderId::Vary => "vary",
219            HeaderId::Allow => "allow",
220            HeaderId::Trailer => "trailer",
221            HeaderId::Te => "te",
222            HeaderId::ContentEncoding => "content-encoding",
223            HeaderId::ContentLanguage => "content-language",
224            HeaderId::Range => "range",
225            HeaderId::IfMatch => "if-match",
226            HeaderId::IfUnmodifiedSince => "if-unmodified-since",
227            HeaderId::Origin => "origin",
228            HeaderId::Other(s) => s.as_str(),
229        }
230    }
231
232    /// Whether this field is hop-by-hop and must not be forwarded.
233    #[inline]
234    pub fn is_hop_by_hop(&self) -> bool {
235        matches!(
236            self,
237            HeaderId::Connection
238                | HeaderId::TransferEncoding
239                | HeaderId::Te
240                | HeaderId::Trailer
241                | HeaderId::Upgrade
242        )
243    }
244
245    /// Whether RFC 9110 section 6.5.1 forbids this field in a trailer section.
246    ///
247    /// Framing fields are the critical entries: a `Transfer-Encoding` or
248    /// `Content-Length` accepted from a trailer is a request-smuggling vector,
249    /// because framing was already decided before the trailer was read.
250    #[inline]
251    pub fn forbidden_in_trailers(&self) -> bool {
252        matches!(
253            self,
254            HeaderId::TransferEncoding
255                | HeaderId::ContentLength
256                | HeaderId::Host
257                | HeaderId::Connection
258                | HeaderId::Expect
259                | HeaderId::Te
260                | HeaderId::Trailer
261                | HeaderId::Upgrade
262                | HeaderId::CacheControl
263                | HeaderId::Authorization
264                | HeaderId::SetCookie
265        )
266    }
267}
268
269/// A request or response header list.
270///
271/// Sixteen inline slots covers the overwhelming majority of real requests, so
272/// the list itself does not allocate. Values are [`Bytes`] slices of the
273/// connection read buffer.
274pub type HeaderVec = SmallVec<[(HeaderId, Bytes); 16]>;
275
276/// The first value for `id`, or `None`.
277///
278/// A linear scan over at most a few dozen entries beats hashing a field name,
279/// and it never allocates.
280#[inline]
281pub fn get<'a>(v: &'a HeaderVec, id: &HeaderId) -> Option<&'a Bytes> {
282    v.iter().find(|(k, _)| k == id).map(|(_, val)| val)
283}
284
285/// The first value for `id` as a string, or `None` if absent or not UTF-8.
286#[inline]
287pub fn get_str<'a>(v: &'a HeaderVec, id: &HeaderId) -> Option<&'a str> {
288    get(v, id).and_then(|b| std::str::from_utf8(b).ok())
289}
290
291/// Every value for `id`, in wire order.
292#[inline]
293pub fn all<'a>(v: &'a HeaderVec, id: &'a HeaderId) -> impl Iterator<Item = &'a Bytes> + 'a {
294    v.iter().filter(move |(k, _)| k == id).map(|(_, val)| val)
295}
296
297/// How many times `id` appears.
298///
299/// Framing correctness depends on this: exactly one `Host` is required on
300/// HTTP/1.1, and duplicate `Content-Length` fields must be rejected.
301#[inline]
302pub fn count(v: &HeaderVec, id: &HeaderId) -> usize {
303    v.iter().filter(|(k, _)| k == id).count()
304}
305
306/// Intern a field name: a well-known variant, or a lowercased [`HeaderId::Other`].
307///
308/// Lowercasing here means every later comparison is a plain byte compare instead
309/// of a case-insensitive one. It allocates only for a name that is not
310/// well-known, which is the uncommon case.
311#[inline]
312pub fn intern(name: &str) -> HeaderId {
313    if let Some(id) = HeaderId::from_bytes(name.as_bytes()) {
314        return id;
315    }
316    if name.bytes().any(|b| b.is_ascii_uppercase()) {
317        return HeaderId::Other(ByteStr::from(name.to_ascii_lowercase()));
318    }
319    HeaderId::Other(ByteStr::from(name))
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn well_known_names_intern_case_insensitively() {
328        assert_eq!(HeaderId::from_bytes(b"host"), Some(HeaderId::Host));
329        assert_eq!(HeaderId::from_bytes(b"Host"), Some(HeaderId::Host));
330        assert_eq!(HeaderId::from_bytes(b"HOST"), Some(HeaderId::Host));
331        assert_eq!(HeaderId::from_bytes(b"hOsT"), Some(HeaderId::Host));
332        assert_eq!(
333            HeaderId::from_bytes(b"content-length"),
334            Some(HeaderId::ContentLength)
335        );
336        assert_eq!(
337            HeaderId::from_bytes(b"Transfer-Encoding"),
338            Some(HeaderId::TransferEncoding)
339        );
340        assert_eq!(HeaderId::from_bytes(b"TE"), Some(HeaderId::Te));
341    }
342
343    #[test]
344    fn unknown_names_are_not_well_known() {
345        assert_eq!(HeaderId::from_bytes(b"x-request-id"), None);
346        assert_eq!(HeaderId::from_bytes(b""), None);
347        // A prefix of a known name must not match it.
348        assert_eq!(HeaderId::from_bytes(b"hos"), None);
349        assert_eq!(HeaderId::from_bytes(b"hostx"), None);
350    }
351
352    #[test]
353    fn as_str_uses_canonical_casing_and_round_trips() {
354        assert_eq!(HeaderId::Host.as_str(), "host");
355        assert_eq!(HeaderId::ContentLength.as_str(), "content-length");
356        assert_eq!(HeaderId::Te.as_str(), "te");
357        for id in HeaderId::WELL_KNOWN {
358            assert_eq!(
359                HeaderId::from_bytes(id.as_str().as_bytes()).as_ref(),
360                Some(id),
361                "{} failed to round-trip",
362                id.as_str()
363            );
364        }
365    }
366
367    #[test]
368    fn hop_by_hop_classified() {
369        assert!(HeaderId::Connection.is_hop_by_hop());
370        assert!(HeaderId::TransferEncoding.is_hop_by_hop());
371        assert!(HeaderId::Te.is_hop_by_hop());
372        assert!(HeaderId::Trailer.is_hop_by_hop());
373        assert!(HeaderId::Upgrade.is_hop_by_hop());
374        assert!(!HeaderId::ContentType.is_hop_by_hop());
375        assert!(!HeaderId::Host.is_hop_by_hop());
376    }
377
378    /// RFC 9110 section 6.5.1: a trailer section must not carry framing,
379    /// routing, or request-modifier fields. Accepting Transfer-Encoding or
380    /// Content-Length in a trailer is a smuggling vector.
381    #[test]
382    fn framing_headers_forbidden_in_trailers() {
383        assert!(HeaderId::TransferEncoding.forbidden_in_trailers());
384        assert!(HeaderId::ContentLength.forbidden_in_trailers());
385        assert!(HeaderId::Host.forbidden_in_trailers());
386        assert!(HeaderId::Connection.forbidden_in_trailers());
387        assert!(HeaderId::Expect.forbidden_in_trailers());
388        assert!(HeaderId::Te.forbidden_in_trailers());
389        assert!(HeaderId::Trailer.forbidden_in_trailers());
390        assert!(HeaderId::Upgrade.forbidden_in_trailers());
391        assert!(!HeaderId::Etag.forbidden_in_trailers());
392        assert!(!HeaderId::ContentType.forbidden_in_trailers());
393    }
394
395    fn vec_of(pairs: &[(HeaderId, &'static str)]) -> HeaderVec {
396        pairs
397            .iter()
398            .map(|(id, v)| (id.clone(), Bytes::from_static(v.as_bytes())))
399            .collect()
400    }
401
402    #[test]
403    fn get_returns_first_match() {
404        let v = vec_of(&[
405            (HeaderId::Host, "a.example"),
406            (HeaderId::ContentLength, "5"),
407            (HeaderId::Host, "b.example"),
408        ]);
409        assert_eq!(get_str(&v, &HeaderId::Host), Some("a.example"));
410        assert_eq!(get_str(&v, &HeaderId::ContentLength), Some("5"));
411        assert_eq!(get(&v, &HeaderId::ContentType), None);
412    }
413
414    #[test]
415    fn count_and_all_see_every_occurrence() {
416        let v = vec_of(&[
417            (HeaderId::Host, "a.example"),
418            (HeaderId::Host, "b.example"),
419            (HeaderId::ContentLength, "5"),
420        ]);
421        assert_eq!(count(&v, &HeaderId::Host), 2);
422        assert_eq!(count(&v, &HeaderId::ContentLength), 1);
423        assert_eq!(count(&v, &HeaderId::Date), 0);
424        let hosts: Vec<_> = all(&v, &HeaderId::Host).collect();
425        assert_eq!(hosts.len(), 2);
426        assert_eq!(&hosts[1][..], b"b.example");
427    }
428
429    #[test]
430    fn custom_names_compare_by_value() {
431        let x = HeaderId::Other(ByteStr::from_static("x-request-id"));
432        let mut v = HeaderVec::new();
433        v.push((x.clone(), Bytes::from_static(b"abc")));
434        assert_eq!(get_str(&v, &x), Some("abc"));
435        assert_eq!(
436            get(&v, &HeaderId::Other(ByteStr::from_static("x-other"))),
437            None
438        );
439    }
440
441    #[test]
442    fn get_str_rejects_non_utf8_values() {
443        let mut v = HeaderVec::new();
444        v.push((HeaderId::ContentType, Bytes::from_static(&[0xff, 0xfe])));
445        assert_eq!(get_str(&v, &HeaderId::ContentType), None);
446        assert!(get(&v, &HeaderId::ContentType).is_some());
447    }
448
449    /// Sixteen inline slots covers the overwhelming majority of real requests,
450    /// so the header list itself never allocates.
451    #[test]
452    fn typical_request_stays_inline() {
453        let mut v = HeaderVec::new();
454        for _ in 0..16 {
455            v.push((HeaderId::Accept, Bytes::from_static(b"*/*")));
456        }
457        assert!(!v.spilled(), "16 headers must stay on the stack");
458        v.push((HeaderId::Accept, Bytes::from_static(b"*/*")));
459        assert!(v.spilled(), "17 headers is expected to spill");
460    }
461
462    #[test]
463    fn intern_prefers_well_known_and_lowercases_the_rest() {
464        assert_eq!(intern("Content-Length"), HeaderId::ContentLength);
465        assert_eq!(intern("content-length"), HeaderId::ContentLength);
466        // A custom name is lowercased once here so every later comparison is a
467        // plain byte compare rather than a case-insensitive one.
468        assert_eq!(
469            intern("X-Tenant-Id"),
470            HeaderId::Other(ByteStr::from_static("x-tenant-id"))
471        );
472        // Already lowercase: no allocation beyond the copy into Bytes.
473        assert_eq!(
474            intern("x-req-id"),
475            HeaderId::Other(ByteStr::from_static("x-req-id"))
476        );
477    }
478}