h3x 0.6.1-beta.2

Peer-to-peer DHTTP/3 transport over QUIC
Documentation
use http::{
    Method, StatusCode, Uri,
    uri::{Authority, PathAndQuery, Scheme},
};

use super::Protocol;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PseudoHeaders {
    /// <https://datatracker.ietf.org/doc/html/rfc9114#name-request-pseudo-header-field>
    Request {
        method: Option<Method>,
        scheme: Option<Scheme>,
        authority: Option<Authority>,
        path: Option<PathAndQuery>,
        protocol: Option<Protocol>,
    },
    /// <https://datatracker.ietf.org/doc/html/rfc9114#name-response-pseudo-header-fiel>
    Response { status: Option<StatusCode> },
}

fn has_missing_path_component(path_and_query: &PathAndQuery) -> bool {
    path_and_query.as_str().is_empty() || path_and_query.as_str().starts_with('?')
}

fn normalize_http_path(path_and_query: PathAndQuery) -> PathAndQuery {
    if path_and_query.as_str().is_empty() {
        return PathAndQuery::from_static("/");
    }

    if path_and_query.as_str().starts_with('?') {
        let mut normalized = String::with_capacity(path_and_query.as_str().len() + 1);
        normalized.push('/');
        normalized.push_str(path_and_query.as_str());
        return PathAndQuery::try_from(normalized)
            .expect("path-and-query with a prefixed slash remains valid");
    }

    path_and_query
}

pub(super) fn asterisk_path() -> PathAndQuery {
    Uri::from_static("*")
        .into_parts()
        .path_and_query
        .expect("asterisk URI carries a path-and-query")
}

impl PseudoHeaders {
    pub const METHOD: &str = ":method";
    pub const SCHEME: &str = ":scheme";
    pub const AUTHORITY: &str = ":authority";
    pub const PATH: &str = ":path";
    pub const PROTOOCL: &str = ":protocol";

    pub const STATUS: &str = ":status";

    pub fn request(method: Method, uri: Uri) -> Self {
        let uri = uri.into_parts();
        let is_http = uri.scheme == Some(Scheme::HTTP) || uri.scheme == Some(Scheme::HTTPS);
        // RFC 9114 Section 4.3.1 defines :path as path-absolute plus an
        // optional query. For http/https URIs with no path component, use "/"
        // (or "/?query" for query-only targets). OPTIONS requests with no path
        // component use "*".
        let path = match uri.path_and_query {
            Some(path_and_query)
                if method == http::Method::OPTIONS
                    && has_missing_path_component(&path_and_query) =>
            {
                Some(asterisk_path())
            }
            Some(path_and_query) if is_http => Some(normalize_http_path(path_and_query)),
            Some(path_and_query) => Some(path_and_query),
            None if method == http::Method::OPTIONS => Some(asterisk_path()),
            None if is_http => Some(PathAndQuery::from_static("/")),
            None => None,
        };
        PseudoHeaders::Request {
            method: Some(method),
            scheme: uri.scheme,
            authority: uri.authority,
            path,
            protocol: None,
        }
    }

    pub fn response(status: StatusCode) -> Self {
        Self::Response {
            status: Some(status),
        }
    }

    pub const fn unresolved_request() -> Self {
        PseudoHeaders::Request {
            method: None,
            scheme: None,
            authority: None,
            path: None,
            protocol: None,
        }
    }

    pub const fn unresolved_response() -> Self {
        PseudoHeaders::Response { status: None }
    }

    pub const fn is_empty(&self) -> bool {
        match self {
            PseudoHeaders::Request {
                method,
                scheme,
                authority,
                path,
                protocol: protoocl,
            } => {
                method.is_none()
                    && scheme.is_none()
                    && authority.is_none()
                    && path.is_none()
                    && protoocl.is_none()
            }
            PseudoHeaders::Response { status } => status.is_none(),
        }
    }
}

#[cfg(test)]
mod tests {
    use http::{
        Method, StatusCode, Uri,
        uri::{PathAndQuery, Scheme},
    };

    use super::{PseudoHeaders, asterisk_path, has_missing_path_component};

    #[test]
    fn pseudo_header_names_match_http3_wire_names() {
        assert_eq!(PseudoHeaders::METHOD, ":method");
        assert_eq!(PseudoHeaders::SCHEME, ":scheme");
        assert_eq!(PseudoHeaders::AUTHORITY, ":authority");
        assert_eq!(PseudoHeaders::PATH, ":path");
        assert_eq!(PseudoHeaders::PROTOOCL, ":protocol");
        assert_eq!(PseudoHeaders::STATUS, ":status");
    }

    #[test]
    fn path_helpers_identify_query_only_and_asterisk_forms() {
        let query_only = PathAndQuery::from_static("?x=1");
        let absolute = PathAndQuery::from_static("/x?y=1");

        assert!(has_missing_path_component(&query_only));
        assert!(!has_missing_path_component(&absolute));
        assert_eq!(asterisk_path().as_str(), "*");
    }

