aster_forge_utils/
url.rs

1//! URL and origin normalization helpers.
2//!
3//! This module contains product-neutral URL rules shared by Aster services. It normalizes HTTP
4//! origins for CORS and public-site matching, validates HTTP base URLs used by integrations, and
5//! exposes small predicates for OAuth-style redirect and endpoint checks. Callers still decide
6//! whether failures are configuration errors, validation errors, or domain-specific errors.
7
8use std::cell::Cell;
9
10use http::Uri;
11use url::{SyntaxViolation, Url};
12
13use crate::{Result, UtilsError, net::is_loopback_host};
14
15/// Options for [`normalize_http_base_url`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct HttpBaseUrlOptions {
18    /// Whether empty input should return `None`.
19    pub allow_empty: bool,
20    /// Whether query strings and fragments should be rejected.
21    pub forbid_query_fragment: bool,
22}
23
24impl HttpBaseUrlOptions {
25    /// Creates options for a required base URL without query or fragment components.
26    #[must_use]
27    pub const fn required_without_query_fragment() -> Self {
28        Self {
29            allow_empty: false,
30            forbid_query_fragment: true,
31        }
32    }
33
34    /// Creates options for an optional base URL without query or fragment components.
35    #[must_use]
36    pub const fn optional_without_query_fragment() -> Self {
37        Self {
38            allow_empty: true,
39            forbid_query_fragment: true,
40        }
41    }
42}
43
44/// Returns whether `url` uses `http` or `https`.
45#[must_use]
46pub fn has_http_scheme(url: &Url) -> bool {
47    matches!(url.scheme(), "http" | "https")
48}
49
50/// Parses a URL and maps parser failures into [`UtilsError`].
51///
52/// The parser requires an absolute URL. The `context` is included verbatim in the error message so
53/// callers can name the field or operation that supplied the value, then map [`UtilsError`] into
54/// their own validation or configuration error category at the application boundary.
55///
56/// # Errors
57///
58/// Returns an error when `value` is not an absolute URL accepted by [`Url::parse`].
59pub fn parse_url(value: &str, context: &str) -> Result<Url> {
60    Url::parse(value).map_err(|error| UtilsError::invalid_value(format!("{context}: {error}")))
61}
62
63/// Returns whether `url` is HTTPS or an HTTP loopback URL.
64///
65/// This is useful for development-friendly security checks where plain HTTP is accepted only for
66/// localhost and loopback IP addresses.
67pub fn is_https_or_loopback_http(url: &Url) -> bool {
68    url.scheme() == "https"
69        || (url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host))
70}
71
72/// Parses an absolute URL and maps parser failures into [`UtilsError`].
73///
74/// # Errors
75///
76/// Returns an error when `value` is not an absolute URL accepted by [`Url::parse`].
77pub fn parse_absolute_url(value: &str, label: &str) -> Result<Url> {
78    Url::parse(value).map_err(|error| {
79        UtilsError::invalid_value(format!("{label} must be an absolute URL: {error}"))
80    })
81}
82
83/// Parses an absolute base URL and injects raw credentials through [`Url`] setters.
84///
85/// `base_url` must include a host and must not already contain userinfo. The raw username and
86/// password are never trimmed or interpolated into a string, so reserved characters are encoded
87/// exactly once by the URL implementation. Passing `None` for the username with a password is the
88/// password-only form used by Redis.
89///
90/// The returned URL contains credentials. Callers must keep it out of logs, debug output, health
91/// details, and error context.
92///
93/// # Errors
94///
95/// Returns an error when `base_url` is invalid, has no authority host, cannot be a base URL,
96/// already contains userinfo, or does not support the requested username or password components.
97pub fn url_with_credentials(
98    base_url: &str,
99    username: Option<&str>,
100    password: Option<&str>,
101    context: &str,
102) -> Result<Url> {
103    let has_explicit_userinfo = Cell::new(false);
104    let record_syntax_violation = |violation| {
105        if violation == SyntaxViolation::EmbeddedCredentials {
106            has_explicit_userinfo.set(true);
107        }
108    };
109    let mut url = Url::options()
110        .syntax_violation_callback(Some(&record_syntax_violation))
111        .parse(base_url)
112        .map_err(|error| {
113            UtilsError::invalid_value(format!("{context} must be an absolute URL: {error}"))
114        })?;
115    if url.host_str().is_none_or(str::is_empty) || url.cannot_be_a_base() {
116        return Err(UtilsError::invalid_value(format!(
117            "{context} must include an authority host for credentials"
118        )));
119    }
120
121    if has_explicit_userinfo.get() || !url.username().is_empty() || url.password().is_some() {
122        return Err(UtilsError::invalid_value(format!(
123            "{context} must not already include userinfo"
124        )));
125    }
126
127    if username.is_none() && password.is_none() {
128        return Ok(url);
129    }
130
131    url.set_username(username.unwrap_or_default())
132        .map_err(|()| {
133            UtilsError::invalid_value(format!("{context} does not support username credentials"))
134        })?;
135    url.set_password(password).map_err(|()| {
136        UtilsError::invalid_value(format!("{context} does not support password credentials"))
137    })?;
138    Ok(url)
139}
140
141/// Parses a required absolute HTTP or HTTPS URL with a host.
142///
143/// Paths, queries, and fragments are preserved. This is intended for ordinary
144/// navigation or redirect targets rather than base URLs used for path joining.
145///
146/// # Errors
147///
148/// Returns an error when the value is empty, is not an absolute URL, does not use HTTP(S), or has
149/// no host.
150pub fn parse_http_url(value: &str, label: &str) -> Result<Url> {
151    let normalized = value.trim();
152    if normalized.is_empty() {
153        return Err(UtilsError::invalid_value(format!(
154            "{label} cannot be empty"
155        )));
156    }
157
158    let parsed = parse_absolute_url(normalized, label)?;
159    if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
160        return Err(UtilsError::invalid_value(format!(
161            "{label} must use http or https and include a host"
162        )));
163    }
164    Ok(parsed)
165}
166
167/// Normalizes an HTTP base URL.
168///
169/// Surrounding whitespace and trailing slashes are removed before parsing. The URL must be absolute,
170/// use `http` or `https`, and include a host. When `options.forbid_query_fragment` is set, query
171/// strings and fragments are rejected so callers can safely append paths.
172///
173/// # Errors
174///
175/// Returns an error when a required value is empty, the URL is invalid or lacks an HTTP(S) host,
176/// or query and fragment components are present while forbidden by `options`.
177pub fn normalize_http_base_url(
178    value: &str,
179    label: &str,
180    options: HttpBaseUrlOptions,
181) -> Result<Option<String>> {
182    let normalized = value.trim().trim_end_matches('/').to_string();
183    if normalized.is_empty() {
184        if options.allow_empty {
185            return Ok(None);
186        }
187        return Err(UtilsError::invalid_value(format!(
188            "{label} cannot be empty"
189        )));
190    }
191
192    let parsed = Url::parse(&normalized).map_err(|error| {
193        UtilsError::invalid_value(format!(
194            "{label} must be an absolute http/https URL: {error}"
195        ))
196    })?;
197    if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
198        return Err(UtilsError::invalid_value(format!(
199            "{label} must use http or https and include a host"
200        )));
201    }
202    if options.forbid_query_fragment && (parsed.query().is_some() || parsed.fragment().is_some()) {
203        return Err(UtilsError::invalid_value(format!(
204            "{label} cannot include query or fragment"
205        )));
206    }
207
208    Ok(Some(normalized))
209}
210
211/// Normalizes an HTTP origin for CORS and public-site comparisons.
212///
213/// The returned value is lowercase `scheme://authority`. Paths other than `/`, query strings,
214/// fragments, and userinfo are rejected. When `allow_wildcard` is true, `*` is returned unchanged.
215///
216/// # Errors
217///
218/// Returns an error when the origin is empty, malformed, lacks an HTTP(S) scheme or authority, or
219/// contains userinfo, a query, or a non-root path.
220pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result<String> {
221    normalize_origin_with_additional_schemes(origin, allow_wildcard, &[])
222}
223
224/// Normalizes an origin while accepting explicitly selected non-HTTP schemes.
225///
226/// Additional schemes only affect syntax validation. Callers must still apply their own exact
227/// origin allowlist; accepting a scheme here does not authorize every origin using that scheme.
228///
229/// # Errors
230///
231/// Returns an error when the origin is empty or malformed, its scheme is not HTTP(S) or listed in
232/// `additional_schemes`, it has no authority, or it contains userinfo, a query, or a non-root path.
233pub fn normalize_origin_with_additional_schemes(
234    origin: &str,
235    allow_wildcard: bool,
236    additional_schemes: &[&str],
237) -> Result<String> {
238    let trimmed = origin.trim();
239    if trimmed.is_empty() {
240        return Err(UtilsError::invalid_value("origin cannot be empty"));
241    }
242
243    if allow_wildcard && trimmed == "*" {
244        return Ok("*".to_string());
245    }
246
247    let uri: Uri = trimmed
248        .parse()
249        .map_err(|_| UtilsError::invalid_value(format!("invalid origin '{trimmed}'")))?;
250
251    let scheme = uri.scheme_str().ok_or_else(|| {
252        UtilsError::invalid_value(format!(
253            "origin must include http:// or https://: '{trimmed}'"
254        ))
255    })?;
256
257    if scheme != "http" && scheme != "https" && !additional_schemes.contains(&scheme) {
258        return Err(UtilsError::invalid_value(format!(
259            "origin scheme is not supported: '{trimmed}'"
260        )));
261    }
262
263    let authority = uri.authority().ok_or_else(|| {
264        UtilsError::invalid_value(format!("origin must include a host: '{trimmed}'"))
265    })?;
266
267    if authority.as_str().contains('@') {
268        return Err(UtilsError::invalid_value(format!(
269            "origin must not include userinfo: '{trimmed}'"
270        )));
271    }
272
273    if uri.path_and_query().and_then(|pq| pq.query()).is_some() {
274        return Err(UtilsError::invalid_value(format!(
275            "origin must not include query parameters: '{trimmed}'"
276        )));
277    }
278
279    let path = uri.path();
280    if !path.is_empty() && path != "/" {
281        return Err(UtilsError::invalid_value(format!(
282            "origin must not include a path: '{trimmed}'"
283        )));
284    }
285
286    Ok(format!(
287        "{}://{}",
288        scheme.to_ascii_lowercase(),
289        authority.as_str().to_ascii_lowercase()
290    ))
291}
292
293/// Parses a JSON array of public site origins.
294///
295/// Empty input is rejected because a public-site URL configuration value should either be absent
296/// at the product layer or contain an explicit JSON array. Empty strings inside the array are
297/// ignored so operators can clean up accidental blank entries without blocking the whole value.
298///
299/// # Errors
300///
301/// Returns an error when the input is empty or is not a JSON array of strings.
302pub fn parse_public_site_origin_entries(value: &str) -> Result<Vec<String>> {
303    let trimmed = value.trim();
304    if trimmed.is_empty() {
305        return Err(UtilsError::invalid_value(
306            "public_site_url must be a JSON array of origins",
307        ));
308    }
309
310    let entries = serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
311        UtilsError::invalid_value(format!(
312            "public_site_url must be a JSON array of origins: {error}"
313        ))
314    })?;
315
316    Ok(entries
317        .into_iter()
318        .map(|origin| origin.trim().to_string())
319        .filter(|origin| !origin.is_empty())
320        .collect())
321}
322
323/// Normalizes a public site origin.
324///
325/// Public-site origins intentionally reject wildcards because they are used for selecting concrete
326/// callback, CSRF, and frontend URLs.
327///
328/// # Errors
329///
330/// Returns an error for wildcard input or any origin rejected by [`normalize_origin`].
331pub fn normalize_public_site_origin(origin: &str) -> Result<String> {
332    if origin.trim() == "*" {
333        return Err(UtilsError::invalid_value(
334            "public_site_url does not support wildcard origins",
335        ));
336    }
337
338    normalize_origin(origin, false).map_err(|error| {
339        UtilsError::invalid_value(format!(
340            "invalid public_site_url origin '{origin}': {error}"
341        ))
342    })
343}
344
345/// Parses, normalizes, and de-duplicates configured public site origins while preserving order.
346///
347/// # Errors
348///
349/// Returns an error when the value is not a JSON string array or any non-blank entry is not a valid
350/// concrete public-site origin.
351pub fn parse_public_site_origins(value: &str) -> Result<Vec<String>> {
352    let mut origins = Vec::new();
353    for origin in parse_public_site_origin_entries(value)? {
354        let normalized = normalize_public_site_origin(&origin)?;
355        if !origins.contains(&normalized) {
356            origins.push(normalized);
357        }
358    }
359
360    Ok(origins)
361}
362
363/// Normalizes a public-site URL config value into canonical JSON.
364///
365/// # Errors
366///
367/// Returns an error when origin parsing or normalization fails, or when the normalized origin list
368/// cannot be serialized.
369pub fn normalize_public_site_origins_config_value(value: &str) -> Result<String> {
370    let origins = parse_public_site_origins(value)?;
371    serde_json::to_string(&origins).map_err(|error| {
372        UtilsError::invalid_value(format!(
373            "failed to serialize public_site_url origins: {error}"
374        ))
375    })
376}
377
378/// Parses runtime public-site origins, ignoring invalid entries individually.
379///
380/// The `on_invalid` callback receives invalid entries or whole-value parse failures so product
381/// crates can log with their own config key and context.
382pub fn runtime_public_site_origins_with<F>(value: Option<&str>, mut on_invalid: F) -> Vec<String>
383where
384    F: FnMut(Option<&str>, &UtilsError),
385{
386    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
387        return Vec::new();
388    };
389
390    let entries = match parse_public_site_origin_entries(value) {
391        Ok(entries) => entries,
392        Err(error) => {
393            on_invalid(None, &error);
394            return Vec::new();
395        }
396    };
397
398    let mut origins = Vec::new();
399    for origin in entries {
400        match normalize_public_site_origin(&origin) {
401            Ok(normalized) => {
402                if !origins.contains(&normalized) {
403                    origins.push(normalized);
404                }
405            }
406            Err(error) => on_invalid(Some(&origin), &error),
407        }
408    }
409
410    origins
411}
412
413/// Selects the configured public-site origin that matches the current request, falling back to the
414/// first configured origin.
415#[must_use]
416pub fn public_site_origin_for_request(
417    origins: &[String],
418    scheme: &str,
419    host: &str,
420) -> Option<String> {
421    if origins.is_empty() {
422        return None;
423    }
424
425    let request_origin = normalize_origin(&format!("{scheme}://{host}"), false).ok();
426    if let Some(request_origin) = request_origin
427        && origins.iter().any(|origin| origin == &request_origin)
428    {
429        return Some(request_origin);
430    }
431
432    origins.first().cloned()
433}
434
435/// Joins an origin and an application path.
436#[must_use]
437pub fn join_origin_and_path(base: &str, path: &str) -> String {
438    let normalized_path = if path.starts_with('/') {
439        path.to_string()
440    } else {
441        format!("/{path}")
442    };
443
444    format!("{base}{normalized_path}")
445}
446
447#[cfg(test)]
448mod tests {
449    use super::{
450        HttpBaseUrlOptions, has_http_scheme, is_https_or_loopback_http, normalize_http_base_url,
451        normalize_origin, normalize_public_site_origins_config_value, parse_absolute_url,
452        parse_public_site_origins, parse_url, public_site_origin_for_request, url_with_credentials,
453    };
454    use crate::UtilsError;
455    use percent_encoding::percent_decode_str;
456    use url::Url;
457
458    fn decode_userinfo(value: &str) -> String {
459        percent_decode_str(value)
460            .decode_utf8()
461            .unwrap()
462            .into_owned()
463    }
464
465    #[test]
466    fn credentials_are_percent_encoded_once_and_round_trip() {
467        let username = "user#[]{}^+=*@:/?%\u{63a7}\u{5236}";
468        let password = "pass#[]{}^+=*@:/?%\u{0001}\u{5bc6}\u{7801}";
469        let url = url_with_credentials(
470            "postgres://db.example:5432/app?sslmode=require#driver",
471            Some(username),
472            Some(password),
473            "database base URL",
474        )
475        .unwrap();
476
477        assert_eq!(url.host_str(), Some("db.example"));
478        assert_eq!(url.path(), "/app");
479        assert_eq!(url.query(), Some("sslmode=require"));
480        assert_eq!(url.fragment(), Some("driver"));
481        assert_eq!(decode_userinfo(url.username()), username);
482        assert_eq!(decode_userinfo(url.password().unwrap()), password);
483        assert!(!url.as_str().contains(username));
484        assert!(!url.as_str().contains(password));
485        assert_eq!(Url::parse(url.as_str()).unwrap(), url);
486    }
487
488    #[test]
489    fn credentials_support_password_only_and_empty_username() {
490        let password_only = url_with_credentials(
491            "redis://cache.example:6379/2?protocol=resp3",
492            None,
493            Some("#secret[]"),
494            "Redis base URL",
495        )
496        .unwrap();
497        assert_eq!(password_only.username(), "");
498        assert_eq!(
499            decode_userinfo(password_only.password().unwrap()),
500            "#secret[]"
501        );
502
503        let empty_username = url_with_credentials(
504            "redis://cache.example:6379/2",
505            Some(""),
506            Some("secret"),
507            "Redis base URL",
508        )
509        .unwrap();
510        assert_eq!(empty_username.username(), "");
511        assert_eq!(empty_username.password(), Some("secret"));
512    }
513
514    #[test]
515    fn credentials_reject_existing_userinfo_without_exposing_raw_password() {
516        for base_url in [
517            "redis://user@cache.example/0",
518            "redis://:password@cache.example/0",
519            "redis://@cache.example/0",
520        ] {
521            let error = url_with_credentials(
522                base_url,
523                Some("replacement"),
524                Some("raw#replacement"),
525                "Redis base URL",
526            )
527            .unwrap_err();
528            assert!(matches!(error, UtilsError::InvalidValue(_)));
529            assert!(
530                error
531                    .to_string()
532                    .contains("must not already include userinfo")
533            );
534            assert!(!error.to_string().contains("raw#replacement"));
535        }
536    }
537
538    #[test]
539    fn credentials_reject_hostless_and_invalid_base_urls() {
540        for base_url in [
541            "mailto:user@example.com",
542            "data:text/plain,hello",
543            "redis+unix:///tmp/redis.sock",
544        ] {
545            let error =
546                url_with_credentials(base_url, None, Some("raw#secret"), "credential base URL")
547                    .unwrap_err();
548            assert!(matches!(error, UtilsError::InvalidValue(_)));
549            assert!(
550                error
551                    .to_string()
552                    .contains("must include an authority host for credentials")
553            );
554            assert!(!error.to_string().contains("raw#secret"));
555        }
556
557        let error = url_with_credentials(
558            "not a URL",
559            Some("user"),
560            Some("raw#secret"),
561            "credential base URL",
562        )
563        .unwrap_err();
564        assert!(matches!(error, UtilsError::InvalidValue(_)));
565        assert!(!error.to_string().contains("raw#secret"));
566    }
567
568    #[test]
569    fn parse_url_maps_parser_errors_with_context() {
570        let parsed = parse_url("https://example.com/callback", "callback URL").unwrap();
571        assert_eq!(parsed.scheme(), "https");
572        assert_eq!(parsed.host_str(), Some("example.com"));
573
574        let error = parse_url("not a url", "callback URL").unwrap_err();
575        assert!(matches!(error, UtilsError::InvalidValue(_)));
576        assert!(error.to_string().contains("callback URL:"));
577        assert!(error.to_string().contains("relative URL without a base"));
578    }
579
580    #[test]
581    fn http_base_url_normalization_trims_and_removes_trailing_slashes() {
582        assert_eq!(
583            normalize_http_base_url(
584                " https://example.test/root// ",
585                "demo_url",
586                HttpBaseUrlOptions::required_without_query_fragment(),
587            )
588            .unwrap(),
589            Some("https://example.test/root".to_string())
590        );
591    }
592
593    #[test]
594    fn http_base_url_normalization_handles_empty_values() {
595        assert_eq!(
596            normalize_http_base_url(
597                "  ",
598                "demo_url",
599                HttpBaseUrlOptions::optional_without_query_fragment(),
600            )
601            .unwrap(),
602            None
603        );
604        assert!(
605            normalize_http_base_url(
606                "  ",
607                "demo_url",
608                HttpBaseUrlOptions::required_without_query_fragment(),
609            )
610            .is_err()
611        );
612    }
613
614    #[test]
615    fn http_base_url_normalization_rejects_bad_scheme_and_query_fragment() {
616        assert!(
617            normalize_http_base_url(
618                "ftp://example.test/root",
619                "demo_url",
620                HttpBaseUrlOptions::required_without_query_fragment(),
621            )
622            .is_err()
623        );
624        assert!(
625            normalize_http_base_url(
626                "https://example.test/root?x=1",
627                "demo_url",
628                HttpBaseUrlOptions::required_without_query_fragment(),
629            )
630            .is_err()
631        );
632        assert!(
633            normalize_http_base_url(
634                "https://example.test/root#frag",
635                "demo_url",
636                HttpBaseUrlOptions::required_without_query_fragment(),
637            )
638            .is_err()
639        );
640    }
641
642    #[test]
643    fn normalize_origin_trims_trailing_slash_and_lowercases() {
644        assert_eq!(
645            normalize_origin(" HTTPS://Example.COM:8443/ ", false).unwrap(),
646            "https://example.com:8443"
647        );
648    }
649
650    #[test]
651    fn normalize_origin_accepts_wildcard_only_when_allowed() {
652        assert_eq!(normalize_origin("*", true).unwrap(), "*");
653        assert!(normalize_origin("*", false).is_err());
654    }
655
656    #[test]
657    fn normalize_origin_rejects_invalid_origin_components() {
658        assert!(normalize_origin("https://app.example.com/path", false).is_err());
659        assert!(normalize_origin("https://app.example.com?x=1", false).is_err());
660        assert!(normalize_origin("https://user@app.example.com", false).is_err());
661        assert!(normalize_origin("ftp://app.example.com", false).is_err());
662        assert!(normalize_origin("https:///missing-host", false).is_err());
663    }
664
665    #[test]
666    fn normalize_origin_accepts_only_explicit_additional_schemes() {
667        use super::normalize_origin_with_additional_schemes;
668
669        let extension_origin = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd";
670        assert!(normalize_origin(extension_origin, false).is_err());
671        assert_eq!(
672            normalize_origin_with_additional_schemes(
673                extension_origin,
674                false,
675                &["chrome-extension"],
676            )
677            .unwrap(),
678            extension_origin
679        );
680        assert!(
681            normalize_origin_with_additional_schemes(
682                "custom-extension://example",
683                false,
684                &["chrome-extension"],
685            )
686            .is_err()
687        );
688    }
689
690    #[test]
691    fn public_site_origins_are_normalized_and_deduplicated() {
692        assert_eq!(
693            normalize_public_site_origins_config_value(
694                r#"[" HTTPS://Forge.EXAMPLE.com/ ","https://Panel.example.com","https://forge.example.com"]"#
695            )
696            .unwrap(),
697            r#"["https://forge.example.com","https://panel.example.com"]"#
698        );
699        assert_eq!(
700            parse_public_site_origins(
701                r#"["https://forge.example.com","","https://api.example.com"]"#
702            )
703            .unwrap(),
704            vec![
705                "https://forge.example.com".to_string(),
706                "https://api.example.com".to_string()
707            ]
708        );
709    }
710
711    #[test]
712    fn public_site_origins_reject_invalid_entries() {
713        assert!(
714            normalize_public_site_origins_config_value(r#"["https://forge.example.com/app"]"#)
715                .is_err()
716        );
717        assert!(
718            normalize_public_site_origins_config_value(r#"["ftp://forge.example.com"]"#).is_err()
719        );
720        assert!(normalize_public_site_origins_config_value(r#"["*"]"#).is_err());
721        assert!(
722            normalize_public_site_origins_config_value(r#""https://forge.example.com""#).is_err()
723        );
724    }
725
726    #[test]
727    fn public_site_origin_for_request_prefers_matching_origin() {
728        let origins = vec![
729            "https://forge.example.com".to_string(),
730            "https://panel.example.com".to_string(),
731        ];
732
733        assert_eq!(
734            public_site_origin_for_request(&origins, "https", "panel.example.com").as_deref(),
735            Some("https://panel.example.com")
736        );
737        assert_eq!(
738            public_site_origin_for_request(&origins, "https", "evil.example.com").as_deref(),
739            Some("https://forge.example.com")
740        );
741    }
742
743    #[test]
744    fn url_scheme_predicates_match_http_and_loopback_rules() {
745        let https = parse_absolute_url("https://example.com/callback", "callback").unwrap();
746        let http_loopback = parse_absolute_url("http://127.0.0.1/callback", "callback").unwrap();
747        let http_public = parse_absolute_url("http://example.com/callback", "callback").unwrap();
748        let ftp = parse_absolute_url("ftp://example.com/file", "file").unwrap();
749
750        assert!(has_http_scheme(&https));
751        assert!(has_http_scheme(&http_loopback));
752        assert!(!has_http_scheme(&ftp));
753        assert!(is_https_or_loopback_http(&https));
754        assert!(is_https_or_loopback_http(&http_loopback));
755        assert!(!is_https_or_loopback_http(&http_public));
756    }
757}