Skip to main content

uv_redacted/
lib.rs

1use ref_cast::RefCast;
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4use std::fmt::{Debug, Display};
5use std::ops::{Deref, DerefMut};
6use std::str::FromStr;
7use thiserror::Error;
8use url::Url;
9
10const SENSITIVE_QUERY_PARAMETERS: &[&str] = &[
11    "X-Amz-Credential",
12    "X-Amz-Security-Token",
13    "X-Amz-Signature",
14];
15
16#[derive(Error, Debug, Clone, PartialEq, Eq)]
17pub enum DisplaySafeUrlError {
18    /// Failed to parse a URL.
19    #[error(transparent)]
20    Url(#[from] url::ParseError),
21
22    /// We parsed a URL, but couldn't disambiguate its authority
23    /// component.
24    #[error("ambiguous user/pass authority in URL (not percent-encoded?): {0}")]
25    AmbiguousAuthority(String),
26}
27
28/// A [`Url`] wrapper that redacts credentials and sensitive query parameters when displaying the URL.
29///
30/// `DisplaySafeUrl` wraps the standard [`url::Url`] type, providing functionality to mask
31/// secrets by default when the URL is displayed or logged. This helps prevent accidental
32/// exposure of sensitive information in logs and debug output.
33///
34/// # Examples
35///
36/// ```
37/// use uv_redacted::DisplaySafeUrl;
38/// use std::str::FromStr;
39///
40/// // Create a `DisplaySafeUrl` from a `&str`
41/// let mut url = DisplaySafeUrl::parse("https://user:password@example.com").unwrap();
42///
43/// // Display will mask secrets
44/// assert_eq!(url.to_string(), "https://user:****@example.com/");
45///
46/// // You can still access the username and password
47/// assert_eq!(url.username(), "user");
48/// assert_eq!(url.password(), Some("password"));
49///
50/// // And you can still update the username and password
51/// let _ = url.set_username("new_user");
52/// let _ = url.set_password(Some("new_password"));
53/// assert_eq!(url.username(), "new_user");
54/// assert_eq!(url.password(), Some("new_password"));
55///
56/// // It is also possible to remove the credentials entirely
57/// url.remove_credentials();
58/// assert_eq!(url.username(), "");
59/// assert_eq!(url.password(), None);
60/// ```
61#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize, RefCast)]
62#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
63#[cfg_attr(feature = "schemars", schemars(transparent))]
64#[repr(transparent)]
65pub struct DisplaySafeUrl(Url);
66
67/// Check if a path or fragment contains a credential-like pattern (`:` followed by `@`).
68///
69/// This skips colons that are followed by `//`, as those indicate URL schemes (e.g., `https://`)
70/// rather than credentials. This is important for handling nested URLs like proxy URLs:
71/// `git+https://proxy.com/https://github.com/user/repo.git@branch`.
72fn has_credential_like_pattern(s: &str) -> bool {
73    let mut remaining = s;
74    while let Some(colon_pos) = remaining.find(':') {
75        let after_colon = &remaining[colon_pos + 1..];
76        // If the colon is followed by "//", consider it a URL scheme.
77        if after_colon.starts_with("//") {
78            remaining = after_colon;
79            continue;
80        }
81        // Check if there's an @ after this colon.
82        if after_colon.contains('@') {
83            return true;
84        }
85        remaining = after_colon;
86    }
87    false
88}
89
90impl DisplaySafeUrl {
91    #[inline]
92    pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
93        let url = Url::parse(input)?;
94
95        Self::reject_ambiguous_credentials(input, &url)?;
96
97        Ok(Self(url))
98    }
99
100    /// Reject some ambiguous cases, e.g., `https://user/name:password@domain/a/b/c`
101    ///
102    /// In this case the user *probably* meant to have a username of "user/name", but both RFC
103    /// 3986 and WHATWG URL expect the userinfo (RFC 3986) or authority (WHATWG) to not contain a
104    /// non-percent-encoded slash or other special character.
105    ///
106    /// This ends up being moderately annoying to detect, since the above gets parsed into a
107    /// "valid" WHATWG URL where the host is `used` and the pathname is
108    /// `/name:password@domain/a/b/c` rather than causing a parse error.
109    ///
110    /// To detect it, we use a heuristic: if the password component is missing but the path or
111    /// fragment contain a `:` followed by a `@`, then we assume the URL is ambiguous.
112    fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), DisplaySafeUrlError> {
113        // `git://`, `http://`, and `https://` URLs may carry credentials, while `file://` URLs
114        // on Windows may contain both sigils, but it's always safe, e.g.
115        // `file://C:/Users/ferris/project@home/workspace`.
116        if url.scheme() == "file" {
117            return Ok(());
118        }
119
120        if url.password().is_some() {
121            return Ok(());
122        }
123
124        // Check for the suspicious pattern.
125        if !has_credential_like_pattern(url.path())
126            && !url.fragment().is_some_and(has_credential_like_pattern)
127        {
128            return Ok(());
129        }
130
131        // If the previous check passed, we should always expect to find these in the given URL.
132        let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
133            if cfg!(debug_assertions) {
134                unreachable!(
135                    "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
136                );
137            }
138            return Ok(());
139        };
140
141        // Our ambiguous URL probably has credentials in it, so we don't want to blast it out in
142        // the error message. We somewhat aggressively replace everything between the scheme's
143        // ':' and the lastmost `@` with `***`.
144        let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
145        Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path))
146    }
147
148    /// Create a new [`DisplaySafeUrl`] from a [`Url`].
149    ///
150    /// Unlike [`Self::parse`], this doesn't perform any ambiguity checks.
151    /// That means that it's primarily useful for contexts where a human can't easily accidentally
152    /// introduce an ambiguous URL, such as URLs being read from a request.
153    pub fn from_url(url: Url) -> Self {
154        Self(url)
155    }
156
157    /// Cast a `&Url` to a `&DisplaySafeUrl` using ref-cast.
158    #[inline]
159    pub fn ref_cast(url: &Url) -> &Self {
160        RefCast::ref_cast(url)
161    }
162
163    /// Parse a string as an URL, with this URL as the base URL.
164    #[inline]
165    pub fn join(&self, input: &str) -> Result<Self, DisplaySafeUrlError> {
166        Ok(Self(self.0.join(input)?))
167    }
168
169    /// Serialize with Serde using the internal representation of the `Url` struct.
170    #[inline]
171    pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
172    where
173        S: serde::Serializer,
174    {
175        self.0.serialize_internal(serializer)
176    }
177
178    /// Serialize with Serde using the internal representation of the `Url` struct.
179    #[inline]
180    pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
181    where
182        D: serde::Deserializer<'de>,
183    {
184        Ok(Self(Url::deserialize_internal(deserializer)?))
185    }
186
187    #[expect(clippy::result_unit_err)]
188    pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
189        Ok(Self(Url::from_file_path(path)?))
190    }
191
192    /// Remove the credentials from a URL, allowing the generic `git` username (without a password)
193    /// in SSH URLs, as in, `ssh://git@github.com/...`.
194    #[inline]
195    pub fn remove_credentials(&mut self) {
196        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
197        // username.
198        if is_ssh_git_username(&self.0) {
199            return;
200        }
201        let _ = self.0.set_username("");
202        let _ = self.0.set_password(None);
203    }
204
205    /// Returns the URL with any credentials removed.
206    pub fn without_credentials(&self) -> Cow<'_, Url> {
207        if self.0.password().is_none() && self.0.username() == "" {
208            return Cow::Borrowed(&self.0);
209        }
210
211        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
212        // username.
213        if is_ssh_git_username(&self.0) {
214            return Cow::Borrowed(&self.0);
215        }
216
217        let mut url = self.0.clone();
218        let _ = url.set_username("");
219        let _ = url.set_password(None);
220        Cow::Owned(url)
221    }
222
223    /// Returns [`Display`] implementation that doesn't mask credentials.
224    #[inline]
225    pub fn displayable_with_credentials(&self) -> impl Display {
226        &self.0
227    }
228}
229
230impl Deref for DisplaySafeUrl {
231    type Target = Url;
232
233    fn deref(&self) -> &Self::Target {
234        &self.0
235    }
236}
237
238impl DerefMut for DisplaySafeUrl {
239    fn deref_mut(&mut self) -> &mut Self::Target {
240        &mut self.0
241    }
242}
243
244impl Display for DisplaySafeUrl {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        display_with_redacted_credentials(&self.0, f)
247    }
248}
249
250impl Debug for DisplaySafeUrl {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        let url = &self.0;
253        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid masking the
254        // username.
255        let (username, password) = if is_ssh_git_username(url) {
256            (url.username(), None)
257        } else if url.username() != "" && url.password().is_some() {
258            (url.username(), Some("****"))
259        } else if url.username() != "" {
260            ("****", None)
261        } else if url.password().is_some() {
262            ("", Some("****"))
263        } else {
264            ("", None)
265        };
266
267        f.debug_struct("DisplaySafeUrl")
268            .field("scheme", &url.scheme())
269            .field("cannot_be_a_base", &url.cannot_be_a_base())
270            .field("username", &username)
271            .field("password", &password)
272            .field("host", &url.host())
273            .field("port", &url.port())
274            .field("path", &url.path())
275            .field(
276                "query",
277                &url.query()
278                    .map(|query| redacted_query(query, url.query_pairs())),
279            )
280            .field("fragment", &url.fragment())
281            .finish()
282    }
283}
284
285impl From<DisplaySafeUrl> for Url {
286    fn from(url: DisplaySafeUrl) -> Self {
287        url.0
288    }
289}
290
291impl From<Url> for DisplaySafeUrl {
292    fn from(url: Url) -> Self {
293        Self(url)
294    }
295}
296
297impl FromStr for DisplaySafeUrl {
298    type Err = DisplaySafeUrlError;
299
300    fn from_str(input: &str) -> Result<Self, Self::Err> {
301        Self::parse(input)
302    }
303}
304
305fn is_ssh_git_username(url: &Url) -> bool {
306    matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
307        && url.username() == "git"
308        && url.password().is_none()
309}
310
311fn is_sensitive_query_parameter(key: &str) -> bool {
312    SENSITIVE_QUERY_PARAMETERS
313        .iter()
314        .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
315}
316
317fn redacted_query<'a>(
318    query: &'a str,
319    query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
320) -> Cow<'a, str> {
321    let mut redacted = false;
322    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
323    for (key, value) in query_pairs {
324        if is_sensitive_query_parameter(&key) {
325            serializer.append_pair(&key, "****");
326            redacted = true;
327        } else {
328            serializer.append_pair(&key, &value);
329        }
330    }
331
332    if redacted {
333        Cow::Owned(serializer.finish())
334    } else {
335        Cow::Borrowed(query)
336    }
337}
338
339fn display_with_redacted_credentials(
340    url: &Url,
341    f: &mut std::fmt::Formatter<'_>,
342) -> std::fmt::Result {
343    write!(f, "{}:", url.scheme())?;
344
345    if url.has_authority() {
346        write!(f, "//")?;
347
348        if url.username() != "" && url.password().is_some() {
349            write!(f, "{}", url.username())?;
350            write!(f, ":****@")?;
351        } else if url.username() != "" && is_ssh_git_username(url) {
352            write!(f, "{}@", url.username())?;
353        } else if url.username() != "" {
354            write!(f, "****@")?;
355        } else if url.password().is_some() {
356            write!(f, ":****@")?;
357        }
358
359        write!(f, "{}", url.host_str().unwrap_or(""))?;
360
361        if let Some(port) = url.port() {
362            write!(f, ":{port}")?;
363        }
364    }
365
366    write!(f, "{}", url.path())?;
367    if let Some(query) = url.query() {
368        write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
369    }
370    if let Some(fragment) = url.fragment() {
371        write!(f, "#{fragment}")?;
372    }
373
374    Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn from_url_no_credentials() {
383        let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
384        let log_safe_url =
385            DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
386        assert_eq!(log_safe_url.username(), "");
387        assert!(log_safe_url.password().is_none());
388        assert_eq!(log_safe_url.to_string(), url_str);
389    }
390
391    #[test]
392    fn from_url_username_and_password() {
393        let log_safe_url =
394            DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
395                .unwrap();
396        assert_eq!(log_safe_url.username(), "user");
397        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
398        assert_eq!(
399            log_safe_url.to_string(),
400            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
401        );
402    }
403
404    #[test]
405    fn from_url_just_password() {
406        let log_safe_url =
407            DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
408        assert_eq!(log_safe_url.username(), "");
409        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
410        assert_eq!(
411            log_safe_url.to_string(),
412            "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
413        );
414    }
415
416    #[test]
417    fn from_url_just_username() {
418        let log_safe_url =
419            DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
420        assert_eq!(log_safe_url.username(), "user");
421        assert!(log_safe_url.password().is_none());
422        assert_eq!(
423            log_safe_url.to_string(),
424            "https://****@pypi-proxy.fly.dev/basic-auth/simple"
425        );
426    }
427
428    #[test]
429    fn from_url_git_username() {
430        let ssh_str = "ssh://git@github.com/org/repo";
431        let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
432        assert_eq!(ssh_url.username(), "git");
433        assert!(ssh_url.password().is_none());
434        assert_eq!(ssh_url.to_string(), ssh_str);
435        // Test again for the `git+ssh` scheme
436        let git_ssh_str = "git+ssh://git@github.com/org/repo";
437        let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
438        assert_eq!(git_ssh_url.username(), "git");
439        assert!(git_ssh_url.password().is_none());
440        assert_eq!(git_ssh_url.to_string(), git_ssh_str);
441    }
442
443    #[test]
444    fn parse_url_string() {
445        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
446        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
447        assert_eq!(log_safe_url.username(), "user");
448        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
449        assert_eq!(
450            log_safe_url.to_string(),
451            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
452        );
453    }
454
455    #[test]
456    fn remove_credentials() {
457        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
458        let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
459        log_safe_url.remove_credentials();
460        assert_eq!(log_safe_url.username(), "");
461        assert!(log_safe_url.password().is_none());
462        assert_eq!(
463            log_safe_url.to_string(),
464            "https://pypi-proxy.fly.dev/basic-auth/simple"
465        );
466    }
467
468    #[test]
469    fn preserve_ssh_git_username_on_remove_credentials() {
470        let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
471        let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
472        ssh_url.remove_credentials();
473        assert_eq!(ssh_url.username(), "git");
474        assert!(ssh_url.password().is_none());
475        assert_eq!(ssh_url.to_string(), ssh_str);
476        // Test again for `git+ssh` scheme
477        let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
478        let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
479        git_shh_url.remove_credentials();
480        assert_eq!(git_shh_url.username(), "git");
481        assert!(git_shh_url.password().is_none());
482        assert_eq!(git_shh_url.to_string(), git_ssh_str);
483    }
484
485    #[test]
486    fn displayable_with_credentials() {
487        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
488        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
489        assert_eq!(
490            log_safe_url.displayable_with_credentials().to_string(),
491            url_str
492        );
493    }
494
495    #[test]
496    fn redact_aws_presigned_query_values() {
497        let log_safe_url = DisplaySafeUrl::parse(
498            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential&X-Amz-Date=20260424T120000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=signature&X-Amz-Security-Token=token",
499        )
500        .unwrap();
501
502        assert_eq!(
503            log_safe_url.to_string(),
504            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=****&X-Amz-Date=20260424T120000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=****&X-Amz-Security-Token=****"
505        );
506    }
507
508    #[test]
509    fn redact_aws_presigned_query_values_case_insensitive() {
510        let log_safe_url = DisplaySafeUrl::parse(
511            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
512        )
513        .unwrap();
514
515        assert_eq!(
516            log_safe_url.to_string(),
517            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
518        );
519    }
520
521    #[test]
522    fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
523        let log_safe_url = DisplaySafeUrl::parse(
524            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
525        )
526        .unwrap();
527
528        assert_eq!(
529            log_safe_url.to_string(),
530            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
531        );
532    }
533
534    #[test]
535    fn redact_aws_presigned_query_values_in_debug() {
536        let log_safe_url = DisplaySafeUrl::parse(
537            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
538        )
539        .unwrap();
540
541        let debug = format!("{log_safe_url:?}");
542        assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
543        assert!(!debug.contains("credential"));
544        assert!(!debug.contains("signature"));
545    }
546
547    #[test]
548    fn does_not_redact_unknown_query_values() {
549        let log_safe_url =
550            DisplaySafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
551
552        assert_eq!(
553            log_safe_url.to_string(),
554            "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
555        );
556    }
557
558    #[test]
559    fn does_not_add_authority_to_urls_without_authority() {
560        let log_safe_url = DisplaySafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
561
562        assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
563    }
564
565    #[test]
566    fn redacts_query_values_in_urls_without_authority() {
567        let log_safe_url =
568            DisplaySafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
569
570        assert_eq!(
571            log_safe_url.to_string(),
572            "c:/home/ferris/projects/foo?X-Amz-Signature=****"
573        );
574    }
575
576    #[test]
577    fn redacts_query_values_in_cannot_be_a_base_urls() {
578        let log_safe_url =
579            DisplaySafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
580
581        assert!(log_safe_url.cannot_be_a_base());
582        assert_eq!(
583            log_safe_url.to_string(),
584            "mailto:ferris@example.com?X-Amz-Signature=****"
585        );
586    }
587
588    #[test]
589    fn url_join() {
590        let url_str = "https://token@example.com/abc/";
591        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
592        let foo_url = log_safe_url.join("foo").unwrap();
593        assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
594    }
595
596    #[test]
597    fn log_safe_url_ref() {
598        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
599        let url = DisplaySafeUrl::parse(url_str).unwrap();
600        let log_safe_url = DisplaySafeUrl::ref_cast(&url);
601        assert_eq!(log_safe_url.username(), "user");
602        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
603        assert_eq!(
604            log_safe_url.to_string(),
605            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
606        );
607    }
608
609    #[test]
610    fn parse_url_ambiguous() {
611        for url in &[
612            "https://user/name:password@domain/a/b/c",
613            "https://user\\name:password@domain/a/b/c",
614            "https://user#name:password@domain/a/b/c",
615            "https://user.com/name:password@domain/a/b/c",
616        ] {
617            let err = DisplaySafeUrl::parse(url).unwrap_err();
618            match err {
619                DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
620                    assert!(redacted.starts_with("https:***@domain/a/b/c"));
621                }
622                DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
623            }
624        }
625    }
626
627    #[test]
628    fn parse_url_not_ambiguous() {
629        for url in &[
630            // https://github.com/astral-sh/uv/issues/16756
631            "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
632            // https://github.com/astral-sh/uv/issues/17214
633            // Git proxy URLs with nested schemes should not trigger the ambiguity check
634            "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
635            "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
636            "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
637        ] {
638            DisplaySafeUrl::parse(url).unwrap();
639        }
640    }
641
642    #[test]
643    fn credential_like_pattern() {
644        assert!(!has_credential_like_pattern(
645            "/https://github.com/user/repo.git@branch"
646        ));
647        assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
648
649        assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
650        assert!(has_credential_like_pattern(":password@domain"));
651    }
652}