sim_lib_net_core/url.rs
1//! URL parsing into structural components.
2
3use crate::NetError;
4
5/// The structural components of an absolute URL.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct UrlParts {
8 /// Lowercase-preserving scheme exactly as written (e.g. `http`, `https`).
9 pub scheme: String,
10 /// Host without port.
11 pub host: String,
12 /// Port: the explicit port if present, else the scheme default
13 /// (`http` -> 80, `https` -> 443).
14 pub port: u16,
15 /// Request path, defaulting to `/` when the URL has no path component.
16 /// A non-empty path has any trailing slash trimmed (except the lone `/`).
17 pub path: String,
18}
19
20/// Parse a `scheme://host[:port][/path]` URL.
21///
22/// Extracted from `sim-lib-agent-runner-http`'s `parse_url`. Differences from
23/// that internal helper, which are intentional for a shared primitive:
24///
25/// * Default ports are resolved here for `http` (80) and `https` (443); any
26/// other scheme is rejected with [`NetError::UnsupportedScheme`] when no
27/// explicit port is given. (The client only used `http`/`https`.)
28/// * The path defaults to `/` instead of the empty string when absent, so the
29/// result is a usable request target on its own.
30pub fn parse_url(url: &str) -> Result<UrlParts, NetError> {
31 let (scheme, rest) = url
32 .split_once("://")
33 .ok_or_else(|| NetError::MalformedUrl(url.to_owned()))?;
34
35 let (host_port, path) = match rest.split_once('/') {
36 Some((host_port, suffix)) => {
37 let trimmed = suffix.trim_end_matches('/');
38 (host_port, format!("/{trimmed}"))
39 }
40 None => (rest, "/".to_owned()),
41 };
42
43 let (host, port) = match host_port.rsplit_once(':') {
44 Some((host, port)) => (
45 host.to_owned(),
46 port.parse::<u16>()
47 .map_err(|_| NetError::InvalidPort(url.to_owned()))?,
48 ),
49 None => (host_port.to_owned(), default_port_for_scheme(scheme, url)?),
50 };
51
52 if host.is_empty() {
53 return Err(NetError::MalformedUrl(url.to_owned()));
54 }
55
56 Ok(UrlParts {
57 scheme: scheme.to_owned(),
58 host,
59 port,
60 path,
61 })
62}
63
64/// Parse a URL and require a specific `scheme`, applying `default_path` when the
65/// URL carries no path component.
66///
67/// A policy-free convenience over [`parse_url`] for transport callers that know
68/// which scheme they speak and want a usable request target:
69///
70/// * The scheme must equal `scheme` exactly, else
71/// [`NetError::UnexpectedScheme`] is returned. (Callers still get default-port
72/// resolution for `http`/`https` from [`parse_url`].)
73/// * When the URL had no path, [`parse_url`] yields `/`; `parse_url_for_scheme`
74/// substitutes `default_path` in that case, so a bare `scheme://host` becomes
75/// a request target at the caller's default endpoint.
76pub fn parse_url_for_scheme(
77 url: &str,
78 scheme: &str,
79 default_path: &str,
80) -> Result<UrlParts, NetError> {
81 let mut parts = parse_url(url)?;
82 if parts.scheme != scheme {
83 return Err(NetError::UnexpectedScheme {
84 expected: scheme.to_owned(),
85 found: parts.scheme,
86 });
87 }
88 if parts.path == "/" {
89 parts.path = default_path.to_owned();
90 }
91 Ok(parts)
92}
93
94/// Parse a URL for a known `scheme`, resolving `ws`/`wss` default ports and
95/// preserving a caller-supplied trailing slash in the path.
96///
97/// A superset variant of [`parse_url_for_scheme`] for transports that also
98/// speak WebSocket URLs and treat a trailing slash as significant. The
99/// differences from [`parse_url`]/[`parse_url_for_scheme`], all driven by data
100/// rather than transport policy:
101///
102/// * Default ports resolve for `ws` (80) and `wss` (443) in addition to `http`
103/// (80) and `https` (443); any other scheme without an explicit port is
104/// rejected with [`NetError::UnsupportedScheme`].
105/// * The path is taken verbatim after the first `/`, so a caller's trailing
106/// slash is preserved (`scheme://host/a/` keeps `/a/`), where [`parse_url`]
107/// trims it. A request target and its trailing-slash variant address
108/// different resources, so a transport that reconstructs the URL must not lose
109/// the distinction.
110/// * `default_path` is substituted only when the URL carries no path component
111/// at all (no `/` after the authority); `scheme://host/` yields `/`, not
112/// `default_path`.
113///
114/// The scheme must equal `scheme` exactly, else [`NetError::UnexpectedScheme`]
115/// is returned. The scheme list and ports are data; the function opens no
116/// sockets and applies no transport policy.
117pub fn parse_url_for_scheme_preserving_path(
118 url: &str,
119 scheme: &str,
120 default_path: &str,
121) -> Result<UrlParts, NetError> {
122 let (url_scheme, rest) = url
123 .split_once("://")
124 .ok_or_else(|| NetError::MalformedUrl(url.to_owned()))?;
125 if url_scheme != scheme {
126 return Err(NetError::UnexpectedScheme {
127 expected: scheme.to_owned(),
128 found: url_scheme.to_owned(),
129 });
130 }
131
132 let (host_port, path) = match rest.split_once('/') {
133 Some((host_port, suffix)) => (host_port, format!("/{suffix}")),
134 None => (rest, default_path.to_owned()),
135 };
136
137 let (host, port) = match host_port.rsplit_once(':') {
138 Some((host, port)) => (
139 host.to_owned(),
140 port.parse::<u16>()
141 .map_err(|_| NetError::InvalidPort(url.to_owned()))?,
142 ),
143 None => (
144 host_port.to_owned(),
145 web_default_port_for_scheme(url_scheme)
146 .ok_or_else(|| NetError::UnsupportedScheme(format!("{scheme} in {url}")))?,
147 ),
148 };
149
150 if host.is_empty() {
151 return Err(NetError::MalformedUrl(url.to_owned()));
152 }
153
154 Ok(UrlParts {
155 scheme: url_scheme.to_owned(),
156 host,
157 port,
158 path,
159 })
160}
161
162fn default_port_for_scheme(scheme: &str, url: &str) -> Result<u16, NetError> {
163 match scheme {
164 "http" => Ok(80),
165 "https" => Ok(443),
166 _ => Err(NetError::UnsupportedScheme(format!("{scheme} in {url}"))),
167 }
168}
169
170/// The registered default port for a web-transport scheme, or `None` when the
171/// scheme has no default in this table.
172///
173/// The table is data, not policy: `http`/`ws` -> 80, `https`/`wss` -> 443.
174fn web_default_port_for_scheme(scheme: &str) -> Option<u16> {
175 match scheme {
176 "http" | "ws" => Some(80),
177 "https" | "wss" => Some(443),
178 _ => None,
179 }
180}