Skip to main content

azul_core/
url.rs

1//! URL types for the C API.
2//!
3//! Provides a C-compatible, parsed-URL type. Key types: [`Url`],
4//! [`UrlParseError`], [`ResultUrlUrlParseError`].
5//!
6//! The POD type and the cheap accessors live here in `azul-core` (so consumers
7//! like `crate::video::VideoSource` can hold a typed `Url` without an
8//! `azul-layout` dependency). `Url::parse` / `Url::join`, which rely on the
9//! `url` crate, are gated behind the `url` feature; `azul_layout`'s `http`
10//! feature enables it. Re-exported as `azul_layout::url`.
11
12#[cfg(not(feature = "std"))]
13use alloc::string::ToString;
14use alloc::string::String;
15use core::fmt;
16
17use azul_css::{impl_result, impl_result_inner, AzString};
18
19/// A parsed URL
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
21#[repr(C)]
22pub struct Url {
23    /// The full URL string
24    pub href: AzString,
25    /// The scheme (e.g., "https")
26    pub scheme: AzString,
27    /// The host (e.g., "example.com")
28    pub host: AzString,
29    /// The port number, or 0 if not specified (sentinel value; see `effective_port()`)
30    pub port: u16,
31    /// The path (e.g., "/path/to/resource")
32    pub path: AzString,
33    /// The query string without '?' (e.g., "key=value")
34    pub query: AzString,
35    /// The fragment without '#' (e.g., "section")
36    pub fragment: AzString,
37}
38
39/// Error when parsing a URL
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[repr(C)]
42pub struct UrlParseError {
43    /// Error message
44    pub message: AzString,
45}
46
47impl fmt::Display for UrlParseError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "{}", self.message.as_str())
50    }
51}
52
53#[cfg(feature = "std")]
54impl std::error::Error for UrlParseError {}
55
56// FFI-safe Result type for URL parsing
57impl_result!(
58    Url,
59    UrlParseError,
60    ResultUrlUrlParseError,
61    copy = false,
62    [Debug, Clone, PartialEq, Eq]
63);
64
65impl Url {
66    /// Parse a URL from a string
67    #[cfg(feature = "url")]
68    pub fn parse(s: &str) -> Result<Self, UrlParseError> {
69        use ::url::Url as UrlParser;
70
71        let parsed = UrlParser::parse(s).map_err(|e| UrlParseError {
72            message: AzString::from(e.to_string()),
73        })?;
74
75        Ok(Self {
76            href: AzString::from(parsed.as_str().to_string()),
77            scheme: AzString::from(parsed.scheme().to_string()),
78            host: AzString::from(parsed.host_str().unwrap_or("").to_string()),
79            port: parsed.port().unwrap_or(0),
80            path: AzString::from(parsed.path().to_string()),
81            query: AzString::from(parsed.query().unwrap_or("").to_string()),
82            fragment: AzString::from(parsed.fragment().unwrap_or("").to_string()),
83        })
84    }
85
86    /// Create a URL from components
87    #[must_use] pub fn from_parts(scheme: &str, host: &str, port: u16, path: &str) -> Self {
88        let port_str = if port == 0
89            || (scheme == "http" && port == 80)
90            || (scheme == "https" && port == 443)
91        {
92            String::new()
93        } else {
94            alloc::format!(":{port}")
95        };
96
97        let href = alloc::format!("{scheme}://{host}{port_str}{path}");
98
99        Self {
100            href: AzString::from(href),
101            scheme: AzString::from(scheme.to_string()),
102            host: AzString::from(host.to_string()),
103            port,
104            path: AzString::from(path.to_string()),
105            query: AzString::from(String::new()),
106            fragment: AzString::from(String::new()),
107        }
108    }
109
110    /// Get the full URL as a string slice
111    #[must_use] pub fn as_str(&self) -> &str {
112        self.href.as_str()
113    }
114
115    /// Check if this is an HTTPS URL
116    #[must_use] pub fn is_https(&self) -> bool {
117        self.scheme.as_str() == "https"
118    }
119
120    /// Check if this is an HTTP URL
121    #[must_use] pub fn is_http(&self) -> bool {
122        self.scheme.as_str() == "http"
123    }
124
125    /// Get the effective port (using default ports for http/https)
126    #[must_use] pub fn effective_port(&self) -> u16 {
127        if self.port != 0 {
128            self.port
129        } else if self.is_https() {
130            443
131        } else if self.is_http() {
132            80
133        } else {
134            0
135        }
136    }
137
138    /// Join a relative path to this URL
139    #[cfg(feature = "url")]
140    pub fn join(&self, path: &str) -> Result<Self, UrlParseError> {
141        use ::url::Url as UrlParser;
142
143        let base = UrlParser::parse(self.href.as_str()).map_err(|e| UrlParseError {
144            message: AzString::from(e.to_string()),
145        })?;
146
147        let joined = base.join(path).map_err(|e| UrlParseError {
148            message: AzString::from(e.to_string()),
149        })?;
150
151        Self::parse(joined.as_str())
152    }
153
154    /// Stub: `url` feature disabled (the `url` crate is gated behind it).
155    #[cfg(not(feature = "url"))]
156    /// # Errors
157    ///
158    /// Returns an error: the `url` feature is disabled, so URL parsing is unsupported.
159    pub const fn parse(_s: &str) -> Result<Self, UrlParseError> {
160        Err(UrlParseError {
161            message: AzString::from_const_str("url feature not enabled"),
162        })
163    }
164
165    /// Stub: `url` feature disabled (the `url` crate is gated behind it).
166    #[cfg(not(feature = "url"))]
167    /// # Errors
168    ///
169    /// Returns an error: the `url` feature is disabled, so URL joining is unsupported.
170    pub const fn join(&self, _path: &str) -> Result<Self, UrlParseError> {
171        Err(UrlParseError {
172            message: AzString::from_const_str("url feature not enabled"),
173        })
174    }
175}
176
177impl fmt::Display for Url {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        write!(f, "{}", self.href.as_str())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    #[cfg(feature = "url")]
189    fn test_url_parse() {
190        let url = Url::parse("https://example.com:8080/path?query=1#frag").unwrap();
191        assert_eq!(url.scheme.as_str(), "https");
192        assert_eq!(url.host.as_str(), "example.com");
193        assert_eq!(url.port, 8080);
194        assert_eq!(url.path.as_str(), "/path");
195        assert_eq!(url.query.as_str(), "query=1");
196        assert_eq!(url.fragment.as_str(), "frag");
197    }
198
199    #[test]
200    fn test_url_from_parts() {
201        let url = Url::from_parts("https", "example.com", 443, "/api");
202        assert!(url.is_https());
203        assert_eq!(url.effective_port(), 443);
204    }
205}
206
207#[cfg(test)]
208mod autotest_generated {
209    use alloc::format;
210
211    use super::*;
212
213    /// Structural invariants that must hold for ANY `Url`, however it was built.
214    ///
215    /// Only checks properties derivable from the type's own contract, so it is
216    /// safe to apply to the output of the external `url` crate parser too.
217    fn assert_url_invariants(u: &Url) {
218        // as_str() is exactly the href field, and Display agrees with it.
219        assert_eq!(u.as_str(), u.href.as_str());
220        assert_eq!(format!("{u}"), u.href.as_str());
221
222        // is_http / is_https are mutually exclusive and match the scheme exactly.
223        assert_eq!(u.is_https(), u.scheme.as_str() == "https");
224        assert_eq!(u.is_http(), u.scheme.as_str() == "http");
225        assert!(!(u.is_http() && u.is_https()));
226
227        // effective_port() is a pure function of (port, scheme).
228        let expected_port = if u.port != 0 {
229            u.port
230        } else if u.is_https() {
231            443
232        } else if u.is_http() {
233            80
234        } else {
235            0
236        };
237        assert_eq!(u.effective_port(), expected_port);
238
239        // Equality/clone consistency (the type derives Clone + PartialEq).
240        assert_eq!(u.clone(), *u);
241    }
242
243    // ---------------------------------------------------------------------
244    // Url::default() / getters / predicates on degenerate instances
245    // ---------------------------------------------------------------------
246
247    #[test]
248    fn default_url_is_inert_and_does_not_panic() {
249        let u = Url::default();
250        assert_eq!(u.as_str(), "");
251        assert_eq!(u.href.as_str(), "");
252        assert_eq!(u.scheme.as_str(), "");
253        assert_eq!(u.host.as_str(), "");
254        assert_eq!(u.port, 0);
255        assert!(!u.is_https());
256        assert!(!u.is_http());
257        // Unknown scheme + sentinel port => no default port to infer.
258        assert_eq!(u.effective_port(), 0);
259        assert_eq!(format!("{u}"), "");
260        assert_url_invariants(&u);
261    }
262
263    #[test]
264    fn effective_port_covers_every_scheme_port_combination() {
265        // Explicit port always wins, even when it contradicts the scheme default.
266        assert_eq!(
267            Url::from_parts("https", "h", 80, "/").effective_port(),
268            80,
269            "explicit port must win over the https default"
270        );
271        assert_eq!(Url::from_parts("http", "h", 443, "/").effective_port(), 443);
272        assert_eq!(
273            Url::from_parts("http", "h", u16::MAX, "/").effective_port(),
274            u16::MAX
275        );
276        assert_eq!(Url::from_parts("https", "h", 1, "/").effective_port(), 1);
277
278        // Sentinel port 0 => infer from scheme.
279        assert_eq!(Url::from_parts("https", "h", 0, "/").effective_port(), 443);
280        assert_eq!(Url::from_parts("http", "h", 0, "/").effective_port(), 80);
281        assert_eq!(Url::from_parts("ftp", "h", 0, "/").effective_port(), 0);
282        assert_eq!(Url::from_parts("", "", 0, "").effective_port(), 0);
283    }
284
285    #[test]
286    fn predicates_are_case_sensitive_and_reject_near_misses() {
287        // Near-miss schemes must NOT be reported as http/https.
288        for scheme in [
289            "HTTPS", "Https", "httpss", "https ", " https", "http\0", "ws", "httpx", "",
290        ] {
291            let u = Url::from_parts(scheme, "example.com", 0, "/");
292            assert!(
293                !u.is_https(),
294                "scheme {scheme:?} must not be treated as https"
295            );
296            assert_url_invariants(&u);
297        }
298        for scheme in ["HTTP", "Http", "httpx", "https", "htt", ""] {
299            let u = Url::from_parts(scheme, "example.com", 0, "/");
300            assert!(
301                !u.is_http(),
302                "scheme {scheme:?} must not be treated as http"
303            );
304        }
305        assert!(Url::from_parts("https", "h", 0, "/").is_https());
306        assert!(Url::from_parts("http", "h", 0, "/").is_http());
307    }
308
309    // ---------------------------------------------------------------------
310    // Url::from_parts — constructor, no panics, invariants
311    // ---------------------------------------------------------------------
312
313    #[test]
314    fn from_parts_omits_default_and_sentinel_ports_only() {
315        // Sentinel 0 => no port in href.
316        assert_eq!(
317            Url::from_parts("https", "example.com", 0, "/a").as_str(),
318            "https://example.com/a"
319        );
320        // Scheme-matching default ports => elided.
321        assert_eq!(
322            Url::from_parts("http", "example.com", 80, "/").as_str(),
323            "http://example.com/"
324        );
325        assert_eq!(
326            Url::from_parts("https", "example.com", 443, "/").as_str(),
327            "https://example.com/"
328        );
329        // Cross-scheme "defaults" are NOT elided.
330        assert_eq!(
331            Url::from_parts("https", "example.com", 80, "/").as_str(),
332            "https://example.com:80/"
333        );
334        assert_eq!(
335            Url::from_parts("http", "example.com", 443, "/").as_str(),
336            "http://example.com:443/"
337        );
338        // A non-default port is always rendered, including the u16 boundary.
339        assert_eq!(
340            Url::from_parts("http", "example.com", u16::MAX, "/").as_str(),
341            "http://example.com:65535/"
342        );
343        assert_eq!(
344            Url::from_parts("ftp", "example.com", 443, "/").as_str(),
345            "ftp://example.com:443/"
346        );
347    }
348
349    #[test]
350    fn from_parts_keeps_the_port_field_even_when_elided_from_href() {
351        // The elision is purely cosmetic: the struct field must still carry the
352        // caller's port, and effective_port() must agree with it.
353        let u = Url::from_parts("https", "example.com", 443, "/api");
354        assert_eq!(u.port, 443);
355        assert!(!u.as_str().contains(":443"));
356        assert_eq!(u.effective_port(), 443);
357        assert_url_invariants(&u);
358    }
359
360    #[test]
361    fn from_parts_fields_mirror_the_arguments_verbatim() {
362        let u = Url::from_parts("https", "example.com", 8080, "/a/b");
363        assert_eq!(u.scheme.as_str(), "https");
364        assert_eq!(u.host.as_str(), "example.com");
365        assert_eq!(u.port, 8080);
366        assert_eq!(u.path.as_str(), "/a/b");
367        // from_parts has no query/fragment inputs; they must be empty, not garbage.
368        assert_eq!(u.query.as_str(), "");
369        assert_eq!(u.fragment.as_str(), "");
370        assert_eq!(u.as_str(), "https://example.com:8080/a/b");
371        assert_url_invariants(&u);
372    }
373
374    #[test]
375    fn from_parts_does_not_panic_on_extreme_or_empty_arguments() {
376        // Every argument empty: degenerate but must not panic.
377        let u = Url::from_parts("", "", 0, "");
378        assert_eq!(u.as_str(), "://");
379        assert_url_invariants(&u);
380
381        // from_parts is a raw formatter, not a validator: it must not panic on
382        // inputs that could never parse, and must reproduce them byte-for-byte.
383        for (scheme, host, port, path) in [
384            ("://", "://", 1, "://"),
385            ("http", "user:pw@host", 0, "/x"),
386            ("http", "[::1]", 8080, "/x"),
387            ("http", "a b c", 0, "/p a t h"),
388            ("http", "example.com", 0, "no-leading-slash"),
389            ("http", "example.com", 0, "?query#frag"),
390            ("\n\t", "\r", 65535, "\0"),
391        ] {
392            let u = Url::from_parts(scheme, host, port, path);
393            assert_eq!(u.scheme.as_str(), scheme);
394            assert_eq!(u.host.as_str(), host);
395            assert_eq!(u.port, port);
396            assert_eq!(u.path.as_str(), path);
397            assert!(u.as_str().starts_with(scheme));
398            assert_url_invariants(&u);
399        }
400    }
401
402    #[test]
403    fn from_parts_handles_unicode_without_panicking_or_mangling() {
404        let u = Url::from_parts("https", "例え.テスト", 0, "/パス/😀");
405        assert_eq!(u.host.as_str(), "例え.テスト");
406        assert_eq!(u.path.as_str(), "/パス/😀");
407        // No IDNA/percent-encoding happens here — from_parts is a plain formatter.
408        assert_eq!(u.as_str(), "https://例え.テスト/パス/😀");
409        assert!(u.is_https());
410        assert_url_invariants(&u);
411
412        // Combining marks + a lone emoji as the whole host.
413        let u = Url::from_parts("http", "e\u{0301}xample", 1, "/\u{1F600}");
414        assert!(u.as_str().contains('\u{0301}'));
415        assert_url_invariants(&u);
416    }
417
418    #[test]
419    fn from_parts_handles_huge_inputs_without_hanging() {
420        let host = "a".repeat(100_000);
421        let path = "/".to_string() + &"b".repeat(100_000);
422        let u = Url::from_parts("https", &host, 8080, &path);
423        // "https" + "://" + host + ":8080" + path
424        assert_eq!(u.as_str().len(), 5 + 3 + 100_000 + 5 + 100_001);
425        assert_eq!(u.host.as_str().len(), 100_000);
426        assert!(u.is_https());
427        assert_eq!(u.effective_port(), 8080);
428        assert_url_invariants(&u);
429    }
430
431    // ---------------------------------------------------------------------
432    // Display / serializer
433    // ---------------------------------------------------------------------
434
435    #[test]
436    fn url_display_never_reinterprets_the_href() {
437        // Display must be a verbatim echo of href, not a re-serialization.
438        for href in ["", "://", "not a url", "{}{{}", "%s%n", "\u{1F600}"] {
439            let u = Url {
440                href: AzString::from(href),
441                ..Url::default()
442            };
443            assert_eq!(format!("{u}"), href);
444            assert_eq!(u.as_str(), href);
445        }
446    }
447
448    #[test]
449    fn url_parse_error_display_is_verbatim_and_panic_free() {
450        // Default / empty message.
451        let e = UrlParseError {
452            message: AzString::from(""),
453        };
454        assert_eq!(format!("{e}"), "");
455
456        // Format-specifier-looking payloads must NOT be interpreted.
457        for msg in [
458            "relative URL without a base",
459            "{}",
460            "{0} {1} {}",
461            "%s %n %p",
462            "\u{1F600} invalid",
463            "e\u{0301}",
464            "\0\t\n",
465        ] {
466            let e = UrlParseError {
467                message: AzString::from(msg),
468            };
469            assert_eq!(format!("{e}"), msg);
470        }
471
472        // Non-empty for a representative value, and huge messages don't panic.
473        let big = "x".repeat(100_000);
474        let e = UrlParseError {
475            message: AzString::from(big.as_str()),
476        };
477        assert_eq!(format!("{e}").len(), 100_000);
478
479        // from_const_str path (used by the no-`url`-feature stubs).
480        let e = UrlParseError {
481            message: AzString::from_const_str("url feature not enabled"),
482        };
483        assert_eq!(format!("{e}"), "url feature not enabled");
484    }
485
486    // ---------------------------------------------------------------------
487    // FFI result type
488    // ---------------------------------------------------------------------
489
490    #[test]
491    fn ffi_result_round_trips_both_variants() {
492        let ok: Result<Url, UrlParseError> = Ok(Url::from_parts("https", "a.b", 0, "/"));
493        let ffi: ResultUrlUrlParseError = ok.clone().into();
494        assert!(ffi.is_ok());
495        assert!(!ffi.is_err());
496        let back: Result<Url, UrlParseError> = ffi.into();
497        assert_eq!(back, ok);
498
499        let err: Result<Url, UrlParseError> = Err(UrlParseError {
500            message: AzString::from("boom"),
501        });
502        let ffi: ResultUrlUrlParseError = err.clone().into();
503        assert!(ffi.is_err());
504        assert!(!ffi.is_ok());
505        assert_eq!(
506            ffi.as_result().unwrap_err().message.as_str(),
507            "boom",
508            "as_result() must borrow the same error payload"
509        );
510        let back: Result<Url, UrlParseError> = ffi.into();
511        assert_eq!(back, err);
512    }
513
514    #[cfg(feature = "std")]
515    #[test]
516    fn eq_implies_equal_hash() {
517        use std::{
518            collections::hash_map::DefaultHasher,
519            hash::{Hash, Hasher},
520        };
521
522        fn hash_of(u: &Url) -> u64 {
523            let mut h = DefaultHasher::new();
524            u.hash(&mut h);
525            h.finish()
526        }
527
528        let a = Url::from_parts("https", "example.com", 8080, "/a");
529        let b = Url::from_parts("https", "example.com", 8080, "/a");
530        assert_eq!(a, b);
531        assert_eq!(hash_of(&a), hash_of(&b));
532
533        // Port is part of identity even when it is elided from the href.
534        let c = Url::from_parts("https", "example.com", 443, "/a");
535        let d = Url::from_parts("https", "example.com", 0, "/a");
536        assert_eq!(c.as_str(), d.as_str(), "hrefs are identical…");
537        assert_ne!(c, d, "…but the port field still distinguishes them");
538    }
539
540    // =====================================================================
541    // Parser tests — only meaningful with the `url` feature.
542    // =====================================================================
543
544    /// Parse `s`; if it succeeds, assert the general invariants AND that
545    /// re-parsing the serialization is a fixed point (`parse(as_str(x)) == x`).
546    ///
547    /// Used for inputs whose accept/reject verdict is the `url` crate's business:
548    /// the point is that we never panic and never produce an inconsistent `Url`.
549    #[cfg(feature = "url")]
550    fn assert_no_panic_and_idempotent(s: &str) {
551        match Url::parse(s) {
552            Ok(u) => {
553                assert_url_invariants(&u);
554                let again = Url::parse(u.as_str())
555                    .expect("a serialized URL must always re-parse (idempotent normalization)");
556                assert_eq!(again, u, "parse(serialize(x)) must equal x for input {s:?}");
557                assert_eq!(again.as_str(), u.as_str());
558            }
559            Err(e) => {
560                // An error must carry a diagnosable, non-empty message.
561                assert!(
562                    !e.message.as_str().is_empty(),
563                    "error for {s:?} must have a message"
564                );
565                assert_eq!(format!("{e}"), e.message.as_str());
566            }
567        }
568    }
569
570    #[cfg(feature = "url")]
571    #[test]
572    fn parse_valid_minimal_positive_control() {
573        let u = Url::parse("http://example.com").expect("minimal absolute URL must parse");
574        assert_eq!(u.scheme.as_str(), "http");
575        assert_eq!(u.host.as_str(), "example.com");
576        // No explicit port => the 0 sentinel, resolved by effective_port().
577        assert_eq!(u.port, 0);
578        assert_eq!(u.effective_port(), 80);
579        assert!(u.is_http());
580        assert!(!u.is_https());
581        // The parser normalizes the empty path to "/".
582        assert_eq!(u.path.as_str(), "/");
583        assert_eq!(u.query.as_str(), "");
584        assert_eq!(u.fragment.as_str(), "");
585        assert_url_invariants(&u);
586    }
587
588    #[cfg(feature = "url")]
589    #[test]
590    fn parse_rejects_empty_and_whitespace_only_input() {
591        for s in ["", " ", "   ", "\t", "\n", "\t\n", "\r\n  \t "] {
592            let r = Url::parse(s);
593            assert!(r.is_err(), "{s:?} must not parse as an absolute URL");
594            let e = r.unwrap_err();
595            assert!(!e.message.as_str().is_empty());
596        }
597    }
598
599    #[cfg(feature = "url")]
600    #[test]
601    fn parse_rejects_garbage_without_panicking() {
602        for s in [
603            "not a url",
604            "///",
605            "::::",
606            "http://",
607            "\u{0}\u{1}\u{2}",
608            "\u{FFFD}\u{FFFD}",
609            "?query-only",
610            "#fragment-only",
611            "/absolute/path/only",
612            "../relative",
613        ] {
614            let r = Url::parse(s);
615            if let Ok(ref u) = r {
616                // If the parser DOES accept it, the result must still be coherent.
617                assert_url_invariants(u);
618            } else {
619                assert!(!r.unwrap_err().message.as_str().is_empty());
620            }
621        }
622        // These are unambiguously relative and must be rejected.
623        assert!(Url::parse("not a url").is_err());
624        assert!(Url::parse("/absolute/path/only").is_err());
625        assert!(Url::parse("http://").is_err(), "empty host must be an error");
626    }
627
628    #[cfg(feature = "url")]
629    #[test]
630    fn parse_rejects_out_of_range_and_non_numeric_ports() {
631        // u16 overflow at the boundary and far beyond it.
632        assert!(Url::parse("http://example.com:65536/").is_err());
633        assert!(Url::parse("http://example.com:99999/").is_err());
634        assert!(Url::parse("http://example.com:4294967296/").is_err());
635        assert!(Url::parse("http://example.com:18446744073709551616/").is_err());
636        assert!(Url::parse("http://example.com:-1/").is_err());
637        assert!(Url::parse("http://example.com:NaN/").is_err());
638        assert!(Url::parse("http://example.com:inf/").is_err());
639        assert!(Url::parse("http://example.com:+80/").is_err());
640        assert!(Url::parse(&format!("http://example.com:{}/", "9".repeat(10_000))).is_err());
641
642        // The largest in-range port must survive intact (no truncation to 0).
643        let u = Url::parse("http://example.com:65535/").expect("65535 is a valid port");
644        assert_eq!(u.port, u16::MAX);
645        assert_eq!(u.effective_port(), u16::MAX);
646        assert_url_invariants(&u);
647    }
648
649    #[cfg(feature = "url")]
650    #[test]
651    fn parse_port_zero_collides_with_the_no_port_sentinel() {
652        // `port: 0` doubles as "unspecified", so an explicit :0 is indistinguishable
653        // from no port at all — effective_port() then reports the scheme default.
654        // This documents the sentinel's cost; it must at least stay self-consistent.
655        if let Ok(u) = Url::parse("http://example.com:0/") {
656            assert_eq!(u.port, 0);
657            assert_eq!(
658                u.effective_port(),
659                80,
660                "explicit :0 is swallowed by the 0 sentinel"
661            );
662            assert_url_invariants(&u);
663        }
664    }
665
666    #[cfg(feature = "url")]
667    #[test]
668    fn parse_default_ports_are_reported_via_effective_port() {
669        // Whether the parser stores or elides a scheme-default port, effective_port()
670        // must resolve to the same answer.
671        let u = Url::parse("https://example.com:443/").unwrap();
672        assert!(u.port == 0 || u.port == 443);
673        assert_eq!(u.effective_port(), 443);
674        assert_url_invariants(&u);
675
676        let u = Url::parse("http://example.com:80/").unwrap();
677        assert!(u.port == 0 || u.port == 80);
678        assert_eq!(u.effective_port(), 80);
679        assert_url_invariants(&u);
680
681        // A non-http(s) scheme with no port has no default to fall back on.
682        let u = Url::parse("ftp://example.com/").unwrap();
683        assert_eq!(u.effective_port(), 0);
684        assert_url_invariants(&u);
685    }
686
687    #[cfg(feature = "url")]
688    #[test]
689    fn parse_is_idempotent_across_hostile_inputs() {
690        for s in [
691            // boundary numbers
692            "http://example.com/0",
693            "http://example.com/-0",
694            "http://example.com/9223372036854775807",
695            "http://example.com/-9223372036854775808",
696            "http://example.com/18446744073709551615",
697            "http://example.com/?n=NaN&i=inf&e=1e400&t=1e-400",
698            "http://example.com/#-0.0",
699            // leading/trailing junk
700            "  https://example.com/  ",
701            "\thttps://example.com/\n",
702            "https://example.com/valid;garbage",
703            "https://example.com/a?b=c;d#e;f",
704            // odd but legal shapes
705            "https://user:pw@example.com:8080/p?q#f",
706            "https://example.com/%2e%2e/%2E%2E/",
707            "https://example.com/a//b///c",
708            "https://example.com/?",
709            "https://example.com/#",
710            "https://example.com/?#",
711            "http://[::1]:8080/",
712            "http://[2001:db8::1]/",
713            "http://127.0.0.1:8080/",
714            "file:///etc/passwd",
715            "data:text/plain,hello",
716            "mailto:user@example.com",
717            "urn:isbn:0451450523",
718            "blob:https://example.com/uuid",
719            // percent-encoding edge cases
720            "https://example.com/%",
721            "https://example.com/%zz",
722            "https://example.com/%00",
723            "https://example.com/%%%%",
724            // unicode
725            "https://example.com/\u{1F600}",
726            "https://example.com/e\u{0301}\u{0301}\u{0301}",
727            "https://example.com/?q=\u{4F8B}\u{3048}",
728            "https://example.com/#\u{200B}\u{FEFF}",
729            "https://\u{4F8B}\u{3048}.\u{30C6}\u{30B9}\u{30C8}/",
730        ] {
731            assert_no_panic_and_idempotent(s);
732        }
733    }
734
735    #[cfg(feature = "url")]
736    #[test]
737    fn parse_normalizes_leading_and_trailing_whitespace_deterministically() {
738        // Either the junk is stripped (spec behaviour) or the input is rejected —
739        // never a Url carrying stray whitespace in its href.
740        match Url::parse("  https://example.com/  ") {
741            Ok(u) => {
742                assert_eq!(u.host.as_str(), "example.com");
743                assert_eq!(u.as_str(), "https://example.com/");
744                assert!(!u.as_str().contains(' '));
745                assert_url_invariants(&u);
746            }
747            Err(e) => assert!(!e.message.as_str().is_empty()),
748        }
749    }
750
751    #[cfg(feature = "url")]
752    #[test]
753    fn parse_unicode_host_is_idna_encoded_to_ascii() {
754        let u = Url::parse("https://\u{4F8B}\u{3048}.\u{30C6}\u{30B9}\u{30C8}/")
755            .expect("an IDN host must parse");
756        assert!(
757            u.host.as_str().is_ascii(),
758            "host must be punycode/ASCII after IDNA, got {:?}",
759            u.host.as_str()
760        );
761        assert!(!u.host.as_str().is_empty());
762        assert!(u.as_str().is_ascii(), "serialized href must be ASCII");
763        assert_url_invariants(&u);
764    }
765
766    #[cfg(feature = "url")]
767    #[test]
768    fn parse_unicode_path_is_percent_encoded() {
769        let u = Url::parse("https://example.com/\u{1F600}").expect("emoji path must parse");
770        assert!(
771            u.path.as_str().is_ascii(),
772            "path must be percent-encoded, got {:?}",
773            u.path.as_str()
774        );
775        assert!(u.as_str().is_ascii());
776        assert_url_invariants(&u);
777    }
778
779    #[cfg(feature = "url")]
780    #[test]
781    fn parse_survives_extremely_long_input() {
782        // 1M-char path segment: must be linear-time and allocation-safe, not a hang.
783        let long = format!("http://example.com/{}", "a".repeat(1_000_000));
784        let u = Url::parse(&long).expect("a long-but-valid URL must parse");
785        assert_eq!(u.host.as_str(), "example.com");
786        assert_eq!(u.path.as_str().len(), 1 + 1_000_000);
787        assert_url_invariants(&u);
788
789        // A 1M-char query and a 1M-char host label must also not panic.
790        let long_q = format!("http://example.com/?{}", "k=v&".repeat(250_000));
791        assert_no_panic_and_idempotent(&long_q);
792        let long_host = format!("http://{}/", "h".repeat(100_000));
793        assert_no_panic_and_idempotent(&long_host);
794    }
795
796    #[cfg(feature = "url")]
797    #[test]
798    fn parse_survives_deeply_nested_and_repetitive_input() {
799        // 10k nested path segments — must not blow the stack.
800        let nested = format!("http://example.com/{}", "a/".repeat(10_000));
801        let u = Url::parse(&nested).expect("deeply nested path must parse");
802        assert_eq!(u.path.as_str().matches('/').count(), 10_001);
803        assert_url_invariants(&u);
804
805        // 10k dot-dot segments that all try to escape the root.
806        let dotdot = format!("http://example.com/{}", "../".repeat(10_000));
807        let u = Url::parse(&dotdot).expect("dot-dot flood must parse");
808        assert_eq!(u.host.as_str(), "example.com", "must not escape the origin");
809        assert!(u.path.as_str().starts_with('/'));
810        assert!(
811            !u.path.as_str().contains(".."),
812            "dot-dot segments must be resolved away, got {:?}",
813            u.path.as_str()
814        );
815        assert_url_invariants(&u);
816
817        // 10k nested brackets/parens as raw path junk.
818        let brackets = format!(
819            "http://example.com/{}{}",
820            "(".repeat(10_000),
821            ")".repeat(10_000)
822        );
823        assert_no_panic_and_idempotent(&brackets);
824    }
825
826    #[cfg(feature = "url")]
827    #[test]
828    fn parse_round_trips_a_fully_populated_url() {
829        let src = "https://example.com:8080/path?query=1#frag";
830        let u = Url::parse(src).unwrap();
831        // Serialization is byte-identical to the (already-normalized) input…
832        assert_eq!(u.as_str(), src);
833        // …and re-parsing it is a fixed point across every field.
834        let u2 = Url::parse(u.as_str()).unwrap();
835        assert_eq!(u2, u);
836        assert_eq!(format!("{u2}"), format!("{u}"));
837        assert_url_invariants(&u);
838    }
839
840    #[cfg(feature = "url")]
841    #[test]
842    fn from_parts_output_reparses_into_the_same_url() {
843        // Non-default port: from_parts and parse must agree on every field.
844        let built = Url::from_parts("https", "example.com", 8080, "/a/b");
845        let parsed = Url::parse(built.as_str()).expect("from_parts output must be parseable");
846        assert_eq!(parsed, built, "from_parts must be a faithful serializer");
847
848        // Elided default port: the href round-trips, but the port FIELD does not —
849        // the parser reports the 0 sentinel where from_parts kept 443. The two
850        // disagree on `port` yet must still agree on `effective_port()`.
851        let built = Url::from_parts("https", "example.com", 443, "/a");
852        let parsed = Url::parse(built.as_str()).unwrap();
853        assert_eq!(parsed.as_str(), built.as_str());
854        assert_eq!(built.port, 443);
855        assert_eq!(parsed.port, 0);
856        assert_ne!(parsed, built, "port-field asymmetry across the round trip");
857        assert_eq!(parsed.effective_port(), built.effective_port());
858        assert_eq!(parsed.effective_port(), 443);
859    }
860
861    // ---------------------------------------------------------------------
862    // Url::join
863    // ---------------------------------------------------------------------
864
865    #[cfg(feature = "url")]
866    #[test]
867    fn join_valid_minimal_positive_control() {
868        let base = Url::parse("https://example.com/a/b").unwrap();
869        let j = base.join("c").expect("relative join must work");
870        assert_eq!(j.as_str(), "https://example.com/a/c");
871        assert_eq!(j.host.as_str(), "example.com");
872        assert!(j.is_https());
873        assert_eq!(j.effective_port(), 443);
874        assert_url_invariants(&j);
875
876        // Absolute path, absolute URL, and fragment-only joins.
877        assert_eq!(
878            base.join("/root").unwrap().as_str(),
879            "https://example.com/root"
880        );
881        assert_eq!(
882            base.join("http://other.com/x").unwrap().host.as_str(),
883            "other.com"
884        );
885        assert_eq!(base.join("#f").unwrap().fragment.as_str(), "f");
886        assert_eq!(base.join("?q=1").unwrap().query.as_str(), "q=1");
887    }
888
889    #[cfg(feature = "url")]
890    #[test]
891    fn join_on_an_unparseable_base_errors_instead_of_panicking() {
892        // Default Url: href is "" — the base itself cannot be parsed.
893        let e = Url::default()
894            .join("/x")
895            .expect_err("joining onto an empty base must fail");
896        assert!(!e.message.as_str().is_empty());
897
898        // from_parts can produce hrefs that are not valid URLs at all.
899        for bad in [
900            Url::from_parts("", "", 0, ""),
901            Url::from_parts("://", "://", 1, "://"),
902            Url::from_parts("http", "", 0, "/x"),
903        ] {
904            let r = bad.join("/y");
905            if let Ok(ref u) = r {
906                assert_url_invariants(u);
907            } else {
908                assert!(!r.unwrap_err().message.as_str().is_empty());
909            }
910        }
911    }
912
913    #[cfg(feature = "url")]
914    #[test]
915    fn join_never_panics_on_hostile_relative_inputs() {
916        let base = Url::parse("https://example.com/a/b?q=1#f").unwrap();
917        for path in [
918            "",
919            " ",
920            "   ",
921            "\t\n",
922            "..",
923            "../..",
924            "/",
925            "//",
926            "///",
927            "//other.com/x",
928            "?",
929            "#",
930            "?#",
931            ":",
932            "::::",
933            "not a path",
934            "valid;garbage",
935            "  padded  ",
936            "%",
937            "%zz",
938            "%00",
939            "\u{1F600}",
940            "e\u{0301}",
941            "\u{4F8B}\u{3048}",
942            "\u{0}",
943            "0",
944            "-0",
945            "9223372036854775807",
946            "NaN",
947            "inf",
948            "javascript:alert(1)",
949            "data:text/plain,x",
950            "mailto:a@b.c",
951        ] {
952            match base.join(path) {
953                Ok(u) => {
954                    assert_url_invariants(&u);
955                    // join() funnels through parse(), so the result must be a
956                    // fixed point of the parser too.
957                    let again = Url::parse(u.as_str())
958                        .expect("join() output must always re-parse");
959                    assert_eq!(again, u, "join({path:?}) is not idempotent");
960                }
961                Err(e) => assert!(
962                    !e.message.as_str().is_empty(),
963                    "join({path:?}) error needs a message"
964                ),
965            }
966        }
967    }
968
969    #[cfg(feature = "url")]
970    #[test]
971    fn join_cannot_escape_the_origin_with_a_dot_dot_flood() {
972        let base = Url::parse("https://example.com/a/b/c").unwrap();
973        let escape = "../".repeat(10_000);
974        let u = base
975            .join(&escape)
976            .expect("a dot-dot flood must resolve, not fail");
977        assert_eq!(u.host.as_str(), "example.com");
978        assert_eq!(u.scheme.as_str(), "https");
979        assert_eq!(u.path.as_str(), "/", "must clamp at the root");
980        assert!(!u.path.as_str().contains(".."));
981        assert_url_invariants(&u);
982    }
983
984    #[cfg(feature = "url")]
985    #[test]
986    fn join_survives_extremely_long_relative_paths() {
987        let base = Url::parse("https://example.com/").unwrap();
988        let long = "a".repeat(1_000_000);
989        let u = base.join(&long).expect("a long relative path must join");
990        assert_eq!(u.host.as_str(), "example.com");
991        assert_eq!(u.path.as_str().len(), 1 + 1_000_000);
992        assert_url_invariants(&u);
993
994        // 10k nested segments.
995        let nested = "x/".repeat(10_000);
996        let u = base.join(&nested).expect("deep nesting must join");
997        assert_eq!(u.path.as_str().matches('/').count(), 10_001);
998        assert_url_invariants(&u);
999    }
1000
1001    #[cfg(feature = "url")]
1002    #[test]
1003    fn join_result_is_a_fully_populated_url_not_a_partial_one() {
1004        // join() re-parses, so query/fragment/port must all be repopulated
1005        // from the joined string rather than inherited or dropped.
1006        let base = Url::parse("http://example.com:8080/a?old=1#oldfrag").unwrap();
1007        let u = base.join("b?new=2#newfrag").unwrap();
1008        assert_eq!(u.as_str(), "http://example.com:8080/b?new=2#newfrag");
1009        assert_eq!(u.scheme.as_str(), "http");
1010        assert_eq!(u.host.as_str(), "example.com");
1011        assert_eq!(u.port, 8080);
1012        assert_eq!(u.path.as_str(), "/b");
1013        assert_eq!(u.query.as_str(), "new=2");
1014        assert_eq!(u.fragment.as_str(), "newfrag");
1015        assert_eq!(u.effective_port(), 8080);
1016        assert_url_invariants(&u);
1017    }
1018
1019    // =====================================================================
1020    // Stub tests — the `url` feature is OFF, parse/join are const Err stubs.
1021    // =====================================================================
1022
1023    #[cfg(not(feature = "url"))]
1024    #[test]
1025    fn stub_parse_always_errors_and_never_panics() {
1026        let huge = "a".repeat(1_000_000);
1027        let nested = "(".repeat(10_000);
1028        for s in [
1029            "",
1030            " ",
1031            "\t\n",
1032            "https://example.com/",
1033            "not a url",
1034            "0",
1035            "-0",
1036            "NaN",
1037            "inf",
1038            "9223372036854775807",
1039            "\u{1F600}",
1040            "e\u{0301}",
1041            "\u{0}",
1042            huge.as_str(),
1043            nested.as_str(),
1044        ] {
1045            let e = Url::parse(s).expect_err("the stub must always fail");
1046            assert_eq!(e.message.as_str(), "url feature not enabled");
1047            assert_eq!(format!("{e}"), "url feature not enabled");
1048        }
1049    }
1050
1051    #[cfg(not(feature = "url"))]
1052    #[test]
1053    fn stub_join_always_errors_for_every_base_and_path() {
1054        let huge = "b".repeat(1_000_000);
1055        let bases = [
1056            Url::default(),
1057            Url::from_parts("https", "example.com", 8080, "/a"),
1058            Url::from_parts("", "", 0, ""),
1059        ];
1060        for base in &bases {
1061            for path in ["", " ", "../..", "\u{1F600}", "x", huge.as_str()] {
1062                let e = base.join(path).expect_err("the stub must always fail");
1063                assert_eq!(e.message.as_str(), "url feature not enabled");
1064            }
1065            // The base must be left untouched by the failed join.
1066            assert_url_invariants(base);
1067        }
1068    }
1069
1070    #[cfg(not(feature = "url"))]
1071    #[test]
1072    fn stub_parse_and_join_are_usable_in_const_context() {
1073        // Both stubs are `const fn`; evaluating them at compile time must not
1074        // trip a const-eval panic.
1075        const PARSED: Result<Url, UrlParseError> = Url::parse("https://example.com/");
1076        assert!(PARSED.is_err());
1077    }
1078}