Skip to main content

rustfs_targets/
net.rs

1// Copyright 2024 RustFS Team
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
15use crate::target::REDACTED_SECRET;
16use hashbrown::HashMap;
17use hyper::HeaderMap;
18use regex::Regex;
19use s3s::{S3Request, S3Response};
20use serde::{Deserialize, Serialize};
21use std::net::IpAddr;
22use std::path::Path;
23use std::sync::LazyLock;
24use thiserror::Error;
25use url::Url;
26
27/// Request headers whose values carry credentials or session tokens and must
28/// never be serialized verbatim into audit/notification entries, which are
29/// forwarded to external sinks (webhook/kafka/file/...). Matched
30/// case-insensitively; hyper lowercases header names, but we normalize
31/// defensively so this stays correct for any caller.
32const SENSITIVE_HEADERS: &[&str] = &[
33    "authorization",
34    "x-amz-security-token",
35    "x-amz-content-sha256",
36    "x-amz-server-side-encryption-customer-key",
37    "x-amz-server-side-encryption-customer-key-md5",
38    "x-amz-copy-source-server-side-encryption-customer-key",
39    "x-amz-copy-source-server-side-encryption-customer-key-md5",
40    "cookie",
41    "set-cookie",
42];
43
44/// Returns true when the header name is credential-bearing and its value must
45/// be redacted before leaving the process.
46fn is_sensitive_header(name: &str) -> bool {
47    SENSITIVE_HEADERS.iter().any(|h| name.eq_ignore_ascii_case(h))
48}
49
50static HOST_LABEL_REGEX: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$").expect("operation should succeed"));
52
53/// NetError represents errors that can occur in network operations.
54#[derive(Error, Debug)]
55pub enum NetError {
56    #[error("invalid argument")]
57    InvalidArgument,
58    #[error("invalid hostname")]
59    InvalidHost,
60    #[error("missing '[' in host")]
61    MissingBracket,
62    #[error("parse error: {0}")]
63    ParseError(String),
64    #[error("unexpected scheme: {0}")]
65    UnexpectedScheme(String),
66    #[error("scheme appears with empty host")]
67    SchemeWithEmptyHost,
68}
69
70/// Host represents a network host with IP/name and port.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Host {
73    pub name: String,
74    pub port: Option<u16>,
75}
76
77impl Host {
78    pub fn is_empty(&self) -> bool {
79        self.name.is_empty()
80    }
81
82    pub fn equal(&self, other: &Host) -> bool {
83        self.to_string() == other.to_string()
84    }
85}
86
87impl std::fmt::Display for Host {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self.port {
90            Some(p) => write!(f, "{}:{}", self.name, p),
91            None => write!(f, "{}", self.name),
92        }
93    }
94}
95
96/// Extract request parameters from S3Request, mainly header information.
97pub fn extract_req_params<T>(req: &S3Request<T>) -> HashMap<String, String> {
98    extract_params_header(&req.headers)
99}
100
101/// Extract request parameters from hyper::HeaderMap, mainly header information.
102/// This function is useful when you have a raw HTTP request and need to extract parameters.
103#[deprecated(since = "0.1.0", note = "Use extract_params_header instead")]
104pub fn extract_req_params_header(head: &HeaderMap) -> HashMap<String, String> {
105    extract_params_header(head)
106}
107
108/// Extract parameters from hyper::HeaderMap, mainly header information.
109///
110/// Credential-bearing headers (see [`SENSITIVE_HEADERS`]) are redacted: the
111/// header name is preserved for correlation, but its value is replaced with
112/// [`REDACTED_SECRET`] so secrets never reach downstream audit/notification
113/// sinks. Non-sensitive headers keep their existing behavior.
114pub fn extract_params_header(head: &HeaderMap) -> HashMap<String, String> {
115    let mut params = HashMap::new();
116    for (key, value) in head.iter() {
117        let name = key.as_str();
118        if is_sensitive_header(name) {
119            params.insert(name.to_string(), REDACTED_SECRET.to_string());
120        } else if let Ok(val_str) = value.to_str() {
121            params.insert(name.to_string(), val_str.to_string());
122        }
123    }
124    params
125}
126
127/// Extract response elements from S3Response, mainly header information.
128pub fn extract_resp_elements<T>(resp: &S3Response<T>) -> HashMap<String, String> {
129    extract_params_header(&resp.headers)
130}
131
132/// Get host from header information.
133pub fn get_request_host(headers: &HeaderMap) -> String {
134    headers
135        .get("host")
136        .and_then(|v| v.to_str().ok())
137        .unwrap_or_default()
138        .to_string()
139}
140
141/// Get Port from header information.
142/// Priority:
143/// 1. x-forwarded-port
144/// 2. host header (parse port)
145/// 3. x-forwarded-proto inferred default (http=80, https=443) when host has no explicit port
146/// 4. port header
147pub fn get_request_port(headers: &HeaderMap) -> u16 {
148    if let Some(port) = headers
149        .get("x-forwarded-port")
150        .and_then(|v| v.to_str().ok())
151        .and_then(|s| s.parse::<u16>().ok())
152    {
153        return port;
154    }
155
156    if let Some(host) = headers.get("host").and_then(|v| v.to_str().ok()) {
157        if let Some(idx) = host.rfind(':') {
158            let valid_colon = match host.rfind(']') {
159                Some(close_bracket_idx) => idx > close_bracket_idx,
160                None => true,
161            };
162
163            if valid_colon
164                && let Ok(port) = host[idx + 1..].parse::<u16>()
165                && port > 0
166            {
167                return port;
168            }
169        }
170
171        if let Some(proto) = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) {
172            match proto {
173                "http" => return 80,
174                "https" => return 443,
175                _ => {}
176            }
177        }
178    }
179
180    headers
181        .get("port")
182        .and_then(|v| v.to_str().ok())
183        .and_then(|s| s.parse::<u16>().ok())
184        .unwrap_or(0)
185}
186
187/// Get content-length from header information.
188pub fn get_request_content_length(headers: &HeaderMap) -> u64 {
189    headers
190        .get("content-length")
191        .and_then(|v| v.to_str().ok())
192        .and_then(|s| s.parse::<u64>().ok())
193        .unwrap_or(0)
194}
195
196/// Get referer from header information.
197pub fn get_request_referer(headers: &HeaderMap) -> String {
198    headers
199        .get("referer")
200        .and_then(|v| v.to_str().ok())
201        .unwrap_or_default()
202        .to_string()
203}
204
205/// Get user-agent from header information.
206pub fn get_request_user_agent(headers: &HeaderMap) -> String {
207    headers
208        .get("user-agent")
209        .and_then(|v| v.to_str().ok())
210        .unwrap_or_default()
211        .to_string()
212}
213
214/// parse_host parses a string into a Host, with validation similar to Go's ParseHost.
215pub fn parse_host(s: &str) -> Result<Host, NetError> {
216    if s.is_empty() {
217        return Err(NetError::InvalidArgument);
218    }
219
220    let is_valid_host = |host: &str| -> bool {
221        if host.is_empty() {
222            return true;
223        }
224        if host.parse::<IpAddr>().is_ok() {
225            return true;
226        }
227        if !(1..=253).contains(&host.len()) {
228            return false;
229        }
230        for (i, label) in host.split('.').enumerate() {
231            if i + 1 == host.split('.').count() && label.is_empty() {
232                continue;
233            }
234            if !(1..=63).contains(&label.len()) || !HOST_LABEL_REGEX.is_match(label) {
235                return false;
236            }
237        }
238        true
239    };
240
241    let (host, port) = if let Some(rest) = s.strip_prefix('[') {
242        let Some(end) = rest.find(']') else {
243            return Err(NetError::MissingBracket);
244        };
245        let host = rest[..end].to_string();
246        let port_str = &rest[end + 1..];
247        let port = if let Some(port_str) = port_str.strip_prefix(':') {
248            if port_str.is_empty() {
249                None
250            } else {
251                Some(port_str.parse().map_err(|_| NetError::ParseError(port_str.to_string()))?)
252            }
253        } else if port_str.is_empty() {
254            None
255        } else {
256            return Err(NetError::InvalidHost);
257        };
258
259        (host, port)
260    } else {
261        if s.contains(']') {
262            return Err(NetError::MissingBracket);
263        }
264
265        let (host_str, port_str) = if s.matches(':').count() > 1 {
266            (s, "")
267        } else {
268            s.rsplit_once(':').map_or((s, ""), |(h, p)| (h, p))
269        };
270        let port = if !port_str.is_empty() {
271            Some(port_str.parse().map_err(|_| NetError::ParseError(port_str.to_string()))?)
272        } else {
273            None
274        };
275
276        (trim_ipv6(host_str)?, port)
277    };
278
279    let trimmed_host = host.split('%').next().unwrap_or(&host);
280
281    if !is_valid_host(trimmed_host) {
282        return Err(NetError::InvalidHost);
283    }
284
285    Ok(Host { name: host, port })
286}
287
288fn trim_ipv6(host: &str) -> Result<String, NetError> {
289    if host.ends_with(']') {
290        if !host.starts_with('[') {
291            return Err(NetError::MissingBracket);
292        }
293        Ok(host[1..host.len() - 1].to_string())
294    } else {
295        Ok(host.to_string())
296    }
297}
298
299/// URL is a wrapper around url::Url for custom handling.
300#[derive(Debug, Clone)]
301pub struct ParsedURL(pub Url);
302
303impl ParsedURL {
304    pub fn is_empty(&self) -> bool {
305        self.0.as_str() == "" || (self.0.scheme() == "about" && self.0.path() == "blank")
306    }
307
308    pub fn hostname(&self) -> String {
309        self.0.host_str().unwrap_or("").to_string()
310    }
311
312    pub fn port(&self) -> String {
313        match self.0.port() {
314            Some(p) => p.to_string(),
315            None => match self.0.scheme() {
316                "http" => "80".to_string(),
317                "https" => "443".to_string(),
318                _ => "".to_string(),
319            },
320        }
321    }
322
323    pub fn scheme(&self) -> &str {
324        self.0.scheme()
325    }
326
327    pub fn url(&self) -> &Url {
328        &self.0
329    }
330}
331
332impl std::fmt::Display for ParsedURL {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        let mut url = self.0.clone();
335        if let Some(host) = url.host_str().map(|h| h.to_string())
336            && let Some(port) = url.port()
337            && ((url.scheme() == "http" && port == 80) || (url.scheme() == "https" && port == 443))
338        {
339            let _ = url.set_host(Some(&host));
340            let _ = url.set_port(None);
341        }
342        let mut s = url.to_string();
343
344        if s.ends_with('/') && url.path() == "/" {
345            s.pop();
346        }
347
348        write!(f, "{s}")
349    }
350}
351
352impl serde::Serialize for ParsedURL {
353    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
354    where
355        S: serde::Serializer,
356    {
357        serializer.serialize_str(&self.to_string())
358    }
359}
360
361impl<'de> serde::Deserialize<'de> for ParsedURL {
362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363    where
364        D: serde::Deserializer<'de>,
365    {
366        let s: String = serde::Deserialize::deserialize(deserializer)?;
367        if s.is_empty() {
368            Ok(ParsedURL(Url::parse("about:blank").expect("operation should succeed")))
369        } else {
370            parse_url(&s).map_err(serde::de::Error::custom)
371        }
372    }
373}
374
375/// parse_url parses a string into a ParsedURL, with host validation and path cleaning.
376pub fn parse_url(s: &str) -> Result<ParsedURL, NetError> {
377    if let Some(scheme_end) = s.find("://")
378        && s[scheme_end + 3..].starts_with('/')
379    {
380        let scheme = &s[..scheme_end];
381        if !scheme.is_empty() {
382            return Err(NetError::SchemeWithEmptyHost);
383        }
384    }
385
386    let mut uu = Url::parse(s).map_err(|e| NetError::ParseError(e.to_string()))?;
387    if uu.host_str().is_none_or(|h| h.is_empty()) {
388        if uu.scheme() != "" {
389            return Err(NetError::SchemeWithEmptyHost);
390        }
391    } else {
392        let port_str = uu.port().map(|p| p.to_string()).unwrap_or_else(|| match uu.scheme() {
393            "http" => "80".to_string(),
394            "https" => "443".to_string(),
395            _ => "".to_string(),
396        });
397
398        if !port_str.is_empty() {
399            let host_port = format!("{}:{}", uu.host_str().expect("operation should succeed"), port_str);
400            parse_host(&host_port)?;
401        }
402    }
403
404    if !uu.path().is_empty() {
405        let mut cleaned_path = String::new();
406        for comp in Path::new(uu.path()).components() {
407            use std::path::Component;
408            match comp {
409                Component::RootDir => cleaned_path.push('/'),
410                Component::Normal(s) => {
411                    if !cleaned_path.ends_with('/') {
412                        cleaned_path.push('/');
413                    }
414                    cleaned_path.push_str(&s.to_string_lossy());
415                }
416                _ => {}
417            }
418        }
419        if s.ends_with('/') && !cleaned_path.ends_with('/') {
420            cleaned_path.push('/');
421        }
422        if cleaned_path.is_empty() {
423            cleaned_path.push('/');
424        }
425        uu.set_path(&cleaned_path);
426    }
427
428    Ok(ParsedURL(uu))
429}
430
431pub fn parse_http_url(s: &str) -> Result<ParsedURL, NetError> {
432    let u = parse_url(s)?;
433    match u.0.scheme() {
434        "http" | "https" => Ok(u),
435        _ => Err(NetError::UnexpectedScheme(u.0.scheme().to_string())),
436    }
437}
438
439pub fn is_network_or_host_down(err: &std::io::Error, expect_timeouts: bool) -> bool {
440    if err.kind() == std::io::ErrorKind::TimedOut {
441        return !expect_timeouts;
442    }
443    let err_str = err.to_string().to_lowercase();
444    err_str.contains("connection reset by peer")
445        || err_str.contains("connection timed out")
446        || err_str.contains("broken pipe")
447        || err_str.contains("use of closed network connection")
448}
449
450pub fn is_conn_reset_err(err: &std::io::Error) -> bool {
451    err.to_string().contains("connection reset by peer") || matches!(err.raw_os_error(), Some(libc::ECONNRESET))
452}
453
454pub fn is_conn_refused_err(err: &std::io::Error) -> bool {
455    err.to_string().contains("connection refused") || matches!(err.raw_os_error(), Some(libc::ECONNREFUSED))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use hyper::header::HeaderValue;
462
463    #[test]
464    fn extract_params_header_redacts_credential_headers() {
465        let mut headers = HeaderMap::new();
466        headers.insert("authorization", HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=AKIA.../secret"));
467        headers.insert("x-amz-security-token", HeaderValue::from_static("FQoGZXIvYXdzE.../session-token"));
468        headers.insert("x-amz-content-sha256", HeaderValue::from_static("e3b0c44298fc1c149afbf4c8996fb924"));
469        headers.insert("x-amz-server-side-encryption-customer-key", HeaderValue::from_static("destination-key"));
470        headers.insert(
471            "x-amz-server-side-encryption-customer-key-md5",
472            HeaderValue::from_static("destination-key-md5"),
473        );
474        headers.insert(
475            "x-amz-copy-source-server-side-encryption-customer-key",
476            HeaderValue::from_static("source-key"),
477        );
478        headers.insert(
479            "x-amz-copy-source-server-side-encryption-customer-key-md5",
480            HeaderValue::from_static("source-key-md5"),
481        );
482        headers.insert("cookie", HeaderValue::from_static("session=abc123"));
483        headers.insert("content-type", HeaderValue::from_static("application/octet-stream"));
484        headers.insert("user-agent", HeaderValue::from_static("aws-cli/2.0"));
485
486        let params = extract_params_header(&headers);
487
488        // Sensitive headers keep their name for correlation but never leak the value.
489        for name in [
490            "authorization",
491            "x-amz-security-token",
492            "x-amz-content-sha256",
493            "x-amz-server-side-encryption-customer-key",
494            "x-amz-server-side-encryption-customer-key-md5",
495            "x-amz-copy-source-server-side-encryption-customer-key",
496            "x-amz-copy-source-server-side-encryption-customer-key-md5",
497            "cookie",
498        ] {
499            assert_eq!(params.get(name).map(String::as_str), Some(REDACTED_SECRET), "{name} must be redacted");
500        }
501        // Non-sensitive headers are preserved verbatim.
502        assert_eq!(params.get("content-type").map(String::as_str), Some("application/octet-stream"));
503        assert_eq!(params.get("user-agent").map(String::as_str), Some("aws-cli/2.0"));
504    }
505
506    #[test]
507    fn is_sensitive_header_matches_case_insensitively() {
508        assert!(is_sensitive_header("Authorization"));
509        assert!(is_sensitive_header("X-Amz-Security-Token"));
510        assert!(is_sensitive_header("X-AMZ-CONTENT-SHA256"));
511        assert!(is_sensitive_header("X-Amz-Server-Side-Encryption-Customer-Key"));
512        assert!(is_sensitive_header("X-Amz-Server-Side-Encryption-Customer-Key-MD5"));
513        assert!(is_sensitive_header("X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key"));
514        assert!(is_sensitive_header("X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5"));
515        assert!(!is_sensitive_header("content-type"));
516        assert!(!is_sensitive_header("x-amz-request-id"));
517    }
518
519    #[test]
520    fn test_get_request_port() {
521        let mut headers = HeaderMap::new();
522
523        assert_eq!(get_request_port(&headers), 0);
524
525        headers.insert("port", HeaderValue::from_static("8080"));
526        assert_eq!(get_request_port(&headers), 8080);
527
528        headers.remove("port");
529        headers.insert("host", HeaderValue::from_static("example.com:9000"));
530        assert_eq!(get_request_port(&headers), 9000);
531
532        headers.insert("host", HeaderValue::from_static("example.com"));
533        assert_eq!(get_request_port(&headers), 0);
534
535        headers.insert("host", HeaderValue::from_static("[::1]:9001"));
536        assert_eq!(get_request_port(&headers), 9001);
537
538        headers.insert("host", HeaderValue::from_static("[::1]"));
539        assert_eq!(get_request_port(&headers), 0);
540
541        headers.insert("x-forwarded-port", HeaderValue::from_static("7000"));
542        assert_eq!(get_request_port(&headers), 7000);
543
544        headers.remove("x-forwarded-port");
545        headers.insert("host", HeaderValue::from_static("example.com"));
546        headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
547        assert_eq!(get_request_port(&headers), 80);
548
549        headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
550        assert_eq!(get_request_port(&headers), 443);
551
552        headers.insert("x-forwarded-proto", HeaderValue::from_static("ftp"));
553        assert_eq!(get_request_port(&headers), 0);
554
555        headers.insert("host", HeaderValue::from_static("example.com:0"));
556        headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
557        assert_eq!(get_request_port(&headers), 443);
558
559        headers.remove("x-forwarded-proto");
560        assert_eq!(get_request_port(&headers), 0);
561    }
562
563    #[test]
564    fn parse_host_with_empty_string_returns_error() {
565        let result = parse_host("");
566        assert!(matches!(result, Err(NetError::InvalidArgument)));
567    }
568
569    #[test]
570    fn parse_host_with_valid_ipv4() {
571        let result = parse_host("192.168.1.1:8080");
572        assert!(result.is_ok());
573        let host = result.expect("operation should succeed");
574        assert_eq!(host.name, "192.168.1.1");
575        assert_eq!(host.port, Some(8080));
576    }
577
578    #[test]
579    fn parse_host_with_valid_hostname() {
580        let result = parse_host("example.com:443");
581        assert!(result.is_ok());
582        let host = result.expect("operation should succeed");
583        assert_eq!(host.name, "example.com");
584        assert_eq!(host.port, Some(443));
585    }
586
587    #[test]
588    fn parse_host_with_ipv6_brackets() {
589        let result = parse_host("[::1]:8080");
590        assert!(result.is_ok());
591        let host = result.expect("operation should succeed");
592        assert_eq!(host.name, "::1");
593        assert_eq!(host.port, Some(8080));
594    }
595
596    #[test]
597    fn parse_host_with_bare_ipv6_without_port() {
598        let result = parse_host("::1");
599        assert!(result.is_ok());
600        let host = result.expect("operation should succeed");
601        assert_eq!(host.name, "::1");
602        assert_eq!(host.port, None);
603    }
604
605    #[test]
606    fn parse_host_with_ipv6_zone_without_port() {
607        let result = parse_host("fe80::1%eth0");
608        assert!(result.is_ok());
609        let host = result.expect("operation should succeed");
610        assert_eq!(host.name, "fe80::1%eth0");
611        assert_eq!(host.port, None);
612    }
613
614    #[test]
615    fn parse_host_with_bracketed_ipv6_zone_and_port() {
616        let result = parse_host("[fe80::1%eth0]:9000");
617        assert!(result.is_ok());
618        let host = result.expect("operation should succeed");
619        assert_eq!(host.name, "fe80::1%eth0");
620        assert_eq!(host.port, Some(9000));
621    }
622
623    #[test]
624    fn parse_host_with_bracketed_ipv6_without_port() {
625        let result = parse_host("[::1]");
626        assert!(result.is_ok());
627        let host = result.expect("operation should succeed");
628        assert_eq!(host.name, "::1");
629        assert_eq!(host.port, None);
630    }
631
632    #[test]
633    fn parse_host_with_invalid_ipv6_missing_bracket() {
634        let result = parse_host("::1]:8080");
635        assert!(matches!(result, Err(NetError::MissingBracket)));
636    }
637
638    #[test]
639    fn parse_host_with_invalid_hostname() {
640        let result = parse_host("invalid..host:80");
641        assert!(matches!(result, Err(NetError::InvalidHost)));
642    }
643
644    #[test]
645    fn parse_host_without_port() {
646        let result = parse_host("example.com");
647        assert!(result.is_ok());
648        let host = result.expect("operation should succeed");
649        assert_eq!(host.name, "example.com");
650        assert_eq!(host.port, None);
651    }
652
653    #[test]
654    fn host_is_empty_when_name_is_empty() {
655        let host = Host {
656            name: "".to_string(),
657            port: None,
658        };
659        assert!(host.is_empty());
660    }
661
662    #[test]
663    fn host_is_not_empty_when_name_present() {
664        let host = Host {
665            name: "example.com".to_string(),
666            port: Some(80),
667        };
668        assert!(!host.is_empty());
669    }
670
671    #[test]
672    fn host_to_string_with_port() {
673        let host = Host {
674            name: "example.com".to_string(),
675            port: Some(80),
676        };
677        assert_eq!(host.to_string(), "example.com:80");
678    }
679
680    #[test]
681    fn host_to_string_without_port() {
682        let host = Host {
683            name: "example.com".to_string(),
684            port: None,
685        };
686        assert_eq!(host.to_string(), "example.com");
687    }
688
689    #[test]
690    fn parse_url_with_valid_http_url() {
691        let result = parse_url("http://example.com/path");
692        assert!(result.is_ok());
693        let parsed = result.expect("operation should succeed");
694        assert_eq!(parsed.hostname(), "example.com");
695        assert_eq!(parsed.port(), "80");
696        assert_eq!(parsed.scheme(), "http");
697        assert_eq!(parsed.to_string(), "http://example.com/path");
698    }
699
700    #[test]
701    fn parse_url_with_explicit_default_https_port() {
702        let result = parse_url("https://example.com:443/path");
703        assert!(result.is_ok());
704        let parsed = result.expect("operation should succeed");
705        assert_eq!(parsed.to_string(), "https://example.com/path");
706    }
707
708    #[test]
709    fn parse_url_with_empty_host_returns_error() {
710        let result = parse_url("http:///path");
711        assert!(matches!(result, Err(NetError::SchemeWithEmptyHost)));
712    }
713
714    #[test]
715    fn parse_url_with_invalid_host_returns_error() {
716        let result = parse_url("http://invalid..host/path");
717        assert!(matches!(result, Err(NetError::InvalidHost)));
718    }
719
720    #[test]
721    fn parse_url_normalizes_path() {
722        let result = parse_url("http://example.com//path/../path/");
723        assert!(result.is_ok());
724        let parsed = result.expect("operation should succeed");
725        assert_eq!(parsed.to_string(), "http://example.com/path/");
726    }
727}