Skip to main content

scion_http3/
authority.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The `host:port` target of a `CONNECT` tunnel.
16
17use std::{fmt, str::FromStr};
18
19/// The `host:port` target of a `CONNECT` tunnel.
20///
21/// The host is a DNS name or an IP literal (IPv6 in brackets) and is stored in
22/// lowercase. The port is never zero.
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct Authority {
25    host: String,
26    port: u16,
27}
28
29impl Authority {
30    /// Creates an authority from a host and a non-zero port.
31    pub fn new(host: impl Into<String>, port: u16) -> Result<Authority, InvalidAuthority> {
32        let host = host.into();
33        if port == 0 {
34            return Err(InvalidAuthority::InvalidPort);
35        }
36        if host.is_empty() {
37            return Err(InvalidAuthority::MissingHost);
38        }
39        if host.contains('@') {
40            return Err(InvalidAuthority::UserinfoNotAllowed);
41        }
42        let parsed = http::uri::Authority::from_str(&format!("{host}:{port}"))
43            .map_err(|_| InvalidAuthority::InvalidHost)?;
44        if parsed.host() != host {
45            return Err(InvalidAuthority::InvalidHost);
46        }
47        Ok(Authority {
48            host: host.to_ascii_lowercase(),
49            port,
50        })
51    }
52
53    /// The host, in lowercase.
54    #[must_use]
55    pub fn host(&self) -> &str {
56        &self.host
57    }
58
59    /// The port.
60    #[must_use]
61    pub fn port(&self) -> u16 {
62        self.port
63    }
64}
65
66impl FromStr for Authority {
67    type Err = InvalidAuthority;
68
69    /// Parses `host:port`.
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        let (host, port) = match s.rsplit_once(':') {
72            Some(split) if !s.ends_with(']') => split,
73            _ => return Err(InvalidAuthority::MissingPort),
74        };
75        let port = port.parse().map_err(|_| InvalidAuthority::InvalidPort)?;
76        Authority::new(host, port)
77    }
78}
79
80impl TryFrom<&str> for Authority {
81    type Error = InvalidAuthority;
82
83    fn try_from(s: &str) -> Result<Self, Self::Error> {
84        s.parse()
85    }
86}
87
88impl fmt::Display for Authority {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}:{}", self.host, self.port)
91    }
92}
93
94/// An [`Authority`] could not be built.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
96#[non_exhaustive]
97pub enum InvalidAuthority {
98    /// The authority has no `:port`.
99    #[error("authority has no port")]
100    MissingPort,
101    /// The port is not a number from 1 to 65535.
102    #[error("port is not a number from 1 to 65535")]
103    InvalidPort,
104    /// The host is empty.
105    #[error("authority has no host")]
106    MissingHost,
107    /// The host is not a DNS name or an IP literal.
108    #[error("host is not a DNS name or an IP literal")]
109    InvalidHost,
110    /// The authority contains `user@`.
111    #[error("authority must not contain userinfo")]
112    UserinfoNotAllowed,
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parses_and_normalizes() {
121        let authority: Authority = "Example.org:8443".parse().unwrap();
122        assert_eq!(authority.host(), "example.org");
123        assert_eq!(authority.port(), 8443);
124        assert_eq!(authority.to_string(), "example.org:8443");
125
126        let v6: Authority = "[::1]:443".parse().unwrap();
127        assert_eq!(v6.host(), "[::1]");
128        assert_eq!(v6, Authority::new("[::1]", 443).unwrap());
129    }
130
131    #[test]
132    fn rejects_invalid_input() {
133        let cases = [
134            ("example.org", InvalidAuthority::MissingPort),
135            ("[::1]", InvalidAuthority::MissingPort),
136            ("example.org:0", InvalidAuthority::InvalidPort),
137            ("example.org:65536", InvalidAuthority::InvalidPort),
138            ("example.org:http", InvalidAuthority::InvalidPort),
139            (":443", InvalidAuthority::MissingHost),
140            ("user@example.org:443", InvalidAuthority::UserinfoNotAllowed),
141            ("a b:443", InvalidAuthority::InvalidHost),
142            ("a/b:443", InvalidAuthority::InvalidHost),
143        ];
144        for (input, expected) in cases {
145            assert_eq!(input.parse::<Authority>(), Err(expected), "{input}");
146        }
147        assert_eq!(
148            Authority::new("example.org", 0),
149            Err(InvalidAuthority::InvalidPort)
150        );
151        assert_eq!(
152            Authority::new("a:b", 443),
153            Err(InvalidAuthority::InvalidHost)
154        );
155    }
156}