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"))]
13
use alloc::string::ToString;
14
use alloc::string::String;
15
use core::fmt;
16

            
17
use azul_css::{impl_result, impl_result_inner, AzString};
18

            
19
/// A parsed URL
20
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
21
#[repr(C)]
22
pub 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)]
42
pub struct UrlParseError {
43
    /// Error message
44
    pub message: AzString,
45
}
46

            
47
impl fmt::Display for UrlParseError {
48
25
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49
25
        write!(f, "{}", self.message.as_str())
50
25
    }
51
}
52

            
53
#[cfg(feature = "std")]
54
impl std::error::Error for UrlParseError {}
55

            
56
// FFI-safe Result type for URL parsing
57
impl_result!(
58
    Url,
59
    UrlParseError,
60
    ResultUrlUrlParseError,
61
    copy = false,
62
    [Debug, Clone, PartialEq, Eq]
63
);
64

            
65
impl Url {
66
    /// Parse a URL from a string
67
    ///
68
    /// # Errors
69
    ///
70
    /// Returns a `UrlParseError` if `s` is not a valid absolute URL.
71
    #[cfg(feature = "url")]
72
    pub fn parse(s: &str) -> Result<Self, UrlParseError> {
73
        use ::url::Url as UrlParser;
74

            
75
        let parsed = UrlParser::parse(s).map_err(|e| UrlParseError {
76
            message: AzString::from(e.to_string()),
77
        })?;
78

            
79
        Ok(Self {
80
            href: AzString::from(parsed.as_str().to_string()),
81
            scheme: AzString::from(parsed.scheme().to_string()),
82
            host: AzString::from(parsed.host_str().unwrap_or("").to_string()),
83
            port: parsed.port().unwrap_or(0),
84
            path: AzString::from(parsed.path().to_string()),
85
            query: AzString::from(parsed.query().unwrap_or("").to_string()),
86
            fragment: AzString::from(parsed.fragment().unwrap_or("").to_string()),
87
        })
88
    }
89

            
90
    /// Create a URL from components
91
166
    #[must_use] pub fn from_parts(scheme: &str, host: &str, port: u16, path: &str) -> Self {
92
166
        let port_str = if port == 0
93
135
            || (scheme == "http" && port == 80)
94
134
            || (scheme == "https" && port == 443)
95
        {
96
148
            String::new()
97
        } else {
98
18
            alloc::format!(":{port}")
99
        };
100

            
101
166
        let href = alloc::format!("{scheme}://{host}{port_str}{path}");
102

            
103
166
        Self {
104
166
            href: AzString::from(href),
105
166
            scheme: AzString::from(scheme.to_string()),
106
166
            host: AzString::from(host.to_string()),
107
166
            port,
108
166
            path: AzString::from(path.to_string()),
109
166
            query: AzString::from(String::new()),
110
166
            fragment: AzString::from(String::new()),
111
166
        }
112
166
    }
113

            
114
    /// Get the full URL as a string slice
115
55
    #[must_use] pub fn as_str(&self) -> &str {
116
55
        self.href.as_str()
117
55
    }
118

            
119
    /// Check if this is an HTTPS URL
120
87
    #[must_use] pub fn is_https(&self) -> bool {
121
87
        self.scheme.as_str() == "https"
122
87
    }
123

            
124
    /// Check if this is an HTTP URL
125
98
    #[must_use] pub fn is_http(&self) -> bool {
126
98
        self.scheme.as_str() == "http"
127
98
    }
128

            
129
    /// Get the effective port (using default ports for http/https)
130
38
    #[must_use] pub fn effective_port(&self) -> u16 {
131
38
        if self.port != 0 {
132
15
            self.port
133
23
        } else if self.is_https() {
134
2
            443
135
21
        } else if self.is_http() {
136
5
            80
137
        } else {
138
16
            0
139
        }
140
38
    }
141

            
142
    /// Join a relative path to this URL
143
    ///
144
    /// # Errors
145
    ///
146
    /// Returns a `UrlParseError` if this URL's `href` is not parseable as a
147
    /// base, or if `path` cannot be resolved against it.
148
    #[cfg(feature = "url")]
149
    pub fn join(&self, path: &str) -> Result<Self, UrlParseError> {
150
        use ::url::Url as UrlParser;
151

            
152
        let base = UrlParser::parse(self.href.as_str()).map_err(|e| UrlParseError {
153
            message: AzString::from(e.to_string()),
154
        })?;
155

            
156
        let joined = base.join(path).map_err(|e| UrlParseError {
157
            message: AzString::from(e.to_string()),
158
        })?;
159

            
160
        Self::parse(joined.as_str())
161
    }
162

            
163
    /// Stub: `url` feature disabled (the `url` crate is gated behind it).
164
    #[cfg(not(feature = "url"))]
165
    /// # Errors
166
    ///
167
    /// Returns an error: the `url` feature is disabled, so URL parsing is unsupported.
168
15
    pub const fn parse(_s: &str) -> Result<Self, UrlParseError> {
169
15
        Err(UrlParseError {
170
15
            message: AzString::from_const_str("url feature not enabled"),
171
15
        })
172
15
    }
173

            
174
    /// Stub: `url` feature disabled (the `url` crate is gated behind it).
175
    #[cfg(not(feature = "url"))]
176
    /// # Errors
177
    ///
178
    /// Returns an error: the `url` feature is disabled, so URL joining is unsupported.