    #[test]
    fn request_extracts_pseudo_headers_from_absolute_uri() {
        let pseudo = PseudoHeaders::request(
            Method::GET,
            Uri::from_static("https://example.com:443/resource?q=1"),
        );

        assert_eq!(
            pseudo,
            PseudoHeaders::Request {
                method: Some(Method::GET),
                scheme: Some(http::uri::Scheme::HTTPS),
                authority: Some(http::uri::Authority::from_static("example.com:443")),
                path: Some(PathAndQuery::from_static("/resource?q=1")),
                protocol: None,
            }
        );
        assert!(!pseudo.is_empty());
    }

    #[test]
    fn request_defaults_http_and_https_missing_path_to_slash() {
        let cases = [
            (
                Uri::from_static("https://example.com"),
                Scheme::HTTPS,
                "example.com",
            ),
            (
                Uri::from_static("http://example.com"),
                Scheme::HTTP,
                "example.com",
            ),
        ];

        for (uri, expected_scheme, expected_authority) in cases {
            assert!(matches!(
                PseudoHeaders::request(Method::GET, uri),
                PseudoHeaders::Request {
                    method: Some(Method::GET),
                    scheme: Some(ref scheme),
                    authority: Some(ref authority),
                    path: Some(ref path),
                    protocol: None,
                }
                    if scheme == &expected_scheme
                        && authority.as_str() == expected_authority
                        && path.as_str() == "/"
            ));
        }
    }

    #[test]
    fn request_normalizes_query_only_http_and_https_uri_to_absolute_path() {
        let cases = [
            (
                Uri::from_static("https://example.com?x=1"),
                Scheme::HTTPS,
                "example.com",
            ),
            (
                Uri::from_static("http://example.com?x=1"),
                Scheme::HTTP,
                "example.com",
            ),
        ];

        for (uri, expected_scheme, expected_authority) in cases {
            assert!(matches!(
                PseudoHeaders::request(Method::GET, uri),
                PseudoHeaders::Request {
                    method: Some(Method::GET),
                    scheme: Some(ref scheme),
                    authority: Some(ref authority),
                    path: Some(ref path),
                    protocol: None,
                }
                    if scheme == &expected_scheme
                        && authority.as_str() == expected_authority
                        && path.as_str() == "/?x=1"
            ));
        }
    }

    #[test]
    fn options_asterisk_form_and_authority_form_use_asterisk_but_explicit_paths_are_preserved() {
        let asterisk_form = PseudoHeaders::request(Method::OPTIONS, Uri::from_static("*"));
        let authority_form =
            PseudoHeaders::request(Method::OPTIONS, Uri::from_static("example.com"));
        let query_only =
            PseudoHeaders::request(Method::OPTIONS, Uri::from_static("https://example.com?x=1"));
        let explicit_slash =
            PseudoHeaders::request(Method::OPTIONS, Uri::from_static("https://example.com/"));
        let explicit_slash_with_query = PseudoHeaders::request(
            Method::OPTIONS,
            Uri::from_static("https://example.com/?x=1"),
        );

        assert!(matches!(
            asterisk_form,
            PseudoHeaders::Request {
                method: Some(Method::OPTIONS),
                scheme: None,
                authority: None,
                path: Some(ref path),
                protocol: None,
            }
                if path.as_str() == "*"
        ));
        assert!(matches!(
            authority_form,
            PseudoHeaders::Request {
                method: Some(Method::OPTIONS),
                scheme: None,
                authority: Some(ref authority),
                path: Some(ref path),
                protocol: None,
            }
                if authority.as_str() == "example.com" && path.as_str() == "*"
        ));
        assert!(matches!(
            query_only,
            PseudoHeaders::Request {
                method: Some(Method::OPTIONS),
                scheme: Some(ref scheme),
                authority: Some(ref authority),
                path: Some(ref path),
                protocol: None,
            }
                if scheme == &Scheme::HTTPS
                    && authority.as_str() == "example.com"
                    && path.as_str() == "*"
        ));
        assert!(matches!(
            explicit_slash,
            PseudoHeaders::Request {
                method: Some(Method::OPTIONS),
                scheme: Some(ref scheme),
                authority: Some(ref authority),
                path: Some(ref path),
                protocol: None,
            }
                if scheme == &Scheme::HTTPS
                    && authority.as_str() == "example.com"
                    && path.as_str() == "/"
        ));
        assert!(matches!(
            explicit_slash_with_query,
            PseudoHeaders::Request {
                method: Some(Method::OPTIONS),
                scheme: Some(ref scheme),
                authority: Some(ref authority),
                path: Some(ref path),
                protocol: None,
            }
                if scheme == &Scheme::HTTPS
                    && authority.as_str() == "example.com"
                    && path.as_str() == "/?x=1"
        ));
    }

    #[test]
    fn response_and_unresolved_headers_report_empty_state() {
        let response = PseudoHeaders::response(StatusCode::NO_CONTENT);
        assert_eq!(
            response,
            PseudoHeaders::Response {
                status: Some(StatusCode::NO_CONTENT),
            }
        );
        assert!(!response.is_empty());

        assert!(PseudoHeaders::unresolved_request().is_empty());
        assert!(PseudoHeaders::unresolved_response().is_empty());
    }
}