Skip to main content

sozu_lib/protocol/kawa_h1/
parser.rs

1//! Kawa-adjacent H1 parsing helpers.
2//!
3//! Hosts the byte-comparison primitives, the `Method` enum, and the small
4//! nom helpers Kawa relies on when its tolerant/strict mode boundaries do
5//! not cover Sōzu-specific cases (custom methods, ASCII-fast-path
6//! comparisons). Pure-function module; no session state.
7
8use std::{
9    cmp::min,
10    fmt::{self, Write},
11    ops::Deref,
12};
13
14use nom::{
15    AsChar, Err, IResult, Parser,
16    bytes::{self, complete::take_while},
17    character::complete::digit1,
18    combinator::opt,
19    error::{Error, ErrorKind},
20    sequence::preceded,
21};
22
23pub fn compare_no_case(left: &[u8], right: &[u8]) -> bool {
24    if left.len() != right.len() {
25        return false;
26    }
27
28    left.iter().zip(right).all(|(a, b)| match (*a, *b) {
29        (0..=64, 0..=64) | (91..=96, 91..=96) | (123..=255, 123..=255) => a == b,
30        (65..=90, 65..=90) | (97..=122, 97..=122) | (65..=90, 97..=122) | (97..=122, 65..=90) => {
31            *a | 0b00_10_00_00 == *b | 0b00_10_00_00
32        }
33        _ => false,
34    })
35}
36
37#[derive(PartialEq, Eq, Clone)]
38pub enum Method {
39    Get,
40    Post,
41    Head,
42    Options,
43    Put,
44    Delete,
45    Trace,
46    Connect,
47    Custom(String),
48}
49
50impl fmt::Debug for Method {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Get => f.write_str("Get"),
54            Self::Post => f.write_str("Post"),
55            Self::Head => f.write_str("Head"),
56            Self::Options => f.write_str("Options"),
57            Self::Put => f.write_str("Put"),
58            Self::Delete => f.write_str("Delete"),
59            Self::Trace => f.write_str("Trace"),
60            Self::Connect => f.write_str("Connect"),
61            Self::Custom(custom) => write!(f, "Custom(bytes={})", custom.len()),
62        }
63    }
64}
65
66impl Method {
67    /// Classify a method token.
68    ///
69    /// RFC 9110 §9.1 makes the method token case-SENSITIVE, and the origin
70    /// server receives the bytes the client actually sent. The match is
71    /// therefore exact: anything that is not one of the eight canonical
72    /// spellings becomes [`Method::Custom`], carrying the token verbatim.
73    /// A lowercase `get` is a custom method, not [`Method::Get`]
74    /// (sozu-proxy/sozu#1451).
75    pub fn new(s: &[u8]) -> Method {
76        match s {
77            b"GET" => Method::Get,
78            b"POST" => Method::Post,
79            b"HEAD" => Method::Head,
80            b"OPTIONS" => Method::Options,
81            b"PUT" => Method::Put,
82            b"DELETE" => Method::Delete,
83            b"TRACE" => Method::Trace,
84            b"CONNECT" => Method::Connect,
85            _ => Method::Custom(String::from_utf8_lossy(s).into_owned()),
86        }
87    }
88
89    /// RFC 9110 §9.2.2: is re-issuing this request equivalent to issuing it
90    /// once?
91    ///
92    /// Used by the mux to decide whether a request already written to a
93    /// pooled upstream connection may be replayed on a fresh one after the
94    /// peer turned out to have closed it (`Stream::can_replay_on_fresh_
95    /// upstream`). `Method::Custom` is deliberately NOT idempotent: an
96    /// extension method sozu does not know the semantics of is treated like
97    /// POST, matching nginx's closed `POST, LOCK, PATCH` list being an
98    /// opt-in carve-out rather than an inferred property, and pingora's
99    /// `http::Method::is_idempotent` returning false for anything it does
100    /// not recognise as safe.
101    ///
102    /// `CONNECT` is excluded: RFC 9110 §9.3.6 makes it neither safe nor
103    /// idempotent, and sozu upgrades it out of the request/response path
104    /// anyway.
105    ///
106    /// The classification this reads is case-sensitive, so it agrees with
107    /// what the origin sees: [`Method::new`] matches the eight canonical
108    /// spellings exactly (RFC 9110 §9.1), and a lowercase `get` is a
109    /// [`Method::Custom`] judged non-idempotent here rather than a
110    /// [`Method::Get`] judged replayable. That divergence was real until
111    /// sozu-proxy/sozu#1451 made the match exact; the stale-upstream replay
112    /// (sozu-proxy/sozu#1442) was the caller for which it was load-bearing.
113    pub fn is_idempotent(&self) -> bool {
114        match self {
115            Method::Get
116            | Method::Head
117            | Method::Put
118            | Method::Delete
119            | Method::Options
120            | Method::Trace => true,
121            Method::Post | Method::Connect | Method::Custom(_) => false,
122        }
123    }
124}
125
126impl AsRef<str> for Method {
127    fn as_ref(&self) -> &str {
128        match self {
129            Self::Get => "GET",
130            Self::Post => "POST",
131            Self::Head => "HEAD",
132            Self::Options => "OPTIONS",
133            Self::Put => "PUT",
134            Self::Delete => "DELETE",
135            Self::Trace => "TRACE",
136            Self::Connect => "CONNECT",
137            Self::Custom(custom) => custom,
138        }
139    }
140}
141
142impl Deref for Method {
143    type Target = str;
144
145    fn deref(&self) -> &Self::Target {
146        self.as_ref()
147    }
148}
149
150impl fmt::Display for Method {
151    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
152        write!(f, "{}", self.as_ref())
153    }
154}
155
156#[cfg(feature = "tolerant-http1-parser")]
157fn is_hostname_char(i: u8) -> bool {
158    i.is_alphanum() ||
159  // the domain name should not start with a hyphen or dot
160  // but is it important here, since we will match this to
161  // the list of accepted clusters?
162  // BTW each label between dots has a max of 63 chars,
163  // and the whole domain should not be larger than 253 chars
164  //
165  // this tolerant parser also allows underscore, which is wrong
166  // in domain names but accepted by some proxies and web servers
167  // see https://github.com/sozu-proxy/sozu/issues/480
168  b"-._".contains(&i)
169}
170
171#[cfg(not(feature = "tolerant-http1-parser"))]
172fn is_hostname_char(i: u8) -> bool {
173    i.is_alphanum() ||
174  // the domain name should not start with a hyphen or dot
175  // but is it important here, since we will match this to
176  // the list of accepted clusters?
177  // BTW each label between dots has a max of 63 chars,
178  // and the whole domain should not be larger than 253 chars
179  b"-.".contains(&i)
180}
181
182pub fn hostname_and_port(i: &[u8]) -> IResult<&[u8], (&[u8], Option<u16>)> {
183    let (i, host) = take_while(is_hostname_char)(i)?;
184    if host.is_empty() {
185        return Err(Err::Error(Error::new(i, ErrorKind::Alpha)));
186    }
187    let (i, port_bytes) = opt(preceded(bytes::complete::tag(":"), digit1)).parse(i)?;
188    let port = match port_bytes {
189        // `digit1` guarantees ASCII digits, so the slice is always valid UTF-8;
190        // `parse::<u16>()` rejects values that overflow the 16-bit port space
191        // (RFC 6335 §6) and `Ok(0)` is explicitly rejected because port 0 is
192        // reserved by the same RFC and not a routable authority.
193        Some(bytes) => match std::str::from_utf8(bytes).unwrap().parse::<u16>() {
194            Ok(p) if p != 0 => Some(p),
195            _ => return Err(Err::Error(Error::new(bytes, ErrorKind::Digit))),
196        },
197        None => None,
198    };
199
200    if !i.is_empty() {
201        return Err(Err::Error(Error::new(i, ErrorKind::Eof)));
202    }
203    Ok((i, (host, port)))
204}
205
206pub fn view(buf: &[u8], size: usize, points: &[usize]) -> String {
207    let mut view = format!("{points:?} => ");
208    let mut end = 0;
209    for (i, point) in points.iter().enumerate() {
210        if *point > buf.len() {
211            break;
212        }
213        let start = if end + size < *point {
214            view.push_str("... ");
215            point - size
216        } else {
217            end
218        };
219        let stop = if i + 1 < points.len() {
220            min(buf.len(), points[i + 1])
221        } else {
222            buf.len()
223        };
224        end = if point + size > stop {
225            stop
226        } else {
227            point + size
228        };
229        for element in &buf[start..*point] {
230            let _ = view.write_fmt(format_args!("{element:02X} "));
231        }
232        view.push_str("| ");
233        for element in &buf[*point..end] {
234            let _ = view.write_fmt(format_args!("{element:02X} "));
235        }
236    }
237    if end < buf.len() {
238        view.push_str("...")
239    }
240    view
241}
242
243#[test]
244fn test_view_out_of_bound() {
245    println!(
246        "{}",
247        view(
248            &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
249            2,
250            &[5, 5, 8, 9, 9, 13, 80]
251        )
252    );
253}
254
255#[test]
256fn custom_method_does_not_assume_utf8() {
257    let method = Method::new(b"\xFFBAD");
258
259    assert_eq!(method, Method::Custom("\u{FFFD}BAD".to_owned()));
260}
261
262#[test]
263fn custom_method_debug_is_bounded() {
264    const METHOD_SECRET: &str = "CUSTOM_METHOD_DEBUG_SECRET_SENTINEL";
265
266    let custom = format!("{METHOD_SECRET}{}", "x".repeat(4096));
267    let custom_len = custom.len();
268    let output = format!("{:?}", Method::Custom(custom));
269
270    assert!(
271        !output.contains(METHOD_SECRET),
272        "custom Method Debug leaked its payload: {output}"
273    );
274    assert!(
275        output.contains(&format!("bytes={custom_len}")),
276        "custom Method Debug omitted its byte length: {output}"
277    );
278    assert!(
279        output.len() <= 128,
280        "custom Method Debug is not bounded: {} bytes",
281        output.len()
282    );
283}
284
285#[test]
286fn hostname_and_port_rejects_empty_host() {
287    assert!(hostname_and_port(b":80").is_err());
288}
289
290#[test]
291fn hostname_and_port_rejects_out_of_range_port() {
292    assert!(hostname_and_port(b"example.com:65536").is_err());
293}
294
295#[test]
296fn hostname_and_port_rejects_port_zero() {
297    // RFC 6335 §6 reserves port 0; it cannot identify a TCP/UDP service.
298    assert!(hostname_and_port(b"example.com:0").is_err());
299}
300
301#[test]
302fn hostname_and_port_accepts_u16_port() {
303    let (remaining, (host, port)) = hostname_and_port(b"example.com:65535").unwrap();
304
305    assert!(remaining.is_empty());
306    assert_eq!(host, b"example.com");
307    assert_eq!(port, Some(65535));
308}
309
310#[test]
311fn hostname_and_port_returns_no_port_when_absent() {
312    let (remaining, (host, port)) = hostname_and_port(b"example.com").unwrap();
313
314    assert!(remaining.is_empty());
315    assert_eq!(host, b"example.com");
316    assert_eq!(port, None);
317}
318
319/// The ASCII constraint `router::DomainRule::from_str` cites when it
320/// explains why leaving its hostname regexes on the regex crate's
321/// default Unicode case folding is unobservable: no byte outside ASCII
322/// ever reaches `Router::lookup` as part of a hostname.
323///
324/// `is_hostname_char` gates the `take_while`, so a non-ASCII byte ends
325/// the host early and the leftover trips this function's own
326/// `if !i.is_empty()` guard — the whole authority is refused with
327/// `ErrorKind::Eof`, which `frontend_from_request` (`lib/src/http.rs`,
328/// `lib/src/https.rs`) surfaces as `FrontendFromRequestError::HostParse`
329/// and the mux answers 400. Note that it is this guard, not the
330/// `InvalidCharsAfterHost` branch in the callers, that does the refusing:
331/// a successful return always carries an empty remainder.
332///
333/// The byte sweep is exhaustive rather than exemplary: one `é` would only
334/// show that ONE non-ASCII byte is refused.
335///
336/// To SEE THIS RED: make `is_hostname_char` admit non-ASCII — add
337/// `|| i >= 0x80` to it. The sweep reports the first accepted byte above
338/// 0x7F, and `café.example.com` starts parsing instead of erroring.
339#[test]
340fn is_hostname_char_admits_only_ascii() {
341    for byte in u8::MIN..=u8::MAX {
342        assert!(
343            !is_hostname_char(byte) || byte.is_ascii(),
344            "is_hostname_char accepted the non-ASCII byte {byte:#04x}; \
345             DomainRule::from_str's Unicode case folding stops being \
346             unobservable the moment one of these can reach a hostname",
347        );
348    }
349
350    // `café.example.com`: `é` is U+00E9, two UTF-8 bytes, neither ASCII.
351    // The host stops at `caf` and the rest is refused outright.
352    assert!(
353        hostname_and_port("café.example.com".as_bytes()).is_err(),
354        "a non-ASCII byte must fail the whole authority, not truncate it to `caf`",
355    );
356
357    // Control: the same name spelled in ASCII parses whole, so the
358    // rejection above is the non-ASCII byte and not the shape.
359    let (remaining, (host, port)) =
360        hostname_and_port(b"cafe.example.com").expect("the ASCII spelling must parse");
361    assert!(remaining.is_empty());
362    assert_eq!(host, b"cafe.example.com");
363    assert_eq!(port, None);
364}
365
366#[test]
367fn lowercase_verb_is_custom_and_not_idempotent() {
368    // RFC 9110 §9.1 makes the method token case-sensitive, and the origin
369    // receives the bytes the client actually sent. A lowercase or mixed-case
370    // spelling must therefore NOT be classified as the canonical verb it
371    // resembles — above all it must not inherit that verb's idempotence and
372    // become replayable on a fresh upstream (sozu-proxy/sozu#1451).
373    let spellings: [&[u8]; 11] = [
374        b"get", b"Get", b"gEt", b"put", b"Put", b"delete", b"head", b"options", b"trace", b"post",
375        b"Post",
376    ];
377
378    for raw in spellings {
379        let method = Method::new(raw);
380        let spelling = String::from_utf8_lossy(raw).into_owned();
381
382        assert_eq!(
383            method,
384            Method::Custom(spelling.clone()),
385            "{spelling} must parse as a custom method, not as a known verb"
386        );
387        assert!(
388            !method.is_idempotent(),
389            "{spelling} must not be judged idempotent"
390        );
391    }
392}
393
394#[test]
395fn canonical_verbs_keep_their_variant() {
396    assert_eq!(Method::new(b"GET"), Method::Get);
397    assert_eq!(Method::new(b"POST"), Method::Post);
398    assert_eq!(Method::new(b"HEAD"), Method::Head);
399    assert_eq!(Method::new(b"OPTIONS"), Method::Options);
400    assert_eq!(Method::new(b"PUT"), Method::Put);
401    assert_eq!(Method::new(b"DELETE"), Method::Delete);
402    assert_eq!(Method::new(b"TRACE"), Method::Trace);
403    assert_eq!(Method::new(b"CONNECT"), Method::Connect);
404}