Skip to main content

rama_net/
proto.rs

1use core::cmp::min;
2use core::str::FromStr;
3
4use crate::std::string::String;
5
6use rama_core::error::BoxErrorExt as _;
7use rama_core::error::{BoxError, ErrorContext};
8use rama_core::extensions::Extension;
9use rama_utils::macros::str::eq_ignore_ascii_case;
10use rama_utils::str::smol_str::SmolStr;
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Extension)]
13#[extension(tags(net))]
14/// Web protocols that are relevant to Rama.
15///
16/// Please [file an issue or open a PR][repo] if you need support for more protocols.
17/// When doing so please provide sufficient motivation and ensure
18/// it has no unintended consequences.
19///
20/// [repo]: https://github.com/plabayo/rama
21pub struct Protocol(ProtocolKind);
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24#[non_exhaustive]
25enum ProtocolKind {
26    /// The `http` protocol.
27    Http,
28    /// The `https` protocol.
29    Https,
30    /// The `ws` protocol.
31    ///
32    /// (WebSocket over HTTP)
33    /// <https://datatracker.ietf.org/doc/html/rfc6455>
34    Ws,
35    /// The `wss` protocol.
36    ///
37    /// (WebSocket over HTTPS)
38    /// <https://datatracker.ietf.org/doc/html/rfc6455>
39    Wss,
40    /// The `socks5` protocol.
41    ///
42    /// <https://datatracker.ietf.org/doc/html/rfc1928>
43    Socks5,
44    /// The `socks5h` protocol.
45    ///
46    /// Not official, but rather a convention that was introduced in version 4 of socks,
47    /// by curl and documented at <https://curl.se/libcurl/c/CURLOPT_PROXY.html>.
48    ///
49    /// The difference with [`Self::Socks5`] is that the proxy resolves the URL hostname.
50    Socks5h,
51    /// The `file` protocol. Local-filesystem URI scheme — the
52    /// `hier-part` is an absolute path on the host running the URI
53    /// consumer; nothing is sent over the network. Defined by
54    /// [RFC 8089](https://datatracker.ietf.org/doc/html/rfc8089).
55    ///
56    /// Has no default port — `file:` is not a network protocol.
57    File,
58    /// The `data` protocol. The URI carries its own payload
59    /// (`data:[<mediatype>][;base64],<data>`); consumers decode it in
60    /// place instead of dialing or opening anything. Defined by
61    /// [RFC 2397](https://datatracker.ietf.org/doc/html/rfc2397).
62    ///
63    /// Has no default port — `data:` is not a network protocol.
64    Data,
65    /// Custom protocol.
66    Custom(SmolStr),
67}
68
69impl Protocol {
70    /// `HTTP` protocol scheme
71    pub const HTTP_SCHEME: &str = "http";
72    /// `HTTP` protocol default port
73    pub const HTTP_DEFAULT_PORT: u16 = 80;
74    /// Common alternate `HTTP` protocol port.
75    pub const HTTP_ALT_PORT: u16 = 8080;
76    /// Common default port for an HTTP proxy address without an explicit port.
77    ///
78    /// This follows the long-standing curl proxy-address convention. It is
79    /// distinct from [`HTTP_DEFAULT_PORT`][Self::HTTP_DEFAULT_PORT], which is
80    /// the default port for an HTTP origin server.
81    pub const HTTP_PROXY_DEFAULT_PORT: u16 = 1080;
82    /// `HTTP` protocol.
83    pub const HTTP: Self = Self(ProtocolKind::Http);
84
85    /// `HTTPS` protocol scheme
86    pub const HTTPS_SCHEME: &str = "https";
87    /// `HTTPS` protocol default port
88    pub const HTTPS_DEFAULT_PORT: u16 = 443;
89    /// Common alternate `HTTPS` protocol port.
90    pub const HTTPS_ALT_PORT: u16 = 8443;
91    /// `HTTPS` protocol.
92    pub const HTTPS: Self = Self(ProtocolKind::Https);
93
94    /// `WS` protocol scheme
95    pub const WS_SCHEME: &str = "ws";
96    /// `WS` protocol default port
97    pub const WS_DEFAULT_PORT: u16 = Self::HTTP_DEFAULT_PORT;
98    /// `WS` protocol.
99    pub const WS: Self = Self(ProtocolKind::Ws);
100
101    /// `WSS` protocol scheme
102    pub const WSS_SCHEME: &str = "wss";
103    /// `WSS` protocol default port
104    pub const WSS_DEFAULT_PORT: u16 = Self::HTTPS_DEFAULT_PORT;
105    /// `WSS` protocol.
106    pub const WSS: Self = Self(ProtocolKind::Wss);
107
108    /// `SOCKS5` protocol scheme
109    pub const SOCKS5_SCHEME: &str = "socks5";
110    /// `SOCKS5` protocol default port
111    pub const SOCKS5_DEFAULT_PORT: u16 = 1080;
112    /// `SOCKS5` protocol.
113    pub const SOCKS5: Self = Self(ProtocolKind::Socks5);
114
115    /// `SOCKS5H` protocol scheme
116    pub const SOCKS5H_SCHEME: &str = "socks5h";
117    /// `SOCKS5H` protocol default port
118    pub const SOCKS5H_DEFAULT_PORT: u16 = Self::SOCKS5_DEFAULT_PORT;
119    /// `SOCKS5H` protocol.
120    pub const SOCKS5H: Self = Self(ProtocolKind::Socks5h);
121
122    /// `FILE` protocol scheme. RFC 8089 — `file:///path/to/x`.
123    pub const FILE_SCHEME: &str = "file";
124    /// The `file` protocol. Local-filesystem URI scheme: the URI
125    /// references a path on the host running the URI consumer.
126    /// Consumers (CLI tools, file fetchers) open the path directly
127    /// rather than dialing a network endpoint.
128    pub const FILE: Self = Self(ProtocolKind::File);
129
130    /// `DATA` protocol scheme. RFC 2397 — `data:[<mediatype>][;base64],<data>`.
131    pub const DATA_SCHEME: &str = "data";
132    /// The `data` protocol. Self-contained URI scheme: the URI itself
133    /// carries the payload, which consumers decode in place rather
134    /// than dialing a network endpoint or opening a file.
135    pub const DATA: Self = Self(ProtocolKind::Data);
136
137    /// Creates a Protocol from a str a compile time.
138    ///
139    /// This function requires the static string to be a valid protocol.
140    ///
141    /// It is intended to be used to facilitate the compile-time creation of
142    /// custom Protocols, as known protocols are easier created by using the desired
143    /// variant directly.
144    ///
145    /// # Panics
146    ///
147    /// This function panics at **compile time** when the static string is not a valid protocol.
148    #[must_use]
149    #[expect(
150        clippy::panic,
151        reason = "static-str invariant: panic at compile time when the static is not a valid protocol"
152    )]
153    pub const fn from_static(s: &'static str) -> Self {
154        // NOTE: once unwrapping is possible in const we can piggy back on
155        // `try_to_convert_str_to_non_custom_protocol`
156
157        Self(if eq_ignore_ascii_case!(s, Self::HTTPS_SCHEME) {
158            ProtocolKind::Https
159        } else if eq_ignore_ascii_case!(s, Self::HTTP_SCHEME) {
160            ProtocolKind::Http
161        } else if eq_ignore_ascii_case!(s, Self::SOCKS5_SCHEME) {
162            ProtocolKind::Socks5
163        } else if eq_ignore_ascii_case!(s, Self::SOCKS5H_SCHEME) {
164            ProtocolKind::Socks5h
165        } else if eq_ignore_ascii_case!(s, Self::WS_SCHEME) {
166            ProtocolKind::Ws
167        } else if eq_ignore_ascii_case!(s, Self::WSS_SCHEME) {
168            ProtocolKind::Wss
169        } else if eq_ignore_ascii_case!(s, Self::FILE_SCHEME) {
170            ProtocolKind::File
171        } else if eq_ignore_ascii_case!(s, Self::DATA_SCHEME) {
172            ProtocolKind::Data
173        } else if validate_scheme_str(s) {
174            ProtocolKind::Custom(SmolStr::new_static(s))
175        } else {
176            panic!("invalid static protocol str");
177        })
178    }
179
180    /// Returns `true` if this protocol is http(s).
181    #[must_use]
182    pub fn is_http(&self) -> bool {
183        match &self.0 {
184            ProtocolKind::Http | ProtocolKind::Https => true,
185            ProtocolKind::Ws
186            | ProtocolKind::Wss
187            | ProtocolKind::Socks5
188            | ProtocolKind::Socks5h
189            | ProtocolKind::File
190            | ProtocolKind::Data
191            | ProtocolKind::Custom(_) => false,
192        }
193    }
194
195    /// Returns `true` if this protocol is ws(s).
196    #[must_use]
197    pub fn is_ws(&self) -> bool {
198        match &self.0 {
199            ProtocolKind::Ws | ProtocolKind::Wss => true,
200            ProtocolKind::Http
201            | ProtocolKind::Https
202            | ProtocolKind::Socks5
203            | ProtocolKind::Socks5h
204            | ProtocolKind::File
205            | ProtocolKind::Data
206            | ProtocolKind::Custom(_) => false,
207        }
208    }
209
210    /// Returns `true` if this protocol is socks5.
211    #[must_use]
212    pub fn is_socks5(&self) -> bool {
213        match &self.0 {
214            ProtocolKind::Socks5 | ProtocolKind::Socks5h => true,
215            ProtocolKind::Http
216            | ProtocolKind::Https
217            | ProtocolKind::Ws
218            | ProtocolKind::Wss
219            | ProtocolKind::File
220            | ProtocolKind::Data
221            | ProtocolKind::Custom(_) => false,
222        }
223    }
224
225    /// Returns `true` if this protocol is "secure" by itself.
226    #[must_use]
227    pub fn is_secure(&self) -> bool {
228        match &self.0 {
229            ProtocolKind::Https | ProtocolKind::Wss => true,
230            ProtocolKind::Ws
231            | ProtocolKind::Http
232            | ProtocolKind::Socks5
233            | ProtocolKind::Socks5h
234            | ProtocolKind::File
235            | ProtocolKind::Data
236            | ProtocolKind::Custom(_) => false,
237        }
238    }
239
240    /// Returns the default port for this [`Protocol`].
241    ///
242    /// Registered defaults: `http=80`, `https=443`, `ws=80`, `wss=443`,
243    /// `socks5=1080`, `socks5h=1080`. Other schemes (`ftp:21`, `ssh:22`,
244    /// `ldap:389`, …) return `None` — `Protocol`'s scope is the
245    /// web-protocol set rama actively models.
246    /// [`crate::uri::Uri::canonicalize`] only drops ports that match a
247    /// registered default, so `ftp://host:21/` keeps its `:21`. This
248    /// diverges from WHATWG-URL (which strips `ftp:21`).
249    ///
250    /// The set of supported protocols grows with the needs that justify them.
251    #[must_use]
252    pub fn default_port(&self) -> Option<u16> {
253        match &self.0 {
254            ProtocolKind::Https => Some(Self::HTTPS_DEFAULT_PORT),
255            ProtocolKind::Wss => Some(Self::WSS_DEFAULT_PORT),
256            ProtocolKind::Http => Some(Self::HTTP_DEFAULT_PORT),
257            ProtocolKind::Ws => Some(Self::WS_DEFAULT_PORT),
258            ProtocolKind::Socks5 => Some(Self::SOCKS5_DEFAULT_PORT),
259            ProtocolKind::Socks5h => Some(Self::SOCKS5H_DEFAULT_PORT),
260            // `file:`/`data:` are not network protocols — no default port.
261            ProtocolKind::File | ProtocolKind::Data | ProtocolKind::Custom(_) => None,
262        }
263    }
264
265    /// Returns the default port when this protocol is used to reach a proxy.
266    ///
267    /// An HTTP proxy URL follows the long-standing curl convention of port
268    /// 1080 when its authority omits a port. HTTPS, SOCKS5, and SOCKS5H use
269    /// their protocol defaults. Other protocols do not have an implicit proxy
270    /// port.
271    #[must_use]
272    pub fn proxy_default_port(&self) -> Option<u16> {
273        match &self.0 {
274            ProtocolKind::Http => Some(Self::HTTP_PROXY_DEFAULT_PORT),
275            ProtocolKind::Https => Some(Self::HTTPS_DEFAULT_PORT),
276            ProtocolKind::Socks5 => Some(Self::SOCKS5_DEFAULT_PORT),
277            ProtocolKind::Socks5h => Some(Self::SOCKS5H_DEFAULT_PORT),
278            ProtocolKind::Ws
279            | ProtocolKind::Wss
280            | ProtocolKind::File
281            | ProtocolKind::Data
282            | ProtocolKind::Custom(_) => None,
283        }
284    }
285
286    /// Returns the [`Protocol`] as a string.
287    #[must_use]
288    pub fn as_str(&self) -> &str {
289        match &self.0 {
290            ProtocolKind::Http => Self::HTTP_SCHEME,
291            ProtocolKind::Https => Self::HTTPS_SCHEME,
292            ProtocolKind::Ws => Self::WS_SCHEME,
293            ProtocolKind::Wss => Self::WSS_SCHEME,
294            ProtocolKind::Socks5 => Self::SOCKS5_SCHEME,
295            ProtocolKind::Socks5h => Self::SOCKS5H_SCHEME,
296            ProtocolKind::File => Self::FILE_SCHEME,
297            ProtocolKind::Data => Self::DATA_SCHEME,
298            ProtocolKind::Custom(s) => s.as_ref(),
299        }
300    }
301
302    /// Return the RFC 3986 canonical presentation of this scheme.
303    ///
304    /// Known protocols are stored canonically already. A custom scheme is
305    /// ASCII-lowercased, allocating only when its presentation contains an
306    /// uppercase letter.
307    #[must_use]
308    pub fn canonicalize(self) -> Self {
309        match self.0 {
310            ProtocolKind::Custom(scheme)
311                if scheme.bytes().any(|byte| byte.is_ascii_uppercase()) =>
312            {
313                Self(ProtocolKind::Custom(SmolStr::new(
314                    scheme.to_ascii_lowercase(),
315                )))
316            }
317            _ => self,
318        }
319    }
320}
321
322rama_utils::macros::error::static_str_error! {
323    #[doc = "invalid protocol string"]
324    pub struct InvalidProtocolStr;
325}
326
327fn try_to_convert_str_to_non_custom_protocol(
328    s: &str,
329) -> Result<Option<Protocol>, InvalidProtocolStr> {
330    Ok(Some(Protocol(
331        if eq_ignore_ascii_case!(s, Protocol::HTTPS_SCHEME) {
332            ProtocolKind::Https
333        } else if eq_ignore_ascii_case!(s, Protocol::HTTP_SCHEME) {
334            ProtocolKind::Http
335        } else if eq_ignore_ascii_case!(s, Protocol::SOCKS5_SCHEME) {
336            ProtocolKind::Socks5
337        } else if eq_ignore_ascii_case!(s, Protocol::SOCKS5H_SCHEME) {
338            ProtocolKind::Socks5h
339        } else if eq_ignore_ascii_case!(s, Protocol::WS_SCHEME) {
340            ProtocolKind::Ws
341        } else if eq_ignore_ascii_case!(s, Protocol::WSS_SCHEME) {
342            ProtocolKind::Wss
343        } else if eq_ignore_ascii_case!(s, Protocol::FILE_SCHEME) {
344            ProtocolKind::File
345        } else if eq_ignore_ascii_case!(s, Protocol::DATA_SCHEME) {
346            ProtocolKind::Data
347        } else if validate_scheme_str(s) {
348            return Ok(None);
349        } else {
350            return Err(InvalidProtocolStr);
351        },
352    )))
353}
354
355impl TryFrom<&str> for Protocol {
356    type Error = InvalidProtocolStr;
357
358    fn try_from(s: &str) -> Result<Self, Self::Error> {
359        // `SmolStr::new` — *not* `new_inline`. `new_inline` panics if the
360        // input exceeds the 23-byte inline cap; the URI parser does not
361        // cap scheme length (RFC 3986 doesn't either), so a custom scheme
362        // > 23 bytes is a valid graceful input and must not abort.
363        Ok(try_to_convert_str_to_non_custom_protocol(s)?
364            .unwrap_or_else(|| Self(ProtocolKind::Custom(SmolStr::new(s)))))
365    }
366}
367
368impl TryFrom<String> for Protocol {
369    type Error = InvalidProtocolStr;
370
371    fn try_from(s: String) -> Result<Self, Self::Error> {
372        Ok(try_to_convert_str_to_non_custom_protocol(&s)?
373            .unwrap_or(Self(ProtocolKind::Custom(SmolStr::new(s)))))
374    }
375}
376
377impl TryFrom<&String> for Protocol {
378    type Error = InvalidProtocolStr;
379
380    fn try_from(s: &String) -> Result<Self, Self::Error> {
381        Ok(try_to_convert_str_to_non_custom_protocol(s)?
382            .unwrap_or_else(|| Self(ProtocolKind::Custom(SmolStr::new(s)))))
383    }
384}
385
386impl FromStr for Protocol {
387    type Err = InvalidProtocolStr;
388
389    fn from_str(s: &str) -> Result<Self, Self::Err> {
390        s.try_into()
391    }
392}
393
394impl PartialEq<str> for Protocol {
395    fn eq(&self, other: &str) -> bool {
396        match &self.0 {
397            ProtocolKind::Https => other.eq_ignore_ascii_case(Self::HTTPS_SCHEME),
398            ProtocolKind::Http => other.eq_ignore_ascii_case(Self::HTTP_SCHEME) || other.is_empty(),
399            ProtocolKind::Socks5 => other.eq_ignore_ascii_case(Self::SOCKS5_SCHEME),
400            ProtocolKind::Socks5h => other.eq_ignore_ascii_case(Self::SOCKS5H_SCHEME),
401            ProtocolKind::Ws => other.eq_ignore_ascii_case(Self::WS_SCHEME),
402            ProtocolKind::Wss => other.eq_ignore_ascii_case(Self::WSS_SCHEME),
403            ProtocolKind::File => other.eq_ignore_ascii_case(Self::FILE_SCHEME),
404            ProtocolKind::Data => other.eq_ignore_ascii_case(Self::DATA_SCHEME),
405            ProtocolKind::Custom(s) => other.eq_ignore_ascii_case(s),
406        }
407    }
408}
409
410impl PartialEq<String> for Protocol {
411    fn eq(&self, other: &String) -> bool {
412        self == other.as_str()
413    }
414}
415
416impl PartialEq<&str> for Protocol {
417    fn eq(&self, other: &&str) -> bool {
418        self == *other
419    }
420}
421
422impl PartialEq<Protocol> for str {
423    fn eq(&self, other: &Protocol) -> bool {
424        other == self
425    }
426}
427
428impl PartialEq<Protocol> for String {
429    fn eq(&self, other: &Protocol) -> bool {
430        other == self.as_str()
431    }
432}
433
434impl PartialEq<Protocol> for &str {
435    #[inline(always)]
436    fn eq(&self, other: &Protocol) -> bool {
437        other == *self
438    }
439}
440
441impl core::fmt::Display for Protocol {
442    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
443        self.as_str().fmt(f)
444    }
445}
446
447pub(crate) fn try_to_extract_protocol_from_uri_scheme(
448    s: &[u8],
449) -> Result<(Option<Protocol>, usize), BoxError> {
450    if s.is_empty() {
451        return Err(BoxError::from_static_str("empty uri contains no scheme"));
452    }
453
454    for i in 0..min(s.len(), 512) {
455        let b = s[i];
456
457        if b == b':' {
458            // Not enough data remaining
459            if s.len() < i + 3 {
460                break;
461            }
462
463            // Not a scheme
464            if &s[i + 1..i + 3] != b"//" {
465                break;
466            }
467
468            let str =
469                core::str::from_utf8(&s[..i]).context("interpret scheme bytes as utf-8 str")?;
470            let protocol = str
471                .try_into()
472                .context("parse scheme utf-8 str as protocol")?;
473            return Ok((Some(protocol), i + 3));
474        }
475    }
476
477    Ok((None, 0))
478}
479
480#[inline]
481const fn validate_scheme_str(s: &str) -> bool {
482    validate_scheme_slice(s.as_bytes())
483}
484
485const fn validate_scheme_slice(s: &[u8]) -> bool {
486    if s.is_empty() || s.len() > MAX_SCHEME_LEN {
487        return false;
488    }
489
490    let mut i = 0;
491    while i < s.len() {
492        if SCHEME_CHARS[s[i] as usize] == 0 {
493            return false;
494        }
495        i += 1;
496    }
497    true
498}
499
500// Require the scheme to not be too long in order to enable further
501// optimizations later.
502pub(crate) const MAX_SCHEME_LEN: usize = 64;
503
504// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
505//
506// SCHEME_CHARS is a table of valid characters in the scheme part of a URI.  An
507// entry in the table is 0 for invalid characters. For valid characters the
508// entry is itself (i.e.  the entry for 43 is b'+' because b'+' == 43u8). An
509// important characteristic of this table is that all entries above 127 are
510// invalid. This makes all of the valid entries a valid single-byte UTF-8 code
511// point. This means that a slice of such valid entries is valid UTF-8.
512#[rustfmt::skip]
513const SCHEME_CHARS: [u8; 256] = [
514    //  0      1      2      3      4      5      6      7      8      9
515        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, //   x
516        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, //  1x
517        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, //  2x
518        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, //  3x
519        0,     0,     0,  b'+',     0,  b'-',  b'.',     0,  b'0',  b'1', //  4x
520     b'2',  b'3',  b'4',  b'5',  b'6',  b'7',  b'8',  b'9',     0,     0, //  5x
521        0,     0,     0,     0,     0,  b'A',  b'B',  b'C',  b'D',  b'E', //  6x
522     b'F',  b'G',  b'H',  b'I',  b'J',  b'K',  b'L',  b'M',  b'N',  b'O', //  7x
523     b'P',  b'Q',  b'R',  b'S',  b'T',  b'U',  b'V',  b'W',  b'X',  b'Y', //  8x
524     b'Z',     0,     0,     0,     0,     0,     0,  b'a',  b'b',  b'c', //  9x
525     b'd',  b'e',  b'f',  b'g',  b'h',  b'i',  b'j',  b'k',  b'l',  b'm', // 10x
526     b'n',  b'o',  b'p',  b'q',  b'r',  b's',  b't',  b'u',  b'v',  b'w', // 11x
527     b'x',  b'y',  b'z',     0,     0,     0,     0,     0,     0,     0, // 12x
528        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 13x
529        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 14x
530        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 15x
531        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 16x
532        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 17x
533        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 18x
534        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 19x
535        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 20x
536        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 21x
537        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 22x
538        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 23x
539        0,     0,     0,     0,     0,     0,     0,     0,     0,     0, // 24x
540        0,     0,     0,     0,     0,     0                              // 25x
541];
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_from_str() {
549        assert_eq!("http".parse(), Ok(Protocol::HTTP));
550        assert_eq!("https".parse(), Ok(Protocol::HTTPS));
551        assert_eq!("ws".parse(), Ok(Protocol::WS));
552        assert_eq!("wss".parse(), Ok(Protocol::WSS));
553        assert_eq!("socks5".parse(), Ok(Protocol::SOCKS5));
554        assert_eq!("socks5h".parse(), Ok(Protocol::SOCKS5H));
555        assert_eq!("file".parse(), Ok(Protocol::FILE));
556        assert_eq!("data".parse(), Ok(Protocol::DATA));
557        assert_eq!("custom".parse(), Ok(Protocol::from_static("custom")));
558    }
559
560    #[test]
561    fn canonicalize_lowercases_custom_schemes() {
562        assert_eq!(
563            Protocol::from_static("CuStOm").canonicalize().as_str(),
564            "custom"
565        );
566        assert_eq!(Protocol::HTTPS.canonicalize(), Protocol::HTTPS);
567    }
568
569    #[test]
570    fn test_non_network_schemes() {
571        for (protocol, scheme) in [
572            (Protocol::FILE, Protocol::FILE_SCHEME),
573            (Protocol::DATA, Protocol::DATA_SCHEME),
574        ] {
575            // guards the const ladder against the runtime ladder drifting apart
576            assert_eq!(Protocol::from_static(scheme), protocol);
577            assert_eq!(scheme.parse(), Ok(protocol.clone()));
578            assert_eq!(scheme.to_uppercase().parse(), Ok(protocol.clone()));
579            assert_eq!(protocol.as_str(), scheme);
580            assert_eq!(protocol.default_port(), None);
581            assert!(!protocol.is_http());
582            assert!(!protocol.is_ws());
583            assert!(!protocol.is_socks5());
584            assert!(!protocol.is_secure());
585        }
586    }
587
588    #[test]
589    fn proxy_default_ports_are_transport_specific() {
590        for (protocol, expected) in [
591            (Protocol::HTTP, Some(Protocol::HTTP_PROXY_DEFAULT_PORT)),
592            (Protocol::HTTPS, Some(Protocol::HTTPS_DEFAULT_PORT)),
593            (Protocol::SOCKS5, Some(Protocol::SOCKS5_DEFAULT_PORT)),
594            (Protocol::SOCKS5H, Some(Protocol::SOCKS5H_DEFAULT_PORT)),
595            (Protocol::WS, None),
596            (Protocol::WSS, None),
597            (Protocol::FILE, None),
598            (Protocol::DATA, None),
599            (Protocol::from_static("custom"), None),
600        ] {
601            assert_eq!(protocol.proxy_default_port(), expected, "{protocol}");
602        }
603    }
604
605    #[test]
606    fn empty_scheme_rejected() {
607        // Per RFC 3986 §3.1 `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-"
608        // / "." )` — empty is not valid. Reject explicitly rather than
609        // silently defaulting to HTTP.
610        "".parse::<Protocol>().unwrap_err();
611        Protocol::try_from("").unwrap_err();
612    }
613
614    #[test]
615    fn try_from_rejects_non_ascii_scheme() {
616        // RFC 3986 §3.1: scheme is ASCII only. `validate_scheme_str`
617        // catches non-ASCII bytes via the byte-set LUT (all entries
618        // above 0x7F are 0). Confirms the typed constructor enforces
619        // the same constraint as the parser's per-byte byte-set check.
620        Protocol::try_from("müncheme").unwrap_err();
621        Protocol::try_from("ab cd").unwrap_err();
622        Protocol::try_from("ab\0").unwrap_err();
623        // Valid: ASCII alpha + sub-delims allowed by the scheme grammar.
624        Protocol::try_from("git+ssh").unwrap();
625        Protocol::try_from("coap+tcp").unwrap();
626    }
627
628    #[test]
629    fn regression_custom_scheme_over_smolstr_inline_cap_does_not_panic() {
630        // Uri-fuzzer regression: a 25-byte all-ASCII custom scheme is a
631        // perfectly valid RFC 3986 scheme but exceeds `SmolStr`'s 23-byte
632        // inline cap. `Protocol::try_from(&str)` previously used
633        // `SmolStr::new_inline`, which panics over the cap. Now uses
634        // `SmolStr::new`, which heap-allocates beyond the cap.
635        let long = "hhhhhhahhhhhhhhhhhhhhhhhh"; // 25 bytes
636        assert_eq!(long.len(), 25);
637        let proto: Protocol = long.try_into().unwrap();
638        assert_eq!(proto.as_str(), long);
639
640        // Also exercise the parser path that the fuzzer hit.
641        let uri: crate::uri::Uri = format!("{long}:/aq").parse().unwrap();
642        assert_eq!(uri.scheme().unwrap().as_str(), long);
643    }
644
645    #[test]
646    fn test_scheme_is_secure() {
647        assert!(!Protocol::HTTP.is_secure());
648        assert!(Protocol::HTTPS.is_secure());
649        assert!(!Protocol::SOCKS5.is_secure());
650        assert!(!Protocol::SOCKS5H.is_secure());
651        assert!(!Protocol::WS.is_secure());
652        assert!(Protocol::WSS.is_secure());
653        assert!(!Protocol::FILE.is_secure());
654        assert!(!Protocol::DATA.is_secure());
655        assert!(!Protocol::from_static("custom").is_secure());
656    }
657
658    #[test]
659    fn test_try_to_extract_protocol_from_uri_scheme() {
660        for (s, expected) in [
661            ("", None),
662            ("http://example.com", Some((Some(Protocol::HTTP), 7))),
663            ("https://example.com", Some((Some(Protocol::HTTPS), 8))),
664            ("ws://example.com", Some((Some(Protocol::WS), 5))),
665            ("wss://example.com", Some((Some(Protocol::WSS), 6))),
666            ("socks5://example.com", Some((Some(Protocol::SOCKS5), 9))),
667            ("socks5h://example.com", Some((Some(Protocol::SOCKS5H), 10))),
668            (
669                "custom://example.com",
670                Some((Some(Protocol::from_static("custom")), 9)),
671            ),
672            (" http://example.com", None),
673            ("example.com", Some((None, 0))),
674            ("127.0.0.1", Some((None, 0))),
675            ("127.0.0.1:8080", Some((None, 0))),
676            (
677                "longlonglongwaytoolongforsomethingusefulorvaliddontyouthinkmydearreader://example.com",
678                None,
679            ),
680        ] {
681            let result = try_to_extract_protocol_from_uri_scheme(s.as_bytes());
682            match expected {
683                Some(t) => match result {
684                    Err(err) => panic!("unexpected err: {err} (case: {s}"),
685                    Ok(p) => assert_eq!(t, p, "case: {s}"),
686                },
687                None => assert!(result.is_err(), "case: {s}, result: {result:?}"),
688            }
689        }
690    }
691}