Skip to main content

http_request_target/
lib.rs

1//! HTTP/1.1 request-target (RFC 9112) parser.
2
3// #![no_std]
4use winnow::{
5    combinator::{alt, delimited, fail, opt, peek, preceded, repeat},
6    error::ContextError,
7    prelude::*,
8    stream::{AsChar, Compare, Stream, StreamIsPartial},
9    token::{literal, one_of, take_while},
10};
11
12mod error;
13
14// use crate::error::ParseRequestTargetError;
15
16/// See <https://datatracker.ietf.org/doc/html/rfc9112#name-request-target>.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RequestTarget<'a> {
19    /// Origin form.
20    Origin(RequestTargetOrigin<'a>),
21
22    /// ```plain
23    /// absolute-form = absolute-URI
24    /// GET http://www.example.org/pub/WWW/TheProject.html HTTP/1.1
25    /// ```
26    Absolute(RequestTargetAbsolute<'a>),
27
28    /// ```plain
29    /// authority-form = uri-host ":" port
30    /// CONNECT www.example.com:80 HTTP/1.1
31    /// ```
32    Authority(RequestTargetAuthority<'a>),
33
34    /// Asterisk form.
35    ///
36    /// ```plain
37    /// asterisk-form = "*"
38    /// OPTIONS * HTTP/1.1
39    /// ```
40    Asterisk,
41}
42
43/// Origin-form request-target.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct RequestTargetOrigin<'a> {
46    /// Original parsed bytes.
47    pub inner: &'a [u8],
48}
49
50/// Absolute-form request-target.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RequestTargetAbsolute<'a> {
53    /// Original parsed bytes.
54    pub inner: &'a [u8],
55}
56
57/// Authority-form request-target.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RequestTargetAuthority<'a> {
60    /// Original parsed bytes.
61    pub inner: &'a [u8],
62}
63
64impl<'a> RequestTarget<'a> {
65    /// Parse request target from slice.
66    pub fn try_from_slice(
67        input: &'a [u8],
68    ) -> Result<Self, winnow::error::ParseError<&'a [u8], ContextError>> {
69        // #[cfg(any(debug_assertions, test))]
70        // let input = winnow::BStr::new(input);
71
72        alt((
73            parse_asterisk.value(RequestTarget::Asterisk),
74            parse_origin_form
75                .take()
76                .map(|inner| RequestTarget::Origin(RequestTargetOrigin { inner })),
77            parse_authority_form
78                .take()
79                .map(|inner| RequestTarget::Authority(RequestTargetAuthority { inner })),
80            parse_absolute_form
81                .take()
82                .map(|inner| RequestTarget::Absolute(RequestTargetAbsolute { inner })),
83            fail,
84        ))
85        .parse(input)
86    }
87}
88
89/// # Request Line Examples
90///
91/// ```plain
92/// GET /where?q=now HTTP/1.1
93/// ```
94///
95/// # BNF
96///
97/// ```plain
98/// origin-form   = absolute-path [ "?" query ]
99/// absolute-path = 1*( "/" segment )
100/// segment       = *pchar
101/// pchar         = unreserved / pct-encoded / sub-delims / ":" / "@"
102/// unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
103/// pct-encoded   = "%" HEXDIG HEXDIG
104/// sub-delims    = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
105/// query         = *( pchar / "/" / "?" )
106/// ```
107fn parse_origin_form<I>(input: &mut I) -> ModalResult<()>
108where
109    I: Stream + StreamIsPartial + Compare<u8>,
110    I::Token: Clone + AsChar,
111{
112    (parse_path, opt((b'?', parse_query)))
113        .void()
114        .parse_next(input)
115}
116
117/// Parses a path.
118///
119/// Assumes entire input is a path, starting with a `/`, and does not include a query or fragment.
120///
121/// See:
122/// - <https://datatracker.ietf.org/doc/html/rfc9112#name-syntax-notation>
123/// - <https://datatracker.ietf.org/doc/html/rfc9110#name-uri-references>
124/// - <https://datatracker.ietf.org/doc/html/rfc3986#section-3.3>
125fn parse_path<I>(input: &mut I) -> ModalResult<()>
126where
127    I: Stream + StreamIsPartial + Compare<u8>,
128    I::Token: Clone + AsChar,
129{
130    peek(b'/').parse_next(input)?;
131
132    // absolute-path
133    repeat(1.., (b'/', take_while(.., is_pchar)))
134        .map(|()| ())
135        .void()
136        .parse_next(input)
137}
138
139/// Parses a query string.
140///
141/// Assumes entire input is only a query string without preceding `?` and without a rogue, trailing
142/// fragement (i.e. `#`).
143///
144/// See:
145/// - <https://datatracker.ietf.org/doc/html/rfc3986#section-3.4>
146/// - <https://datatracker.ietf.org/doc/html/rfc3986#appendix-A>
147fn parse_query<I>(input: &mut I) -> ModalResult<()>
148where
149    I: Stream + StreamIsPartial,
150    I::Token: Clone + AsChar,
151{
152    repeat(
153        ..,
154        one_of((
155            is_pchar,
156            // query literals
157            [b'/', b'?'],
158        )),
159    )
160    .map(|()| ())
161    .void()
162    .parse_next(input)
163}
164
165/// Returns `true` if the given character is in the `unreserved` group.
166///
167/// See: <https://datatracker.ietf.org/doc/html/rfc3986#appendix-A>
168fn is_unreserved(char: char) -> bool {
169    matches!(char, '0'..='9' | 'A'..='Z' | 'a'..='z' | '-' | '.' | '_' | '~')
170}
171
172/// Returns `true` if the given character is in the `sub-delims` group.
173///
174/// See: <https://datatracker.ietf.org/doc/html/rfc3986#appendix-A>
175fn is_sub_delim(char: char) -> bool {
176    matches!(
177        char,
178        '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
179    )
180}
181
182/// Returns `true` if the given character is a valid `pchar`.
183///
184/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.3>
185fn is_pchar(char: impl AsChar) -> bool {
186    let char = char.as_char();
187
188    is_unreserved(char)
189        || is_sub_delim(char)
190        // pct-encoded
191        || matches!(char, '%') // HEXDIG are included in `unreserved`; we do not validate hex escape sequences
192        // pchar literals
193        || matches!(char, ':' | '@')
194}
195
196/// Returns `true` if the given character is valid in `reg-name`.
197///
198/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2>
199fn is_reg_name_char(char: impl AsChar) -> bool {
200    let char = char.as_char();
201
202    is_unreserved(char)
203        || is_sub_delim(char)
204        // pct-encoded
205        || matches!(char, '%') // HEXDIG are included in `unreserved`; we do not validate hex escape sequences
206}
207
208/// Returns `true` if the given character is valid in `userinfo`.
209///
210/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1>
211fn is_userinfo_char(char: impl AsChar) -> bool {
212    let char = char.as_char();
213
214    is_unreserved(char)
215        || is_sub_delim(char)
216        // pct-encoded
217        || matches!(char, '%') // HEXDIG are included in `unreserved`; we do not validate hex escape sequences
218        || matches!(char, ':')
219}
220
221/// Returns `true` if the given character is valid within an `IP-literal` body.
222///
223/// This covers the character groups referenced by the `IPv6address` and `IPvFuture`
224/// productions from RFC 3986.
225fn is_ip_literal_char(char: impl AsChar) -> bool {
226    let char = char.as_char();
227
228    is_unreserved(char)
229        || is_sub_delim(char)
230        // IPv6address / IPvFuture literals
231        || matches!(char, ':')
232}
233
234/// Returns `true` if the given slice looks like an `IPv6address` or `IPvFuture` payload.
235fn is_ip_literal_body(bytes: &[u8]) -> bool {
236    bytes.contains(&b':') || matches!(bytes.first(), Some(b'v' | b'V')) && bytes.contains(&b'.')
237}
238
239/// # Request Line Examples
240///
241/// ```plain
242/// CONNECT www.example.com:80 HTTP/1.1
243/// ```
244fn parse_authority_form<I>(input: &mut I) -> ModalResult<()>
245where
246    I: Stream + StreamIsPartial + Compare<u8>,
247    I::Token: Clone + AsChar,
248    I::Slice: AsRef<[u8]>,
249{
250    (
251        parse_uri_host,
252        b':',
253        take_while(1.., |char: I::Token| matches!(char.as_char(), '0'..='9')),
254    )
255        .void()
256        .parse_next(input)
257}
258
259/// # Request Line Examples
260///
261/// ```plain
262/// GET http://www.example.org/pub/WWW/TheProject.html HTTP/1.1
263/// ```
264///
265/// # Policy
266///
267/// RFC 9112 defines `absolute-form = absolute-URI`, but this crate narrows absolute-form parsing
268/// to the authority-based URI shape commonly used by HTTP-family schemes:
269///
270/// ```plain
271/// scheme "://" authority path-abempty [ "?" query ]
272/// ```
273///
274/// This rejects generic RFC 3986 absolute URIs like `htt:p//host` while still allowing arbitrary
275/// schemes such as `git+http://...`.
276fn parse_absolute_form<I>(input: &mut I) -> ModalResult<()>
277where
278    I: Stream + StreamIsPartial + Compare<u8>,
279    I::Token: Clone + AsChar,
280    I::Slice: AsRef<[u8]>,
281{
282    (
283        parse_scheme,
284        b':',
285        (b'/', b'/'),
286        parse_authority,
287        parse_path_abempty,
288        opt((b'?', parse_query)),
289    )
290        .void()
291        .parse_next(input)
292}
293
294/// # Request Line Examples
295///
296/// ```plain
297/// OPTIONS * HTTP/1.1
298/// ```
299fn parse_asterisk<I>(input: &mut I) -> ModalResult<()>
300where
301    I: Stream + StreamIsPartial + Compare<u8>,
302{
303    literal(b'*').void().parse_next(input)
304}
305
306/// Parses a `uri-host`.
307///
308/// RFC 9112 defines `authority-form = uri-host ":" port` and references the URI grammar for the
309/// host production.
310fn parse_uri_host<I>(input: &mut I) -> ModalResult<()>
311where
312    I: Stream + StreamIsPartial + Compare<u8>,
313    I::Token: Clone + AsChar,
314    I::Slice: AsRef<[u8]>,
315{
316    alt((
317        parse_ip_literal,
318        // IPv4 addresses are valid in `reg-name` context
319        parse_reg_name,
320    ))
321    .void()
322    .parse_next(input)
323}
324
325/// Parses an `IP-literal`.
326///
327/// We enforce the surrounding brackets and restrict the inner character set to the `IPv6address` /
328/// `IPvFuture` productions, while leaving the detailed numeric validation to a future pass.
329fn parse_ip_literal<I>(input: &mut I) -> ModalResult<()>
330where
331    I: Stream + StreamIsPartial + Compare<u8>,
332    I::Token: Clone + AsChar,
333    I::Slice: AsRef<[u8]>,
334{
335    delimited(
336        b'[',
337        take_while(1.., is_ip_literal_char)
338            .verify(|slice: &I::Slice| is_ip_literal_body(slice.as_ref())),
339        b']',
340    )
341    .void()
342    .parse_next(input)
343}
344
345/// Parses a `reg-name`.
346///
347/// RFC 3986 permits an empty `reg-name`, but `authority-form` requires a concrete `uri-host`, so
348/// we require at least one character here.
349fn parse_reg_name<I>(input: &mut I) -> ModalResult<()>
350where
351    I: Stream + StreamIsPartial,
352    I::Token: Clone + AsChar,
353{
354    take_while(1.., is_reg_name_char).void().parse_next(input)
355}
356
357/// Returns `true` if the given character is a valid first character in `scheme`.
358///
359/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.1>
360fn is_scheme_start(char: impl AsChar) -> bool {
361    matches!(char.as_char(), 'A'..='Z' | 'a'..='z')
362}
363
364/// Returns `true` if the given character is valid in `scheme`.
365///
366/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.1>
367fn is_scheme_char(char: impl AsChar) -> bool {
368    matches!(char.as_char(), '0'..='9' | 'A'..='Z' | 'a'..='z' | '+' | '-' | '.')
369}
370
371/// Parses `scheme`.
372///
373/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.1>
374fn parse_scheme<I>(input: &mut I) -> ModalResult<()>
375where
376    I: Stream + StreamIsPartial,
377    I::Token: Clone + AsChar,
378{
379    preceded(one_of(is_scheme_start), take_while(.., is_scheme_char))
380        .void()
381        .parse_next(input)
382}
383
384/// Parses `authority`.
385///
386/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2>
387fn parse_authority<I>(input: &mut I) -> ModalResult<()>
388where
389    I: Stream + StreamIsPartial + Compare<u8>,
390    I::Token: Clone + AsChar,
391    I::Slice: AsRef<[u8]>,
392{
393    (
394        opt((take_while(1.., is_userinfo_char), b'@')),
395        parse_uri_host,
396        opt((
397            b':',
398            take_while(1.., |char: I::Token| matches!(char.as_char(), '0'..='9')),
399        )),
400    )
401        .void()
402        .parse_next(input)
403}
404
405/// Parses `path-abempty`.
406///
407/// See: <https://datatracker.ietf.org/doc/html/rfc3986#section-3.3>
408fn parse_path_abempty<I>(input: &mut I) -> ModalResult<()>
409where
410    I: Stream + StreamIsPartial + Compare<u8>,
411    I::Token: Clone + AsChar,
412{
413    repeat(.., (b'/', take_while(.., is_pchar)))
414        .map(|()| ())
415        .void()
416        .parse_next(input)
417}
418
419#[cfg(test)]
420mod tests {
421    use winnow::{
422        BStr, Partial,
423        error::{ErrMode, Needed},
424    };
425
426    use super::*;
427
428    macro_rules! assert_backtrack {
429        ($parser:expr, $input:expr $(,)?) => {
430            assert!(
431                matches!(
432                    $parser.parse_peek(BStr::new($input)),
433                    Err(ErrMode::Backtrack(_))
434                ),
435                "assertion failed: parser did not backtrack for input {:?}: {:?}",
436                $input,
437                $parser.parse_peek(BStr::new($input)),
438            );
439        };
440    }
441
442    macro_rules! assert_ok_remaining {
443        ($parser:expr, $input:expr, $remaining:expr $(,)?) => {
444            assert_eq!(
445                $parser.parse_peek(BStr::new($input)),
446                Ok((BStr::new($remaining), ())),
447            );
448        };
449    }
450
451    macro_rules! assert_partial_incomplete {
452        ($parser:expr, $input:expr, $needed:expr $(,)?) => {
453            assert_eq!(
454                $parser.parse_peek(Partial::new(BStr::new($input))),
455                Err(ErrMode::Incomplete($needed)),
456            );
457        };
458    }
459
460    #[test]
461    fn validates_char_groups() {
462        assert!(!is_unreserved('/'));
463        assert!(is_unreserved('a'));
464        assert!(is_unreserved('Z'));
465        assert!(is_unreserved('0'));
466        assert!(is_unreserved('~'));
467
468        assert!(!is_sub_delim(':'));
469        assert!(is_sub_delim('!'));
470        assert!(is_sub_delim('='));
471
472        assert!(!is_pchar(b'/'));
473        assert!(is_pchar(b'='));
474        assert!(is_pchar(b'%'));
475        assert!(is_pchar(b':'));
476        assert!(is_pchar(b'@'));
477
478        assert!(!is_reg_name_char(b':'));
479        assert!(!is_reg_name_char(b'@'));
480        assert!(is_reg_name_char(b'%'));
481        assert!(is_reg_name_char(b'.'));
482
483        assert!(!is_ip_literal_char(b'['));
484        assert!(!is_ip_literal_char(b'/'));
485        assert!(is_ip_literal_char(b':'));
486        assert!(is_ip_literal_char(b'v'));
487        assert!(is_ip_literal_char(b'.'));
488    }
489
490    #[test]
491    fn validates_ip_literal_bodies() {
492        assert!(!is_ip_literal_body(b""));
493        assert!(!is_ip_literal_body(b"localhost"));
494        assert!(!is_ip_literal_body(b"v1"));
495        assert!(!is_ip_literal_body(b"v1/"));
496
497        assert!(is_ip_literal_body(b"::1"));
498        assert!(is_ip_literal_body(b"2001:db8::1"));
499        assert!(is_ip_literal_body(b"v1.future-host"));
500        assert!(is_ip_literal_body(b"Vf.token:more"));
501    }
502
503    #[test]
504    fn parses_reg_name() {
505        assert_backtrack!(parse_reg_name, b"");
506        assert_backtrack!(parse_reg_name, b"@localhost");
507
508        assert_ok_remaining!(parse_reg_name, b"localhost", b"");
509        assert_ok_remaining!(parse_reg_name, b"example.com:80", b":80");
510        assert_ok_remaining!(parse_reg_name, b"xn--hllo-bpa.example", b"");
511    }
512
513    #[test]
514    fn parses_ip_literal() {
515        assert_backtrack!(parse_ip_literal, b"");
516        assert_backtrack!(parse_ip_literal, b"[localhost]");
517        assert_backtrack!(parse_ip_literal, b"[::1");
518
519        assert_ok_remaining!(parse_ip_literal, b"[::1]", b"");
520        assert_ok_remaining!(parse_ip_literal, b"[2001:db8::1]:443", b":443");
521        assert_ok_remaining!(parse_ip_literal, b"[v1.future-host]", b"");
522    }
523
524    #[test]
525    fn parses_uri_host() {
526        assert_backtrack!(parse_uri_host, b"");
527        assert_backtrack!(parse_uri_host, b"@localhost:80");
528
529        assert_ok_remaining!(parse_uri_host, b"localhost:80", b":80");
530        assert_ok_remaining!(parse_uri_host, b"127.0.0.1:80", b":80");
531        assert_ok_remaining!(parse_uri_host, b"[::1]:80", b":80");
532    }
533
534    #[test]
535    fn parses_scheme() {
536        assert_backtrack!(parse_scheme, b"");
537        assert_backtrack!(parse_scheme, b"1http");
538        assert_partial_incomplete!(parse_scheme, b"", Needed::new(1));
539
540        assert_ok_remaining!(parse_scheme, b"http", b"");
541        assert_ok_remaining!(parse_scheme, b"https:", b":");
542        assert_ok_remaining!(parse_scheme, b"http+unix:", b":");
543    }
544
545    #[test]
546    fn parses_authority() {
547        assert_backtrack!(parse_authority, b"");
548        assert_backtrack!(parse_authority, b"@localhost");
549        assert_partial_incomplete!(parse_authority, b"", Needed::new(1));
550
551        assert_ok_remaining!(parse_authority, b"127.0.0.1:80", b"");
552        assert_ok_remaining!(parse_authority, b"user:pass@localhost:3000", b"");
553        assert_ok_remaining!(parse_authority, b"[::1]/path", b"/path");
554    }
555
556    #[test]
557    fn parses_path_abempty() {
558        assert_ok_remaining!(parse_path_abempty, b"", b"");
559        assert_ok_remaining!(parse_path_abempty, b"/", b"");
560        assert_ok_remaining!(parse_path_abempty, b"/foo/bar", b"");
561        assert_ok_remaining!(parse_path_abempty, b"?foo=bar", b"?foo=bar");
562    }
563
564    #[test]
565    fn parses_path() {
566        assert_backtrack!(parse_path, b"");
567        assert_partial_incomplete!(parse_path, b"", Needed::Unknown);
568        assert_backtrack!(parse_path, b"=");
569
570        assert_ok_remaining!(parse_path, b"/foo", b"");
571        assert_ok_remaining!(parse_path, b"/foo/bar", b"");
572
573        // parser assumes it won't receive a query but doesn't fail
574        assert_ok_remaining!(parse_path, b"/foo/bar?baz", b"?baz");
575    }
576
577    #[test]
578    fn parses_query() {
579        assert_ok_remaining!(parse_query, b"", b"");
580        assert_ok_remaining!(parse_query, b"=", b"");
581        assert_ok_remaining!(parse_query, b"foo=bar", b"");
582        assert_ok_remaining!(parse_query, b"foo=bar&baz", b"");
583    }
584
585    #[test]
586    fn parses_authority_form() {
587        assert_backtrack!(parse_authority_form, b"");
588        assert_partial_incomplete!(parse_authority_form, b"", Needed::Unknown);
589
590        assert_backtrack!(parse_authority_form, b"localhost");
591        assert_backtrack!(parse_authority_form, b"user@localhost:3000");
592        assert_backtrack!(parse_authority_form, b"[::1]");
593
594        assert_ok_remaining!(parse_authority_form, b"localhost:3000", b"");
595        assert_ok_remaining!(parse_authority_form, b"127.0.0.1:80", b"");
596        assert_ok_remaining!(parse_authority_form, b"[::1]:443", b"");
597    }
598
599    #[test]
600    fn parses_absolute_form() {
601        assert_backtrack!(parse_absolute_form, b"");
602        assert_backtrack!(parse_absolute_form, b"/foo");
603        assert_backtrack!(parse_absolute_form, b"htt:p//host");
604        assert_partial_incomplete!(parse_absolute_form, b"", Needed::new(1));
605
606        assert_ok_remaining!(parse_absolute_form, b"http://127.0.0.1:61761/chunks", b"");
607        assert_ok_remaining!(parse_absolute_form, b"https://127.0.0.1:61761", b"");
608        assert_ok_remaining!(parse_absolute_form, b"http://127.0.0.1?foo=bar", b"");
609        assert_ok_remaining!(parse_absolute_form, b"git+http://example.com/repo", b"");
610    }
611
612    #[test]
613    fn parses_asterisk() {
614        assert_backtrack!(parse_asterisk, b"");
615        assert_partial_incomplete!(parse_asterisk, b"", Needed::Unknown);
616
617        assert_ok_remaining!(parse_asterisk, b"*", b"");
618        assert_ok_remaining!(parse_asterisk, b"**", b"*");
619    }
620}