Skip to main content

nano_get/url/
models.rs

1use std::fmt::{self, Display, Formatter};
2
3use crate::errors::NanoGetError;
4
5/// Parsed URL data used by request builders and transports.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Url {
8    /// URL scheme (`http` or `https`).
9    pub scheme: String,
10    /// Hostname or literal IP address (without IPv6 brackets).
11    pub host: String,
12    /// Effective network port.
13    pub port: u16,
14    /// Normalized path component (always starts with `/`).
15    pub path: String,
16    /// Optional query string without the leading `?`.
17    pub query: Option<String>,
18    explicit_port: bool,
19}
20
21impl Url {
22    /// Parses a URL string into a [`Url`].
23    ///
24    /// If no scheme is provided, `http` is assumed.
25    pub fn parse(input: &str) -> Result<Self, NanoGetError> {
26        let trimmed = input.trim();
27        if trimmed.is_empty() {
28            return Err(NanoGetError::invalid_url("URL cannot be empty"));
29        }
30
31        let (scheme, remainder) = match trimmed.find("://") {
32            Some(index) => {
33                let scheme = &trimmed[..index];
34                validate_scheme(scheme)?;
35                (scheme.to_ascii_lowercase(), &trimmed[index + 3..])
36            }
37            None => ("http".to_string(), trimmed),
38        };
39
40        let without_fragment = strip_fragment(remainder);
41        let (authority, target) = split_authority_and_target(without_fragment)?;
42        let (host, port, explicit_port) = parse_authority(&scheme, authority)?;
43        let (path, query) = parse_target(target)?;
44
45        Ok(Self {
46            scheme,
47            host,
48            port,
49            path,
50            query,
51            explicit_port,
52        })
53    }
54
55    /// Resolves a redirect location against this URL.
56    ///
57    /// Supports:
58    /// - absolute URLs
59    /// - scheme-relative URLs
60    /// - absolute paths
61    /// - relative paths
62    /// - query-only redirects
63    pub fn resolve(&self, location: &str) -> Result<Self, NanoGetError> {
64        let trimmed = strip_fragment(location.trim());
65        if trimmed.is_empty() {
66            return Err(NanoGetError::invalid_url(
67                "redirect location cannot be empty",
68            ));
69        }
70
71        if let Some(scheme) = uri_scheme_prefix(trimmed) {
72            if matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
73                return Self::parse(trimmed);
74            }
75            return Err(NanoGetError::UnsupportedScheme(scheme.to_ascii_lowercase()));
76        }
77
78        if trimmed.starts_with("//") {
79            return Self::parse(&format!("{}:{}", self.scheme, trimmed));
80        }
81
82        if let Some(query) = trimmed.strip_prefix('?') {
83            validate_target_component(query, "query")?;
84            return Ok(Self {
85                scheme: self.scheme.clone(),
86                host: self.host.clone(),
87                port: self.port,
88                path: self.path.clone(),
89                query: Some(query.to_string()),
90                explicit_port: self.explicit_port,
91            });
92        }
93
94        let (path, query) = if trimmed.starts_with('/') {
95            let (path, query) = parse_target(trimmed)?;
96            (normalize_path(&path), query)
97        } else {
98            let (relative_path, query) = split_path_and_query(trimmed);
99            validate_target_component(relative_path, "path")?;
100            if let Some(query) = query {
101                validate_target_component(query, "query")?;
102            }
103            let combined = format!("{}{}", base_directory(&self.path), relative_path);
104            (
105                normalize_path(&combined),
106                query.map(|value| value.to_string()),
107            )
108        };
109
110        Ok(Self {
111            scheme: self.scheme.clone(),
112            host: self.host.clone(),
113            port: self.port,
114            path,
115            query,
116            explicit_port: self.explicit_port,
117        })
118    }
119
120    /// Returns the request-target in origin-form, for example `/path?query`.
121    pub fn origin_form(&self) -> String {
122        match &self.query {
123            Some(query) => format!("{}?{}", self.path, query),
124            None => self.path.clone(),
125        }
126    }
127
128    /// Returns the request-target in absolute-form, for proxy HTTP requests.
129    pub fn absolute_form(&self) -> String {
130        format!(
131            "{}://{}{}",
132            self.scheme,
133            self.host_header_value(),
134            self.origin_form()
135        )
136    }
137
138    /// Returns the request-target in authority-form, for example `example.com:443`.
139    pub fn authority_form(&self) -> String {
140        self.connect_host_with_port()
141    }
142
143    /// Returns the value used for the `Host` header.
144    pub fn host_header_value(&self) -> String {
145        let host = format_host_for_authority(&self.host);
146        if self.explicit_port || !self.is_default_port() {
147            format!("{host}:{}", self.port)
148        } else {
149            host
150        }
151    }
152
153    /// Returns `host:port`, with IPv6 hosts bracketed as required.
154    pub fn connect_host_with_port(&self) -> String {
155        format!("{}:{}", format_host_for_authority(&self.host), self.port)
156    }
157
158    /// Returns the full normalized URL string.
159    pub fn full_url(&self) -> String {
160        self.absolute_form()
161    }
162
163    /// Returns `true` when scheme is `https`.
164    pub fn is_https(&self) -> bool {
165        self.scheme == "https"
166    }
167
168    /// Returns `true` when scheme is `http`.
169    pub fn is_http(&self) -> bool {
170        self.scheme == "http"
171    }
172
173    /// Returns `true` if the port matches the scheme default.
174    pub fn is_default_port(&self) -> bool {
175        default_port_for_scheme(&self.scheme) == Some(self.port)
176    }
177
178    /// Returns `true` when scheme, host, and port are identical.
179    pub fn same_authority(&self, other: &Self) -> bool {
180        self.scheme == other.scheme && self.host == other.host && self.port == other.port
181    }
182
183    /// Returns the cache key used by this crate's in-memory cache.
184    pub fn cache_key(&self) -> String {
185        self.full_url()
186    }
187}
188
189impl Display for Url {
190    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
191        write!(f, "{}", self.full_url())
192    }
193}
194
195/// Conversion trait for values that can be parsed into a [`Url`].
196pub trait ToUrl {
197    /// Parses or converts `self` into a [`Url`].
198    fn to_url(&self) -> Result<Url, NanoGetError>;
199}
200
201impl ToUrl for String {
202    fn to_url(&self) -> Result<Url, NanoGetError> {
203        Url::parse(self)
204    }
205}
206
207impl ToUrl for &str {
208    fn to_url(&self) -> Result<Url, NanoGetError> {
209        Url::parse(self)
210    }
211}
212
213impl ToUrl for &String {
214    fn to_url(&self) -> Result<Url, NanoGetError> {
215        Url::parse(self)
216    }
217}
218
219impl ToUrl for Url {
220    fn to_url(&self) -> Result<Url, NanoGetError> {
221        Ok(self.clone())
222    }
223}
224
225impl ToUrl for &Url {
226    fn to_url(&self) -> Result<Url, NanoGetError> {
227        Ok((*self).clone())
228    }
229}
230
231fn validate_scheme(scheme: &str) -> Result<(), NanoGetError> {
232    match scheme.to_ascii_lowercase().as_str() {
233        "http" | "https" => Ok(()),
234        other => Err(NanoGetError::UnsupportedScheme(other.to_string())),
235    }
236}
237
238fn strip_fragment(input: &str) -> &str {
239    input.split('#').next().unwrap_or(input)
240}
241
242fn split_authority_and_target(input: &str) -> Result<(&str, &str), NanoGetError> {
243    if input.is_empty() {
244        return Err(NanoGetError::invalid_url("missing host"));
245    }
246
247    match input.find(['/', '?']) {
248        Some(index) => Ok((&input[..index], &input[index..])),
249        None => Ok((input, "")),
250    }
251}
252
253fn parse_authority(scheme: &str, authority: &str) -> Result<(String, u16, bool), NanoGetError> {
254    if authority.is_empty() {
255        return Err(NanoGetError::invalid_url("missing host"));
256    }
257    if authority.contains('@') {
258        return Err(NanoGetError::invalid_url(
259            "user info in URLs is not supported",
260        ));
261    }
262
263    let default_port = default_port_for_scheme(scheme)
264        .ok_or_else(|| NanoGetError::UnsupportedScheme(scheme.to_string()))?;
265
266    if authority.starts_with('[') {
267        let closing = authority
268            .find(']')
269            .ok_or_else(|| NanoGetError::invalid_url("unterminated IPv6 host"))?;
270        let host = authority[1..closing].to_ascii_lowercase();
271        validate_ipv6_literal(&host)?;
272        let remainder = &authority[closing + 1..];
273        if remainder.is_empty() {
274            return Ok((host, default_port, false));
275        }
276        if let Some(port) = remainder.strip_prefix(':') {
277            return Ok((host, parse_port(port)?, true));
278        }
279        return Err(NanoGetError::invalid_url("invalid IPv6 authority"));
280    }
281
282    let (host, port, explicit_port) = match authority.rsplit_once(':') {
283        Some((host, port)) if !host.contains(':') => {
284            (host.to_ascii_lowercase(), parse_port(port)?, true)
285        }
286        Some(_) => {
287            return Err(NanoGetError::invalid_url(
288                "IPv6 hosts must use bracket notation",
289            ));
290        }
291        None => (authority.to_ascii_lowercase(), default_port, false),
292    };
293
294    validate_reg_name_host(&host)?;
295
296    Ok((host, port, explicit_port))
297}
298
299fn parse_port(input: &str) -> Result<u16, NanoGetError> {
300    input
301        .parse::<u16>()
302        .map_err(|_| NanoGetError::invalid_url(format!("invalid port: {input}")))
303}
304
305fn parse_target(target: &str) -> Result<(String, Option<String>), NanoGetError> {
306    if target.is_empty() {
307        return Ok(("/".to_string(), None));
308    }
309
310    if let Some(query) = target.strip_prefix('?') {
311        validate_target_component(query, "query")?;
312        return Ok(("/".to_string(), Some(query.to_string())));
313    }
314
315    if !target.starts_with('/') {
316        return Err(NanoGetError::invalid_url("path must start with `/`"));
317    }
318
319    let (path, query) = split_path_and_query(target);
320    validate_target_component(path, "path")?;
321    if let Some(query) = query {
322        validate_target_component(query, "query")?;
323    }
324    Ok((path.to_string(), query.map(|value| value.to_string())))
325}
326
327fn split_path_and_query(input: &str) -> (&str, Option<&str>) {
328    match input.find('?') {
329        Some(index) => (&input[..index], Some(&input[index + 1..])),
330        None => (input, None),
331    }
332}
333
334fn default_port_for_scheme(scheme: &str) -> Option<u16> {
335    match scheme {
336        "http" => Some(80),
337        "https" => Some(443),
338        _ => None,
339    }
340}
341
342fn uri_scheme_prefix(value: &str) -> Option<&str> {
343    let first = value.as_bytes().first().copied()?;
344    if !first.is_ascii_alphabetic() {
345        return None;
346    }
347
348    for (index, byte) in value.bytes().enumerate().skip(1) {
349        if byte == b':' {
350            return Some(&value[..index]);
351        }
352        if matches!(byte, b'/' | b'?' | b'#') {
353            return None;
354        }
355        if !byte.is_ascii_alphanumeric() && !matches!(byte, b'+' | b'-' | b'.') {
356            return None;
357        }
358    }
359    None
360}
361
362fn format_host_for_authority(host: &str) -> String {
363    if host.contains(':') {
364        format!("[{host}]")
365    } else {
366        host.to_string()
367    }
368}
369
370fn base_directory(path: &str) -> String {
371    if path.ends_with('/') {
372        return path.to_string();
373    }
374
375    match path.rfind('/') {
376        Some(index) if index > 0 => path[..index + 1].to_string(),
377        Some(_) => "/".to_string(),
378        None => "/".to_string(),
379    }
380}
381
382fn validate_reg_name_host(host: &str) -> Result<(), NanoGetError> {
383    if host.is_empty() {
384        return Err(NanoGetError::invalid_url("missing host"));
385    }
386
387    if !host.is_ascii() {
388        return Err(NanoGetError::invalid_url(
389            "host must be ASCII (use punycode for international domains)",
390        ));
391    }
392
393    if host.chars().any(|ch| {
394        ch.is_ascii_control()
395            || ch.is_ascii_whitespace()
396            || matches!(ch, '/' | '\\' | '?' | '#' | '[' | ']')
397    }) {
398        return Err(NanoGetError::invalid_url("invalid host"));
399    }
400
401    Ok(())
402}
403
404fn validate_ipv6_literal(host: &str) -> Result<(), NanoGetError> {
405    if host.is_empty() {
406        return Err(NanoGetError::invalid_url("unterminated IPv6 host"));
407    }
408
409    if host.parse::<std::net::Ipv6Addr>().is_ok() {
410        return Ok(());
411    }
412
413    Err(NanoGetError::invalid_url("invalid IPv6 authority"))
414}
415
416fn validate_target_component(value: &str, component: &str) -> Result<(), NanoGetError> {
417    if !value.is_ascii() {
418        return Err(NanoGetError::invalid_url(format!(
419            "invalid {component}: contains non-ASCII characters"
420        )));
421    }
422
423    if value
424        .chars()
425        .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
426    {
427        return Err(NanoGetError::invalid_url(format!(
428            "invalid {component}: contains control characters or whitespace"
429        )));
430    }
431    Ok(())
432}
433
434fn normalize_path(path: &str) -> String {
435    let mut input = path.to_string();
436    let mut output = String::new();
437
438    while !input.is_empty() {
439        if input.starts_with("../") {
440            input.drain(..3);
441            continue;
442        }
443        if input.starts_with("./") {
444            input.drain(..2);
445            continue;
446        }
447        if input.starts_with("/./") {
448            input.replace_range(..3, "/");
449            continue;
450        }
451        if input == "/." {
452            input.replace_range(..2, "/");
453            continue;
454        }
455        if input.starts_with("/../") {
456            input.replace_range(..4, "/");
457            pop_last_path_segment(&mut output);
458            continue;
459        }
460        if input == "/.." {
461            input.replace_range(..3, "/");
462            pop_last_path_segment(&mut output);
463            continue;
464        }
465        if input == "." || input == ".." {
466            input.clear();
467            continue;
468        }
469
470        let segment_end = if let Some(stripped) = input.strip_prefix('/') {
471            match stripped.find('/') {
472                Some(index) => index + 1,
473                None => input.len(),
474            }
475        } else {
476            input.find('/').unwrap_or(input.len())
477        };
478        output.push_str(&input[..segment_end]);
479        input.drain(..segment_end);
480    }
481
482    if output.is_empty() && path.starts_with('/') {
483        "/".to_string()
484    } else {
485        output
486    }
487}
488
489fn pop_last_path_segment(path: &mut String) {
490    if path.is_empty() {
491        return;
492    }
493
494    let candidate = path.strip_suffix('/').unwrap_or(path.as_str());
495    if let Some(index) = candidate.rfind('/') {
496        path.truncate(index);
497    } else {
498        path.clear();
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use std::panic::{self, AssertUnwindSafe};
505
506    use super::{
507        default_port_for_scheme, normalize_path, parse_target, split_authority_and_target, ToUrl,
508        Url,
509    };
510    use crate::errors::NanoGetError;
511
512    #[test]
513    fn parses_default_http_url() {
514        let url = Url::parse("example.com/a/b?c=1").unwrap();
515        assert_eq!(url.scheme, "http");
516        assert_eq!(url.host, "example.com");
517        assert_eq!(url.port, 80);
518        assert_eq!(url.path, "/a/b");
519        assert_eq!(url.query.as_deref(), Some("c=1"));
520        assert_eq!(url.origin_form(), "/a/b?c=1");
521    }
522
523    #[test]
524    fn parses_https_url_with_explicit_port() {
525        let url = Url::parse("https://example.com:8443/path").unwrap();
526        assert_eq!(url.scheme, "https");
527        assert_eq!(url.port, 8443);
528        assert_eq!(url.host_header_value(), "example.com:8443");
529        assert_eq!(url.connect_host_with_port(), "example.com:8443");
530    }
531
532    #[test]
533    fn parses_bracketed_ipv6_hosts() {
534        let url = Url::parse("http://[::1]:8080/").unwrap();
535        assert_eq!(url.host, "::1");
536        assert_eq!(url.connect_host_with_port(), "[::1]:8080");
537        assert_eq!(url.host_header_value(), "[::1]:8080");
538    }
539
540    #[test]
541    fn strips_fragments() {
542        let url = Url::parse("http://example.com/path?a=1#fragment").unwrap();
543        assert_eq!(url.origin_form(), "/path?a=1");
544        assert_eq!(url.full_url(), "http://example.com/path?a=1");
545    }
546
547    #[test]
548    fn parse_preserves_user_path_structure() {
549        let url = Url::parse("http://example.com/a//b/./c/../d").unwrap();
550        assert_eq!(url.path, "/a//b/./c/../d");
551        assert_eq!(url.origin_form(), "/a//b/./c/../d");
552    }
553
554    #[test]
555    fn resolves_relative_redirects() {
556        let base = Url::parse("http://example.com/a/b/index.html?x=1").unwrap();
557        let resolved = base.resolve("../next?y=2").unwrap();
558        assert_eq!(resolved.full_url(), "http://example.com/a/next?y=2");
559    }
560
561    #[test]
562    fn resolves_relative_redirects_with_dot_segments_without_collapsing_double_slashes() {
563        let base = Url::parse("http://example.com/a/b/index.html").unwrap();
564        let resolved = base.resolve("../x//y/./z/..").unwrap();
565        assert_eq!(resolved.full_url(), "http://example.com/a/x//y/");
566    }
567
568    #[test]
569    fn resolves_absolute_path_redirects() {
570        let base = Url::parse("https://example.com/one/two").unwrap();
571        let resolved = base.resolve("/rooted").unwrap();
572        assert_eq!(resolved.full_url(), "https://example.com/rooted");
573    }
574
575    #[test]
576    fn resolves_query_only_redirects() {
577        let base = Url::parse("http://example.com/path?a=1").unwrap();
578        let resolved = base.resolve("?b=2").unwrap();
579        assert_eq!(resolved.full_url(), "http://example.com/path?b=2");
580    }
581
582    #[test]
583    fn resolve_treats_only_scheme_prefixed_locations_as_absolute() {
584        let base = Url::parse("http://example.com/base").unwrap();
585        let resolved = base.resolve("/foo://bar").unwrap();
586        assert_eq!(resolved.full_url(), "http://example.com/foo://bar");
587
588        let error = base.resolve("ftp://example.com").unwrap_err();
589        assert!(matches!(error, NanoGetError::UnsupportedScheme(_)));
590    }
591
592    #[test]
593    fn resolve_treats_scheme_colon_prefix_as_absolute_uri_reference() {
594        let base = Url::parse("http://example.com/base").unwrap();
595        let error = base.resolve("foo:bar").unwrap_err();
596        assert!(matches!(
597            error,
598            NanoGetError::UnsupportedScheme(ref scheme) if scheme == "foo"
599        ));
600
601        let relative = base.resolve("./foo:bar").unwrap();
602        assert_eq!(relative.full_url(), "http://example.com/foo:bar");
603    }
604
605    #[test]
606    fn rejects_unsupported_schemes() {
607        let error = Url::parse("ftp://example.com").unwrap_err();
608        assert!(matches!(error, NanoGetError::UnsupportedScheme(ref value) if value == "ftp"));
609    }
610
611    #[test]
612    fn rejects_unbracketed_ipv6_hosts() {
613        let error = Url::parse("http://::1/path").unwrap_err();
614        assert!(matches!(error, NanoGetError::InvalidUrl(_)));
615    }
616
617    #[test]
618    fn rejects_empty_and_userinfo_urls() {
619        assert!(matches!(Url::parse(""), Err(NanoGetError::InvalidUrl(_))));
620        assert!(matches!(
621            Url::parse("http://user@example.com"),
622            Err(NanoGetError::InvalidUrl(_))
623        ));
624    }
625
626    #[test]
627    fn rejects_invalid_ports_and_ipv6_authorities() {
628        assert!(matches!(
629            Url::parse("http://example.com:abc"),
630            Err(NanoGetError::InvalidUrl(_))
631        ));
632        assert!(matches!(
633            Url::parse("http://[::1]bad"),
634            Err(NanoGetError::InvalidUrl(_))
635        ));
636        assert!(matches!(
637            Url::parse("http://[:::1]/"),
638            Err(NanoGetError::InvalidUrl(_))
639        ));
640    }
641
642    #[test]
643    fn resolves_scheme_relative_redirects() {
644        let base = Url::parse("https://example.com/one").unwrap();
645        let resolved = base.resolve("//cdn.example.com/path").unwrap();
646        assert_eq!(resolved.full_url(), "https://cdn.example.com/path");
647    }
648
649    #[test]
650    fn builds_absolute_and_authority_forms() {
651        let url = Url::parse("http://example.com:8080/path?x=1").unwrap();
652        assert_eq!(url.absolute_form(), "http://example.com:8080/path?x=1");
653        assert_eq!(url.authority_form(), "example.com:8080");
654    }
655
656    #[test]
657    fn detects_matching_authorities() {
658        let one = Url::parse("https://example.com/path").unwrap();
659        let two = Url::parse("https://example.com/other").unwrap();
660        let three = Url::parse("http://example.com/other").unwrap();
661        assert!(one.same_authority(&two));
662        assert!(!one.same_authority(&three));
663    }
664
665    #[test]
666    fn covers_display_and_tourl_variants() {
667        let url = Url::parse("http://example.com/path").unwrap();
668        assert_eq!(url.to_string(), "http://example.com/path");
669        assert_eq!(url.to_url().unwrap(), url);
670        assert_eq!(<&Url as ToUrl>::to_url(&&url).unwrap(), url);
671    }
672
673    #[test]
674    fn covers_missing_hosts_and_invalid_targets() {
675        assert!(matches!(
676            Url::parse("http://"),
677            Err(NanoGetError::InvalidUrl(_))
678        ));
679        assert!(matches!(
680            Url::parse("http:///path"),
681            Err(NanoGetError::InvalidUrl(_))
682        ));
683        assert!(matches!(
684            split_authority_and_target(""),
685            Err(NanoGetError::InvalidUrl(_))
686        ));
687        assert!(matches!(
688            Url::parse("http://:80/path"),
689            Err(NanoGetError::InvalidUrl(_))
690        ));
691        assert!(matches!(
692            parse_target("not-a-path"),
693            Err(NanoGetError::InvalidUrl(_))
694        ));
695        assert_eq!(
696            parse_target("?x=1").unwrap(),
697            ("/".to_string(), Some("x=1".to_string()))
698        );
699    }
700
701    #[test]
702    fn rejects_hosts_and_targets_with_controls_or_whitespace() {
703        assert!(matches!(
704            Url::parse("http://exa mple.com"),
705            Err(NanoGetError::InvalidUrl(_))
706        ));
707        assert!(matches!(
708            Url::parse("http://example.com/hello world"),
709            Err(NanoGetError::InvalidUrl(_))
710        ));
711        assert!(matches!(
712            Url::parse("http://example.com/path?x=\n1"),
713            Err(NanoGetError::InvalidUrl(_))
714        ));
715        assert!(matches!(
716            Url::parse("http://example.com/caf\u{00e9}"),
717            Err(NanoGetError::InvalidUrl(_))
718        ));
719        assert!(matches!(
720            Url::parse("http://example.com/path?q=\u{00e9}"),
721            Err(NanoGetError::InvalidUrl(_))
722        ));
723
724        let base = Url::parse("http://example.com/path").unwrap();
725        assert!(matches!(
726            base.resolve("/caf\u{00e9}"),
727            Err(NanoGetError::InvalidUrl(_))
728        ));
729    }
730
731    #[test]
732    fn covers_helper_branches_for_paths_and_ports() {
733        let ipv6 = Url::parse("http://[::1]/").unwrap();
734        assert_eq!(ipv6.authority_form(), "[::1]:80");
735
736        assert_eq!(default_port_for_scheme("http"), Some(80));
737        assert_eq!(default_port_for_scheme("https"), Some(443));
738        assert_eq!(default_port_for_scheme("ws"), None);
739
740        assert_eq!(super::base_directory("/a/b/"), "/a/b/");
741        assert_eq!(super::base_directory("/a"), "/");
742        assert_eq!(super::base_directory("a"), "/");
743        assert_eq!(normalize_path("/a/b/"), "/a/b/");
744        assert_eq!(normalize_path("/a//b/./c/../"), "/a//b/");
745
746        let base = Url::parse("http://example.com/path").unwrap();
747        let error = base.resolve("   ").unwrap_err();
748        assert!(matches!(error, NanoGetError::InvalidUrl(_)));
749    }
750
751    struct DeterministicRng {
752        state: u64,
753    }
754
755    impl DeterministicRng {
756        fn new(seed: u64) -> Self {
757            Self { state: seed }
758        }
759
760        fn next_u32(&mut self) -> u32 {
761            self.state = self
762                .state
763                .wrapping_mul(6_364_136_223_846_793_005)
764                .wrapping_add(1);
765            (self.state >> 32) as u32
766        }
767
768        fn next_location(&mut self, max_len: usize) -> String {
769            const ASCII: &[u8] =
770                b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&'()*+,;=%";
771            let len = (self.next_u32() as usize) % (max_len + 1);
772            let mut out = String::with_capacity(len);
773            for _ in 0..len {
774                let roll = self.next_u32() % 29;
775                if roll == 0 {
776                    out.push('\u{00e9}');
777                } else if roll == 1 {
778                    out.push('\u{2603}');
779                } else {
780                    let idx = (self.next_u32() as usize) % ASCII.len();
781                    out.push(ASCII[idx] as char);
782                }
783            }
784            out
785        }
786    }
787
788    #[test]
789    fn deterministic_url_parse_and_resolve_fuzz_harness_is_panic_free() {
790        let base = Url::parse("http://example.com/a/b/index.html?x=1").unwrap();
791        let corpus = [
792            "",
793            "http://example.com/path",
794            "https://example.com:8443/path?q=1",
795            "/foo://bar",
796            "foo:bar",
797            "//cdn.example.com/resource",
798            "?query=1",
799            "/caf\u{00e9}",
800            "http://[::1]/",
801            "http://[:::1]/",
802            "http://user@example.com",
803            "http://example.com/hello world",
804        ];
805
806        for input in corpus {
807            let run = panic::catch_unwind(AssertUnwindSafe(|| {
808                let _ = Url::parse(input);
809                let _ = base.resolve(input);
810            }));
811            assert!(run.is_ok(), "URL parsing panicked for corpus input");
812        }
813
814        let mut rng = DeterministicRng::new(0x1234_5678_ABCD_EF01);
815        for _ in 0..3_000 {
816            let input = rng.next_location(128);
817            let run = panic::catch_unwind(AssertUnwindSafe(|| {
818                let _ = Url::parse(&input);
819                let _ = base.resolve(&input);
820            }));
821            assert!(run.is_ok(), "URL parsing panicked for fuzz input");
822        }
823    }
824}