179
18
    pub const fn join(&self, _path: &str) -> Result<Self, UrlParseError> {
180
18
        Err(UrlParseError {
181
18
            message: AzString::from_const_str("url feature not enabled"),
182
18
        })
183
18
    }
184
}
185

            
186
impl fmt::Display for Url {
187
33
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188
33
        write!(f, "{}", self.href.as_str())
189
33
    }
190
}
191

            
192
#[cfg(test)]
193
mod tests {
194
    use super::*;
195

            
196
    #[test]
197
    #[cfg(feature = "url")]
198
    fn test_url_parse() {
199
        let url = Url::parse("https://example.com:8080/path?query=1#frag").unwrap();
200
        assert_eq!(url.scheme.as_str(), "https");
201
        assert_eq!(url.host.as_str(), "example.com");
202
        assert_eq!(url.port, 8080);
203
        assert_eq!(url.path.as_str(), "/path");
204
        assert_eq!(url.query.as_str(), "query=1");
205
        assert_eq!(url.fragment.as_str(), "frag");
206
    }
207

            
208
    #[test]
209
1
    fn test_url_from_parts() {
210
1
        let url = Url::from_parts("https", "example.com", 443, "/api");
211
1
        assert!(url.is_https());
212
1
        assert_eq!(url.effective_port(), 443);
213
1
    }
214
}
215

            
216
#[cfg(test)]
217
mod autotest_generated {
218
    use alloc::format;
219

            
220
    use super::*;
221

            
222
    /// Structural invariants that must hold for ANY `Url`, however it was built.
223
    ///
224
    /// Only checks properties derivable from the type's own contract, so it is
225
    /// safe to apply to the output of the external `url` crate parser too.
226
    fn assert_url_invariants(u: &Url) {
227
        // as_str() is exactly the href field, and Display agrees with it.
228
        assert_eq!(u.as_str(), u.href.as_str());
229
        assert_eq!(format!("{u}"), u.href.as_str());
230

            
231
        // is_http / is_https are mutually exclusive and match the scheme exactly.
232
        assert_eq!(u.is_https(), u.scheme.as_str() == "https");
233
        assert_eq!(u.is_http(), u.scheme.as_str() == "http");
234
        assert!(!(u.is_http() && u.is_https()));
235

            
236
        // effective_port() is a pure function of (port, scheme).
237
        let expected_port = if u.port != 0 {
238
            u.port
239
        } else if u.is_https() {
240
            443
241
        } else if u.is_http() {
242
            80
243
        } else {
244
            0
245
        };
246
        assert_eq!(u.effective_port(), expected_port);
247

            
248
        // Equality/clone consistency (the type derives Clone + PartialEq).
249
        assert_eq!(u.clone(), *u);
250
    }
251

            
252
    // ---------------------------------------------------------------------
253
    // Url::default() / getters / predicates on degenerate instances
254
    // ---------------------------------------------------------------------
255

            
256
    #[test]
257
    fn default_url_is_inert_and_does_not_panic() {
258
        let u = Url::default();
259
        assert_eq!(u.as_str(), "");
260
        assert_eq!(u.href.as_str(), "");
261
        assert_eq!(u.scheme.as_str(), "");
262
        assert_eq!(u.host.as_str(), "");
263
        assert_eq!(u.port, 0);
264
        assert!(!u.is_https());
265
        assert!(!u.is_http());
266
        // Unknown scheme + sentinel port => no default port to infer.
267
        assert_eq!(u.effective_port(), 0);
268
        assert_eq!(format!("{u}"), "");
269
        assert_url_invariants(&u);
270
    }
271

            
272
    #[test]
273
    fn effective_port_covers_every_scheme_port_combination() {
274
        // Explicit port always wins, even when it contradicts the scheme default.
275
        assert_eq!(
276
            Url::from_parts("https", "h", 80, "/").effective_port(),
277
            80,
278
            "explicit port must win over the https default"
279
        );
280
        assert_eq!(Url::from_parts("http", "h", 443, "/").effective_port(), 443);
281
        assert_eq!(
282
            Url::from_parts("http", "h", u16::MAX, "/").effective_port(),
283
            u16::MAX
284
        );
285
        assert_eq!(Url::from_parts("https", "h", 1, "/").effective_port(), 1);
286

            
287
        // Sentinel port 0 => infer from scheme.
288
        assert_eq!(Url::from_parts("https", "h", 0, "/").effective_port(), 443);
289
        assert_eq!(Url::from_parts("http", "h", 0, "/").effective_port(), 80);
290
        assert_eq!(Url::from_parts("ftp", "h", 0, "/").effective_port(), 0);
291
        assert_eq!(Url::from_parts("", "", 0, "").effective_port(), 0);
292
    }
293

            
294
    #[test]
295
    fn predicates_are_case_sensitive_and_reject_near_misses() {
296
        // Near-miss schemes must NOT be reported as http/https.
297
        for scheme in [
298
            "HTTPS", "Https", "httpss", "https ", " https", "http\0", "ws", "httpx", "",
299
        ] {
300
            let u = Url::from_parts(scheme, "example.com", 0, "/");
301
            assert!(
302
                !u.is_https(),
303
                "scheme {scheme:?} must not be treated as https"
304
            );
305
            assert_url_invariants(&u);
306
        }
307
        for scheme in ["HTTP", "Http", "httpx", "https", "htt", ""] {
308
            let u = Url::from_parts(scheme, "example.com", 0, "/");
309
            assert!(
310
                !u.is_http(),
311
                "scheme {scheme:?} must not be treated as http"
312
            );
313
        }
314
        assert!(Url::from_parts("https", "h", 0, "/").is_https());
315
        assert!(Url::from_parts("http", "h", 0, "/").is_http());
316
    }
317

            
318
    // ---------------------------------------------------------------------
319
    // Url::from_parts — constructor, no panics, invariants
320
    // ---------------------------------------------------------------------
321

            
322
    #[test]
323
    fn from_parts_omits_default_and_sentinel_ports_only() {
324
        // Sentinel 0 => no port in href.
325
        assert_eq!(
326
            Url::from_parts("https", "example.com", 0, "/a").as_str(),
327
            "https://example.com/a"
328
        );
329
        // Scheme-matching default ports => elided.
330
        assert_eq!(
331
            Url::from_parts("http", "example.com", 80, "/").as_str(),
332
            "http://example.com/"
333
        );
334
        assert_eq!(
335
            Url::from_parts("https", "example.com", 443, "/").as_str(),
336
            "https://example.com/"
337
        );
338
        // Cross-scheme "defaults" are NOT elided.
339
        assert_eq!(
340
            Url::from_parts("https", "example.com", 80, "/").as_str(),
341
            "https://example.com:80/"
342
        );
343
        assert_eq!(
344
            Url::from_parts("http", "example.com", 443, "/").as_str(),
345
            "http://example.com:443/"
346
        );
347
        // A non-default port is always rendered, including the u16 boundary.
348
        assert_eq!(
349
            Url::from_parts("http", "example.com", u16::MAX, "/").as_str(),
350
            "http://example.com:65535/"
351
        );
352
        assert_eq!(
353
            Url::from_parts("ftp", "example.com", 443, "/").as_str(),
354
            "ftp://example.com:443/"
355
        );
356
    }
357

            
358
    #[test]
359
    fn from_parts_keeps_the_port_field_even_when_elided_from_href() {
360
        // The elision is purely cosmetic: the struct field must still carry the
361
        // caller's port, and effective_port() must agree with it.
362
        let u = Url::from_parts("https", "example.com", 443, "/api");
363
        assert_eq!(u.port, 443);
364
        assert!(!u.as_str().contains(":443"));
365
        assert_eq!(u.effective_port(), 443);
366
        assert_url_invariants(&u);
367
    }
368

            
369
    #[test]
370
    fn from_parts_fields_mirror_the_arguments_verbatim() {
371
        let u = Url::from_parts("https", "example.com", 8080, "/a/b");
372
        assert_eq!(u.scheme.as_str(), "https");
373
        assert_eq!(u.host.as_str(), "example.com");
374
        assert_eq!(u.port, 8080);
375
        assert_eq!(u.path.as_str(), "/a/b");
376
        // from_parts has no query/fragment inputs; they must be empty, not garbage.
377
        assert_eq!(u.query.as_str(), "");
378
        assert_eq!(u.fragment.as_str(), "");
379
        assert_eq!(u.as_str(), "https://example.com:8080/a/b");
380
        assert_url_invariants(&u);
381
    }
382

            
383
    #[test]
384
    fn from_parts_does_not_panic_on_extreme_or_empty_arguments() {
385
        // Every argument empty: degenerate but must not panic.
386
        let u = Url::from_parts("", "", 0, "");
387
        assert_eq!(u.as_str(), "://");
388
        assert_url_invariants(&u);
389

            
390
        // from_parts is a raw formatter, not a validator: it must not panic on
391
        // inputs that could never parse, and must reproduce them byte-for-byte.
392
        for (scheme, host, port, path) in [
393
            ("://", "://", 1, "://"),
394
            ("http", "user:pw@host", 0, "/x"),
395
            ("http", "[::1]", 8080, "/x"),
396
            ("http", "a b c", 0, "/p a t h"),
397
            ("http", "example.com", 0, "no-leading-slash"),
398
            ("http", "example.com", 0, "?query#frag"),
399
            ("\n\t", "\r", 65535, "\0"),
400
        ] {
401
            let u = Url::from_parts(scheme, host, port, path);
402
            assert_eq!(u.scheme.as_str(), scheme);
403
            assert_eq!(u.host.as_str(), host);
404
            assert_eq!(u.port, port);
405
            assert_eq!(u.path.as_str(), path);
406
            assert!(u.as_str().starts_with(scheme));
407
            assert_url_invariants(&u);
408
        }
409
    }
410

            
411
    #[test]
412
    fn from_parts_handles_unicode_without_panicking_or_mangling() {
413
        let u = Url::from_parts("https", "例え.テスト", 0, "/パス/😀");
414
        assert_eq!(u.host.as_str(), "例え.テスト");
415
        assert_eq!(u.path.as_str(), "/パス/😀");
416
        // No IDNA/percent-encoding happens here — from_parts is a plain formatter.
417
        assert_eq!(u.as_str(), "https://例え.テスト/パス/😀");
418
        assert!(u.is_https());
419
        assert_url_invariants(&u);
420

            
421
        // Combining marks + a lone emoji as the whole host.
422
        let u = Url::from_parts("http", "e\u{0301}xample", 1, "/\u{1F600}");
423
        assert!(u.as_str().contains('\u{0301}'));
424
        assert_url_invariants(&u);
425
    }
426

            
427
    #[test]
428
    fn from_parts_handles_huge_inputs_without_hanging() {
429
        let host = "a".repeat(100_000);
430
        let path = "/".to_string() + &"b".repeat(100_000);
431
        let u = Url::from_parts("https", &host, 8080, &path);
432
        // "https" + "://" + host + ":8080" + path
433
        assert_eq!(u.as_str().len(), 5 + 3 + 100_000 + 5 + 100_001);
434
        assert_eq!(u.host.as_str().len(), 100_000);
435
        assert!(u.is_https());
436
        assert_eq!(u.effective_port(), 8080);
437
        assert_url_invariants(&u);
438
    }
439

            
440
    // ---------------------------------------------------------------------
441
    // Display / serializer
442
    // ---------------------------------------------------------------------
443

            
444
    #[test]
445
    fn url_display_never_reinterprets_the_href() {
446
        // Display must be a verbatim echo of href, not a re-serialization.
447
        for href in ["", "://", "not a url", "{}{{}", "%s%n", "\u{1F600}"] {
448
            let u = Url {
449
                href: AzString::from(href),
450
                ..Url::default()
451
            };
452
            assert_eq!(format!("{u}"), href);
453
            assert_eq!(u.as_str(), href);
454
        }
455
    }
456

            
457
    #[test]
458
    fn url_parse_error_display_is_verbatim_and_panic_free() {
459
        // Default / empty message.
460
        let e = UrlParseError {
461
            message: AzString::from(""),
462
        };
463
        assert_eq!(format!("{e}"), "");
464

            
465
        // Format-specifier-looking payloads must NOT be interpreted.
466
        for msg in [
467
            "relative URL without a base",
468
            "{}",
469
            "{0} {1} {}",
470
            "%s %n %p",
471
            "\u{1F600} invalid",
472
            "e\u{0301}",
473
            "\0\t\n",
474
        ] {
475
            let e = UrlParseError {
476
                message: AzString::from(msg),
477
            };
478
            assert_eq!(format!("{e}"), msg);
479
        }
480

            
481
        // Non-empty for a representative value, and huge messages don't panic.
482
        let big = "x".repeat(100_000);
483
        let e = UrlParseError {
484
            message: AzString::from(big.as_str()),
485
        };
486
        assert_eq!(format!("{e}").len(), 100_000);
487

            
488
        // from_const_str path (used by the no-`url`-feature stubs).
489
        let e = UrlParseError {
490
            message: AzString::from_const_str("url feature not enabled"),
491
        };
492
        assert_eq!(format!("{e}"), "url feature not enabled");
493
    }
494

            
495
    // ---------------------------------------------------------------------
496
    // FFI result type
497
    // ---------------------------------------------------------------------
498

            
499
    #[test]
500
    fn ffi_result_round_trips_both_variants() {
501
        let ok: Result<Url, UrlParseError> = Ok(Url::from_parts("https", "a.b", 0, "/"));
502
        let ffi: ResultUrlUrlParseError = ok.clone().into();
503
        assert!(ffi.is_ok());
504
        assert!(!ffi.is_err());
505
        let back: Result<Url, UrlParseError> = ffi.into();
506
        assert_eq!(back, ok);
507

            
508
        let err: Result<Url, UrlParseError> = Err(UrlParseError {
509
            message: AzString::from("boom"),
510
        });
511
        let ffi: ResultUrlUrlParseError = err.clone().into();
512
        assert!(ffi.is_err());
513
        assert!(!ffi.is_ok());
514
        assert_eq!(
515
            ffi.as_result().unwrap_err().message.as_str(),
516
            "boom",
517
            "as_result() must borrow the same error payload"
518
        );
519
        let back: Result<Url, UrlParseError> = ffi.into();
520
        assert_eq!(back, err);
521
    }
522

            
523
    #[cfg(feature = "std")]
524
    #[test]
525
    fn eq_implies_equal_hash() {
526
        use std::{
527
            collections::hash_map::DefaultHasher,
528
            hash::{Hash, Hasher},
529
        };
530

            
531
        fn hash_of(u: &Url) -> u64 {
532
            let mut h = DefaultHasher::new();
533
            u.hash(&mut h);
534
            h.finish()
535
        }
536

            
537
        let a = Url::from_parts("https", "example.com", 8080, "/a");
538
        let b = Url::from_parts("https", "example.com", 8080, "/a");
539
        assert_eq!(a, b);
540
        assert_eq!(hash_of(&a), hash_of(&b));
541

            
542
        // Port is part of identity even when it is elided from the href.
543
        let c = Url::from_parts("https", "example.com", 443, "/a");
544
        let d = Url::from_parts("https", "example.com", 0, "/a");
545
        assert_eq!(c.as_str(), d.as_str(), "hrefs are identical…");
546
        assert_ne!(c, d, "…but the port field still distinguishes them");
547
    }
548

            
549
    // =====================================================================
550
    // Parser tests — only meaningful with the `url` feature.
551
    // =====================================================================
552

            
553
    /// Parse `s`; if it succeeds, assert the general invariants AND that
554
    /// re-parsing the serialization is a fixed point (`parse(as_str(x)) == x`).
555
    ///
556
    /// Used for inputs whose accept/reject verdict is the `url` crate's business:
557
    /// the point is that we never panic and never produce an inconsistent `Url`.
558
    #[cfg(feature = "url")]
559
    fn assert_no_panic_and_idempotent(s: &str) {
560
        match Url::parse(s) {
561
            Ok(u) => {
562
                assert_url_invariants(&u);
563
                let again = Url::parse(u.as_str())
564
                    .expect("a serialized URL must always re-parse (idempotent normalization)");
565
                assert_eq!(again, u, "parse(serialize(x)) must equal x for input {s:?}");
566
                assert_eq!(again.as_str(), u.as_str());
567
            }
568
            Err(e) => {
569
                // An error must carry a diagnosable, non-empty message.
570
                assert!(
571
                    !e.message.as_str().is_empty(),
572
                    "error for {s:?} must have a message"
573
                );
574
                assert_eq!(format!("{e}"), e.message.as_str());
575
            }
576
        }
577
    }
578

            
579
    #[cfg(feature = "url")]
580
    #[test]
581
    fn parse_valid_minimal_positive_control() {
582
        let u = Url::parse("http://example.com").expect("minimal absolute URL must parse");
583
        assert_eq!(u.scheme.as_str(), "http");
584
        assert_eq!(u.host.as_str(), "example.com");
585
        // No explicit port => the 0 sentinel, resolved by effective_port().
586
        assert_eq!(u.port, 0);
587
        assert_eq!(u.effective_port(), 80);
588
        assert!(u.is_http());
589
        assert!(!u.is_https());
590
        // The parser normalizes the empty path to "/".
591
        assert_eq!(u.path.as_str(), "/");
592
        assert_eq!(u.query.as_str(), "");
593
        assert_eq!(u.fragment.as_str(), "");
594
        assert_url_invariants(&u);
595
    }
596

            
597
    #[cfg(feature = "url")]
598
    #[test]
599
    fn parse_rejects_empty_and_whitespace_only_input() {
600
        for s in ["", " ", "   ", "\t", "\n", "\t\n", "\r\n  \t "] {
601
            let r = Url::parse(s);
602
            assert!(r.is_err(), "{s:?} must not parse as an absolute URL");
603
            let e = r.unwrap_err();
604
            assert!(!e.message.as_str().is_empty());
605
        }
606
    }
607

            
608
    #[cfg(feature = "url")]
609
    #[test]
610
    fn parse_rejects_garbage_without_panicking() {
611
        for s in [
612
            "not a url",
613
            "///",
614
            "::::",
615
            "http://",
616
            "\u{0}\u{1}\u{2}",
617
            "\u{FFFD}\u{FFFD}",
618
            "?query-only",
619
            "#fragment-only",
620
            "/absolute/path/only",
621
            "../relative",
622
        ] {
623
            let r = Url::parse(s);
624
            if let Ok(ref u) = r {
625
                // If the parser DOES accept it, the result must still be coherent.
626
                assert_url_invariants(u);
627
            } else {
628
                assert!(!r.unwrap_err().message.as_str().is_empty());
629
            }
630
        }
631
        // These are unambiguously relative and must be rejected.
632
        assert!(Url::parse("not a url").is_err());
633
        assert!(Url::parse("/absolute/path/only").is_err());
634
        assert!(Url::parse("http://").is_err(), "empty host must be an error");
635
    }
636

            
637
    #[cfg(feature = "url")]
638
    #[test]
639
    fn parse_rejects_out_of_range_and_non_numeric_ports() {
640
        // u16 overflow at the boundary and far beyond it.
641
        assert!(Url::parse("http://example.com:65536/").is_err());
642
        assert!(Url::parse("http://example.com:99999/").is_err());
643
        assert!(Url::parse("http://example.com:4294967296/").is_err());
644
        assert!(Url::parse("http://example.com:18446744073709551616/").is_err());
645
        assert!(Url::parse("http://example.com:-1/").is_err());
646
        assert!(Url::parse("http://example.com:NaN/").is_err());
647
        assert!(Url::parse("http://example.com:inf/").is_err());
648
        assert!(Url::parse("http://example.com:+80/").is_err());
649
        assert!(Url::parse(&format!("http://example.com:{}/", "9".repeat(10_000))).is_err());
650

            
651
        // The largest in-range port must survive intact (no truncation to 0).
652
        let u = Url::parse("http://example.com:65535/").expect("65535 is a valid port");
653
        assert_eq!(u.port, u16::MAX);
654
        assert_eq!(u.effective_port(), u16::MAX);
655
        assert_url_invariants(&u);
656
    }
657

            
658
    #[cfg(feature = "url")]
659
    #[test]
660
    fn parse_port_zero_collides_with_the_no_port_sentinel() {
661
        // `port: 0` doubles as "unspecified", so an explicit :0 is indistinguishable
662
        // from no port at all — effective_port() then reports the scheme default.
663
        // This documents the sentinel's cost; it must at least stay self-consistent.
664
        if let Ok(u) = Url::parse("http://example.com:0/") {
665
            assert_eq!(u.port, 0);
666
            assert_eq!(
667
                u.effective_port(),
668
                80,
669
                "explicit :0 is swallowed by the 0 sentinel"
670
            );
671
            assert_url_invariants(&u);
672
        }
673
    }
674

            
675
    #[cfg(feature = "url")]
676
    #[test]
677
    fn parse_default_ports_are_reported_via_effective_port() {
678
        // Whether the parser stores or elides a scheme-default port, effective_port()
679
        // must resolve to the same answer.
680
        let u = Url::parse("https://example.com:443/").unwrap();
681
        assert!(u.port == 0 || u.port == 443);
682
        assert_eq!(u.effective_port(), 443);
683
        assert_url_invariants(&u);
684

            
685
        let u = Url::parse("http://example.com:80/").unwrap();
686
        assert!(u.port == 0 || u.port == 80);
687
        assert_eq!(u.effective_port(), 80);
688
        assert_url_invariants(&u);
689

            
690
        // A non-http(s) scheme with no port has no default to fall back on.
691
        let u = Url::parse("ftp://example.com/").unwrap();
692
        assert_eq!(u.effective_port(), 0);
693
        assert_url_invariants(&u);
694
    }
695

            
696
    #[cfg(feature = "url")]
697
    #[test]
698
    fn parse_is_idempotent_across_hostile_inputs() {
699
        for s in [
700
            // boundary numbers
701
            "http://example.com/0",
702
            "http://example.com/-0",
703
            "http://example.com/9223372036854775807",
704
            "http://example.com/-9223372036854775808",
705
            "http://example.com/18446744073709551615",
706
            "http://example.com/?n=NaN&i=inf&e=1e400&t=1e-400",
707
            "http://example.com/#-0.0",
708
            // leading/trailing junk
709
            "  https://example.com/  ",
710
            "\thttps://example.com/\n",
711
            "https://example.com/valid;garbage",
712
            "https://example.com/a?b=c;d#e;f",
713
            // odd but legal shapes
714
            "https://user:pw@example.com:8080/p?q#f",
715
            "https://example.com/%2e%2e/%2E%2E/",
716
            "https://example.com/a//b///c",
717
            "https://example.com/?",
718
            "https://example.com/#",
719
            "https://example.com/?#",
720
            "http://[::1]:8080/",
721
            "http://[2001:db8::1]/",
722
            "http://127.0.0.1:8080/",
723
            "file:///etc/passwd",
724
            "data:text/plain,hello",
725
            "mailto:user@example.com",
726
            "urn:isbn:0451450523",
727
            "blob:https://example.com/uuid",
728
            // percent-encoding edge cases
729
            "https://example.com/%",
730
            "https://example.com/%zz",
731
            "https://example.com/%00",
732
            "https://example.com/%%%%",
733
            // unicode
734
            "https://example.com/\u{1F600}",
735
            "https://example.com/e\u{0301}\u{0301}\u{0301}",
736
            "https://example.com/?q=\u{4F8B}\u{3048}",
737
            "https://example.com/#\u{200B}\u{FEFF}",
738
            "https://\u{4F8B}\u{3048}.\u{30C6}\u{30B9}\u{30C8}/",
739
        ] {
740
            assert_no_panic_and_idempotent(s);
741
        }
742
    }
743

            
744
    #[cfg(feature = "url")]
745
    #[test]
746
    fn parse_normalizes_leading_and_trailing_whitespace_deterministically() {
747
        // Either the junk is stripped (spec behaviour) or the input is rejected —
748
        // never a Url carrying stray whitespace in its href.
749
        match Url::parse("  https://example.com/  ") {
750
            Ok(u) => {
751
                assert_eq!(u.host.as_str(), "example.com");
752
                assert_eq!(u.as_str(), "https://example.com/");
753
                assert!(!u.as_str().contains(' '));
754
                assert_url_invariants(&u);
755
            }
756
            Err(e) => assert!(!e.message.as_str().is_empty()),
757
        }
758
    }
759

            
760
    #[cfg(feature = "url")]
761
    #[test]
762
    fn parse_unicode_host_is_idna_encoded_to_ascii() {
763
        let u = Url::parse("https://\u{4F8B}\u{3048}.\u{30C6}\u{30B9}\u{30C8}/")
764
            .expect("an IDN host must parse");
765
        assert!(
766
            u.host.as_str().is_ascii(),
767
            "host must be punycode/ASCII after IDNA, got {:?}",
768
            u.host.as_str()
769
        );
770
        assert!(!u.host.as_str().is_empty());
771
        assert!(u.as_str().is_ascii(), "serialized href must be ASCII");
772
        assert_url_invariants(&u);
773
    }
774

            
775
    #[cfg(feature = "url")]
776
    #[test]
777
    fn parse_unicode_path_is_percent_encoded() {
778
        let u = Url::parse("https://example.com/\u{1F600}").expect("emoji path must parse");
779
        assert!(
780
            u.path.as_str().is_ascii(),
781
            "path must be percent-encoded, got {:?}",
782
            u.path.as_str()
783
        );
784
        assert!(u.as_str().is_ascii());
785
        assert_url_invariants(&u);
786
    }
787

            
788
    #[cfg(feature = "url")]
789
    #[test]
790
    fn parse_survives_extremely_long_input() {
791
        // 1M-char path segment: must be linear-time and allocation-safe, not a hang.
792
        let long = format!("http://example.com/{}", "a".repeat(1_000_000));
793
        let u = Url::parse(&long).expect("a long-but-valid URL must parse");
794
        assert_eq!(u.host.as_str(), "example.com");
795
        assert_eq!(u.path.as_str().len(), 1 + 1_000_000);
796
        assert_url_invariants(&u);
797

            
798
        // A 1M-char query and a 1M-char host label must also not panic.
799
        let long_q = format!("http://example.com/?{}", "k=v&".repeat(250_000));
800
        assert_no_panic_and_idempotent(&long_q);
801
        let long_host = format!("http://{}/", "h".repeat(100_000));
802
        assert_no_panic_and_idempotent(&long_host);
803
    }
804

            
805
    #[cfg(feature = "url")]
806
    #[test]
807
    fn parse_survives_deeply_nested_and_repetitive_input() {
808
        // 10k nested path segments — must not blow the stack.
809
        let nested = format!("http://example.com/{}", "a/".repeat(10_000));
810
        let u = Url::parse(&nested).expect("deeply nested path must parse");
811
        assert_eq!(u.path.as_str().matches('/').count(), 10_001);
812
        assert_url_invariants(&u);
813

            
814
        // 10k dot-dot segments that all try to escape the root.
815
        let dotdot = format!("http://example.com/{}", "../".repeat(10_000));
816
        let u = Url::parse(&dotdot).expect("dot-dot flood must parse");
817
        assert_eq!(u.host.as_str(), "example.com", "must not escape the origin");
818
        assert!(u.path.as_str().starts_with('/'));
819
        assert!(
820
            !u.path.as_str().contains(".."),
821
            "dot-dot segments must be resolved away, got {:?}",
822
            u.path.as_str()
823
        );
824
        assert_url_invariants(&u);
825

            
826
        // 10k nested brackets/parens as raw path junk.
827
        let brackets = format!(
828
            "http://example.com/{}{}",
829
            "(".repeat(10_000),
830
            ")".repeat(10_000)
831
        );
832
        assert_no_panic_and_idempotent(&brackets);
833
    }
834

            
835
    #[cfg(feature = "url")]
836
    #[test]
837
    fn parse_round_trips_a_fully_populated_url() {
838
        let src = "https://example.com:8080/path?query=1#frag";
839
        let u = Url::parse(src).unwrap();
840
        // Serialization is byte-identical to the (already-normalized) input…
841
        assert_eq!(u.as_str(), src);
842
        // …and re-parsing it is a fixed point across every field.
843
        let u2 = Url::parse(u.as_str()).unwrap();
844
        assert_eq!(u2, u);
845
        assert_eq!(format!("{u2}"), format!("{u}"));
846
        assert_url_invariants(&u);
847
    }
848

            
849
    #[cfg(feature = "url")]
850
    #[test]
851
    fn from_parts_output_reparses_into_the_same_url() {
852
        // Non-default port: from_parts and parse must agree on every field.
853
        let built = Url::from_parts("https", "example.com", 8080, "/a/b");
854
        let parsed = Url::parse(built.as_str()).expect("from_parts output must be parseable");
855
        assert_eq!(parsed, built, "from_parts must be a faithful serializer");
856

            
857
        // Elided default port: the href round-trips, but the port FIELD does not —
858
        // the parser reports the 0 sentinel where from_parts kept 443. The two
859
        // disagree on `port` yet must still agree on `effective_port()`.
860
        let built = Url::from_parts("https", "example.com", 443, "/a");
861
        let parsed = Url::parse(built.as_str()).unwrap();
862
        assert_eq!(parsed.as_str(), built.as_str());
863
        assert_eq!(built.port, 443);
864
        assert_eq!(parsed.port, 0);
865
        assert_ne!(parsed, built, "port-field asymmetry across the round trip");
866
        assert_eq!(parsed.effective_port(), built.effective_port());
867
        assert_eq!(parsed.effective_port(), 443);
868
    }
869

            
870
    // ---------------------------------------------------------------------
871
    // Url::join
872
    // ---------------------------------------------------------------------
873

            
874
    #[cfg(feature = "url")]
875
    #[test]
876
    fn join_valid_minimal_positive_control() {
877
        let base = Url::parse("https://example.com/a/b").unwrap();
878
        let j = base.join("c").expect("relative join must work");
879
        assert_eq!(j.as_str(), "https://example.com/a/c");
880
        assert_eq!(j.host.as_str(), "example.com");
881
        assert!(j.is_https());
882
        assert_eq!(j.effective_port(), 443);
883
        assert_url_invariants(&j);
884

            
885
        // Absolute path, absolute URL, and fragment-only joins.
886
        assert_eq!(
887
            base.join("/root").unwrap().as_str(),
888
            "https://example.com/root"
889
        );
890
        assert_eq!(
891
            base.join("http://other.com/x").unwrap().host.as_str(),
892
            "other.com"
893
        );
894
        assert_eq!(base.join("#f").unwrap().fragment.as_str(), "f");
895
        assert_eq!(base.join("?q=1").unwrap().query.as_str(), "q=1");
896
    }
897

            
898
    #[cfg(feature = "url")]
899
    #[test]
900
    fn join_on_an_unparseable_base_errors_instead_of_panicking() {
901
        // Default Url: href is "" — the base itself cannot be parsed.
902
        let e = Url::default()
903
            .join("/x")
904
            .expect_err("joining onto an empty base must fail");
905
        assert!(!e.message.as_str().is_empty());
906

            
907
        // from_parts can produce hrefs that are not valid URLs at all.
908
        for bad in [
909
            Url::from_parts("", "", 0, ""),
910
            Url::from_parts("://", "://", 1, "://"),
911
            Url::from_parts("http", "", 0, "/x"),
912
        ] {
913
            let r = bad.join("/y");
914
            if let Ok(ref u) = r {
915
                assert_url_invariants(u);
916
            } else {
917
                assert!(!r.unwrap_err().message.as_str().is_empty());
918
            }
919
        }
920
    }
921

            
922
    #[cfg(feature = "url")]
923
    #[test]
924
    fn join_never_panics_on_hostile_relative_inputs() {
925
        let base = Url::parse("https://example.com/a/b?q=1#f").unwrap();
926
        for path in [
927
            "",
928
            " ",
929
            "   ",
930
            "\t\n",
931
            "..",
932
            "../..",
933
            "/",
934
            "//",
935
            "///",
936
            "//other.com/x",
937
            "?",
938
            "#",
939
            "?#",
940
            ":",
941
            "::::",
942
            "not a path",
943
            "valid;garbage",
944
            "  padded  ",
945
            "%",
946
            "%zz",
947
            "%00",
948
            "\u{1F600}",
949
            "e\u{0301}",
950
            "\u{4F8B}\u{3048}",
951
            "\u{0}",
952
            "0",
953
            "-0",
954
            "9223372036854775807",
955
            "NaN",
956
            "inf",
957
            "javascript:alert(1)",
958
            "data:text/plain,x",
959
            "mailto:a@b.c",
960
        ] {
961
            match base.join(path) {
962
                Ok(u) => {
963
                    assert_url_invariants(&u);
964
                    // join() funnels through parse(), so the result must be a
965
                    // fixed point of the parser too.
966
                    let again = Url::parse(u.as_str())
967
                        .expect("join() output must always re-parse");
968
                    assert_eq!(again, u, "join({path:?}) is not idempotent");
969
                }
970
                Err(e) => assert!(
971
                    !e.message.as_str().is_empty(),
972
                    "join({path:?}) error needs a message"
973
                ),
974
            }
975
        }
976
    }
977

            
978
    #[cfg(feature = "url")]
979
    #[test]
980
    fn join_cannot_escape_the_origin_with_a_dot_dot_flood() {
981
        let base = Url::parse("https://example.com/a/b/c").unwrap();
982
        let escape = "../".repeat(10_000);
983
        let u = base
984
            .join(&escape)
985
            .expect("a dot-dot flood must resolve, not fail");
986
        assert_eq!(u.host.as_str(), "example.com");
987
        assert_eq!(u.scheme.as_str(), "https");
988
        assert_eq!(u.path.as_str(), "/", "must clamp at the root");
989
        assert!(!u.path.as_str().contains(".."));
990
        assert_url_invariants(&u);
991
    }
992

            
993
    #[cfg(feature = "url")]
994
    #[test]
995
    fn join_survives_extremely_long_relative_paths() {
996
        let base = Url::parse("https://example.com/").unwrap();
997
        let long = "a".repeat(1_000_000);
998
        let u = base.join(&long).expect("a long relative path must join");
999
        assert_eq!(u.host.as_str(), "example.com");
        assert_eq!(u.path.as_str().len(), 1 + 1_000_000);
        assert_url_invariants(&u);
        // 10k nested segments.
        let nested = "x/".repeat(10_000);
        let u = base.join(&nested).expect("deep nesting must join");
        assert_eq!(u.path.as_str().matches('/').count(), 10_001);
        assert_url_invariants(&u);
    }
    #[cfg(feature = "url")]
    #[test]
    fn join_result_is_a_fully_populated_url_not_a_partial_one() {
        // join() re-parses, so query/fragment/port must all be repopulated
        // from the joined string rather than inherited or dropped.
        let base = Url::parse("http://example.com:8080/a?old=1#oldfrag").unwrap();
        let u = base.join("b?new=2#newfrag").unwrap();
        assert_eq!(u.as_str(), "http://example.com:8080/b?new=2#newfrag");
        assert_eq!(u.scheme.as_str(), "http");
        assert_eq!(u.host.as_str(), "example.com");
        assert_eq!(u.port, 8080);
        assert_eq!(u.path.as_str(), "/b");
        assert_eq!(u.query.as_str(), "new=2");
        assert_eq!(u.fragment.as_str(), "newfrag");
        assert_eq!(u.effective_port(), 8080);
        assert_url_invariants(&u);
    }
    // =====================================================================
    // Stub tests — the `url` feature is OFF, parse/join are const Err stubs.
    // =====================================================================
    #[cfg(not(feature = "url"))]
    #[test]
    fn stub_parse_always_errors_and_never_panics() {
        let huge = "a".repeat(1_000_000);
        let nested = "(".repeat(10_000);
        for s in [
            "",
            " ",
            "\t\n",
            "https://example.com/",
            "not a url",
            "0",
            "-0",
            "NaN",
            "inf",
            "9223372036854775807",
            "\u{1F600}",
            "e\u{0301}",
            "\u{0}",
            huge.as_str(),
            nested.as_str(),
        ] {
            let e = Url::parse(s).expect_err("the stub must always fail");
            assert_eq!(e.message.as_str(), "url feature not enabled");
            assert_eq!(format!("{e}"), "url feature not enabled");
        }
    }
    #[cfg(not(feature = "url"))]
    #[test]
    fn stub_join_always_errors_for_every_base_and_path() {
        let huge = "b".repeat(1_000_000);
        let bases = [
            Url::default(),
            Url::from_parts("https", "example.com", 8080, "/a"),
            Url::from_parts("", "", 0, ""),
        ];
        for base in &bases {
            for path in ["", " ", "../..", "\u{1F600}", "x", huge.as_str()] {
                let e = base.join(path).expect_err("the stub must always fail");
                assert_eq!(e.message.as_str(), "url feature not enabled");
            }
            // The base must be left untouched by the failed join.
            assert_url_invariants(base);
        }
    }
    #[cfg(not(feature = "url"))]
    #[test]
    fn stub_parse_and_join_are_usable_in_const_context() {
        // Both stubs are `const fn`; evaluating them at compile time must not
        // trip a const-eval panic.
        const PARSED: Result<Url, UrlParseError> = Url::parse("https://example.com/");
        assert!(PARSED.is_err());
    }
}