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`. The same holds for VCS URLs that use a
116        // file transport, such as `git+file://C:/Users/ferris/repo.git@v1.0`, which likewise carry
117        // no network credentials but can pair a drive-letter `:` with an `@` revision.
118        let scheme = url.scheme();
119        if scheme == "file" || scheme.ends_with("+file") {
120            return Ok(());
121        }
122
123        if url.password().is_some() {
124            return Ok(());
125        }
126
127        // Check for the suspicious pattern.
128        if !has_credential_like_pattern(url.path())
129            && !url.fragment().is_some_and(has_credential_like_pattern)
130        {
131            return Ok(());
132        }
133
134        // If the previous check passed, we should always expect to find these in the given URL.
135        let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
136            if cfg!(debug_assertions) {
137                unreachable!(
138                    "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
139                );
140            }
141            return Ok(());
142        };
143
144        // Our ambiguous URL probably has credentials in it, so we don't want to blast it out in
145        // the error message. We somewhat aggressively replace everything between the scheme's
146        // ':' and the lastmost `@` with `***`.
147        let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
148        Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path))
149    }
150
151    /// Create a new [`DisplaySafeUrl`] from a [`Url`].
152    ///
153    /// Unlike [`Self::parse`], this doesn't perform any ambiguity checks.
154    /// That means that it's primarily useful for contexts where a human can't easily accidentally
155    /// introduce an ambiguous URL, such as URLs being read from a request.
156    pub fn from_url(url: Url) -> Self {
157        Self(url)
158    }
159
160    /// Cast a `&Url` to a `&DisplaySafeUrl` using ref-cast.
161    #[inline]
162    pub fn ref_cast(url: &Url) -> &Self {
163        RefCast::ref_cast(url)
164    }
165
166    /// Parse a string as an URL, with this URL as the base URL.
167    #[inline]
168    pub fn join(&self, input: &str) -> Result<Self, DisplaySafeUrlError> {
169        Ok(Self(self.0.join(input)?))
170    }
171
172    /// Serialize with Serde using the internal representation of the `Url` struct.
173    #[inline]
174    pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
175    where
176        S: serde::Serializer,
177    {
178        self.0.serialize_internal(serializer)
179    }
180
181    /// Serialize with Serde using the internal representation of the `Url` struct.
182    #[inline]
183    pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
184    where
185        D: serde::Deserializer<'de>,
186    {
187        Ok(Self(Url::deserialize_internal(deserializer)?))
188    }
189
190    #[expect(clippy::result_unit_err)]
191    pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
192        Ok(Self(Url::from_file_path(path)?))
193    }
194
195    /// Remove the credentials from a URL, allowing the generic `git` username (without a password)
196    /// in SSH URLs, as in, `ssh://git@github.com/...`.
197    #[inline]
198    pub fn remove_credentials(&mut self) {
199        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
200        // username.
201        if is_ssh_git_username(&self.0) {
202            return;
203        }
204        let _ = self.0.set_username("");
205        let _ = self.0.set_password(None);
206    }
207
208    /// Returns the URL with any credentials removed.
209    pub fn without_credentials(&self) -> Cow<'_, Url> {
210        if self.0.password().is_none() && self.0.username() == "" {
211            return Cow::Borrowed(&self.0);
212        }
213
214        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
215        // username.
216        if is_ssh_git_username(&self.0) {
217            return Cow::Borrowed(&self.0);
218        }
219
220        let mut url = self.0.clone();
221        let _ = url.set_username("");
222        let _ = url.set_password(None);
223        Cow::Owned(url)
224    }
225
226    /// Returns [`Display`] implementation that doesn't mask credentials.
227    #[inline]
228    pub fn displayable_with_credentials(&self) -> impl Display {
229        &self.0
230    }
231
232    /// Redact all occurrences of this URL in a message.
233    ///
234    /// This is useful for errors from external tools, which may include the credentialed URL in
235    /// their command or output instead of using the URL's [`Display`] implementation.
236    pub fn redact_in(&self, message: &str) -> String {
237        message.replace(self.0.as_str(), &self.to_string())
238    }
239}
240
241impl Deref for DisplaySafeUrl {
242    type Target = Url;
243
244    fn deref(&self) -> &Self::Target {
245        &self.0
246    }
247}
248
249impl DerefMut for DisplaySafeUrl {
250    fn deref_mut(&mut self) -> &mut Self::Target {
251        &mut self.0
252    }
253}
254
255impl Display for DisplaySafeUrl {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        display_with_redacted_credentials(&self.0, f)
258    }
259}
260
261impl Debug for DisplaySafeUrl {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        let url = &self.0;
264        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid masking the
265        // username.
266        let (username, password) = if is_ssh_git_username(url) {
267            (url.username(), None)
268        } else if url.username() != "" && url.password().is_some() {
269            (url.username(), Some("****"))
270        } else if url.username() != "" {
271            ("****", None)
272        } else if url.password().is_some() {
273            ("", Some("****"))
274        } else {
275            ("", None)
276        };
277
278        f.debug_struct("DisplaySafeUrl")
279            .field("scheme", &url.scheme())
280            .field("cannot_be_a_base", &url.cannot_be_a_base())
281            .field("username", &username)
282            .field("password", &password)
283            .field("host", &url.host())
284            .field("port", &url.port())
285            .field("path", &url.path())
286            .field(
287                "query",
288                &url.query()
289                    .map(|query| redacted_query(query, url.query_pairs())),
290            )
291            .field("fragment", &url.fragment())
292            .finish()
293    }
294}
295
296impl From<DisplaySafeUrl> for Url {
297    fn from(url: DisplaySafeUrl) -> Self {
298        url.0
299    }
300}
301
302impl From<Url> for DisplaySafeUrl {
303    fn from(url: Url) -> Self {
304        Self(url)
305    }
306}
307
308impl FromStr for DisplaySafeUrl {
309    type Err = DisplaySafeUrlError;
310
311    fn from_str(input: &str) -> Result<Self, Self::Err> {
312        Self::parse(input)
313    }
314}
315
316fn is_ssh_git_username(url: &Url) -> bool {
317    matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
318        && url.username() == "git"
319        && url.password().is_none()
320}
321
322fn is_sensitive_query_parameter(key: &str) -> bool {
323    SENSITIVE_QUERY_PARAMETERS
324        .iter()
325        .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
326}
327
328fn redacted_query<'a>(
329    query: &'a str,
330    query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
331) -> Cow<'a, str> {
332    let mut redacted = false;
333    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
334    for (key, value) in query_pairs {
335        if is_sensitive_query_parameter(&key) {
336            serializer.append_pair(&key, "****");
337            redacted = true;
338        } else {
339            serializer.append_pair(&key, &value);
340        }
341    }
342
343    if redacted {
344        Cow::Owned(serializer.finish())
345    } else {
346        Cow::Borrowed(query)
347    }
348}
349
350fn display_with_redacted_credentials(
351    url: &Url,
352    f: &mut std::fmt::Formatter<'_>,
353) -> std::fmt::Result {
354    write!(f, "{}:", url.scheme())?;
355
356    if url.has_authority() {
357        write!(f, "//")?;
358
359        if url.username() != "" && url.password().is_some() {
360            write!(f, "{}", url.username())?;
361            write!(f, ":****@")?;
362        } else if url.username() != "" && is_ssh_git_username(url) {
363            write!(f, "{}@", url.username())?;
364        } else if url.username() != "" {
365            write!(f, "****@")?;
366        } else if url.password().is_some() {
367            write!(f, ":****@")?;
368        }
369
370        write!(f, "{}", url.host_str().unwrap_or(""))?;
371
372        if let Some(port) = url.port() {
373            write!(f, ":{port}")?;
374        }
375    }
376
377    write!(f, "{}", url.path())?;
378    if let Some(query) = url.query() {
379        write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
380    }
381    if let Some(fragment) = url.fragment() {
382        write!(f, "#{fragment}")?;
383    }
384
385    Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn from_url_no_credentials() {
394        let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
395        let log_safe_url =
396            DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
397        assert_eq!(log_safe_url.username(), "");
398        assert!(log_safe_url.password().is_none());
399        assert_eq!(log_safe_url.to_string(), url_str);
400    }
401
402    #[test]
403    fn from_url_username_and_password() {
404        let log_safe_url =
405            DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
406                .unwrap();
407        assert_eq!(log_safe_url.username(), "user");
408        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
409        assert_eq!(
410            log_safe_url.to_string(),
411            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
412        );
413    }
414
415    #[test]
416    fn from_url_just_password() {
417        let log_safe_url =
418            DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
419        assert_eq!(log_safe_url.username(), "");
420        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
421        assert_eq!(
422            log_safe_url.to_string(),
423            "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
424        );
425    }
426
427    #[test]
428    fn from_url_just_username() {
429        let log_safe_url =
430            DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
431        assert_eq!(log_safe_url.username(), "user");
432        assert!(log_safe_url.password().is_none());
433        assert_eq!(
434            log_safe_url.to_string(),
435            "https://****@pypi-proxy.fly.dev/basic-auth/simple"
436        );
437    }
438
439    #[test]
440    fn from_url_git_username() {
441        let ssh_str = "ssh://git@github.com/org/repo";
442        let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
443        assert_eq!(ssh_url.username(), "git");
444        assert!(ssh_url.password().is_none());
445        assert_eq!(ssh_url.to_string(), ssh_str);
446        // Test again for the `git+ssh` scheme
447        let git_ssh_str = "git+ssh://git@github.com/org/repo";
448        let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
449        assert_eq!(git_ssh_url.username(), "git");
450        assert!(git_ssh_url.password().is_none());
451        assert_eq!(git_ssh_url.to_string(), git_ssh_str);
452    }
453
454    #[test]
455    fn parse_url_string() {
456        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
457        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
458        assert_eq!(log_safe_url.username(), "user");
459        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
460        assert_eq!(
461            log_safe_url.to_string(),
462            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
463        );
464    }
465
466    #[test]
467    fn remove_credentials() {
468        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
469        let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
470        log_safe_url.remove_credentials();
471        assert_eq!(log_safe_url.username(), "");
472        assert!(log_safe_url.password().is_none());
473        assert_eq!(
474            log_safe_url.to_string(),
475            "https://pypi-proxy.fly.dev/basic-auth/simple"
476        );
477    }
478
479    #[test]
480    fn preserve_ssh_git_username_on_remove_credentials() {
481        let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
482        let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
483        ssh_url.remove_credentials();
484        assert_eq!(ssh_url.username(), "git");
485        assert!(ssh_url.password().is_none());
486        assert_eq!(ssh_url.to_string(), ssh_str);
487        // Test again for `git+ssh` scheme
488        let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
489        let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
490        git_shh_url.remove_credentials();
491        assert_eq!(git_shh_url.username(), "git");
492        assert!(git_shh_url.password().is_none());
493        assert_eq!(git_shh_url.to_string(), git_ssh_str);
494    }
495
496    #[test]
497    fn displayable_with_credentials() {
498        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
499        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
500        assert_eq!(
501            log_safe_url.displayable_with_credentials().to_string(),
502            url_str
503        );
504    }
505
506    #[test]
507    fn redact_url_in_message() {
508        let url = DisplaySafeUrl::parse("https://user:pass@example.com/org/repo.git").unwrap();
509        let message = format!(
510            "process didn't exit successfully: `git fetch '{}'`\n--- stderr\nfatal: Authentication failed for '{}'",
511            url.as_str(),
512            url.as_str()
513        );
514
515        assert_eq!(
516            url.redact_in(&message),
517            "process didn't exit successfully: `git fetch 'https://user:****@example.com/org/repo.git'`\n--- stderr\nfatal: Authentication failed for 'https://user:****@example.com/org/repo.git'"
518        );
519    }
520
521    #[test]
522    fn redact_presigned_url_in_message() {
523        let url = DisplaySafeUrl::parse(
524            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&X-Amz-Credential=credential&X-Amz-Security-Token=token&safe=value",
525        )
526        .unwrap();
527        let message = format!("failed to fetch '{}'", url.as_str());
528
529        assert_eq!(
530            url.redact_in(&message),
531            "failed to fetch 'https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&X-Amz-Credential=****&X-Amz-Security-Token=****&safe=value'"
532        );
533    }
534
535    #[test]
536    fn redact_aws_presigned_query_values() {
537        let log_safe_url = DisplaySafeUrl::parse(
538            "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",
539        )
540        .unwrap();
541
542        assert_eq!(
543            log_safe_url.to_string(),
544            "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=****"
545        );
546    }
547
548    #[test]
549    fn redact_aws_presigned_query_values_case_insensitive() {
550        let log_safe_url = DisplaySafeUrl::parse(
551            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
552        )
553        .unwrap();
554
555        assert_eq!(
556            log_safe_url.to_string(),
557            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
558        );
559    }
560
561    #[test]
562    fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
563        let log_safe_url = DisplaySafeUrl::parse(
564            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
565        )
566        .unwrap();
567
568        assert_eq!(
569            log_safe_url.to_string(),
570            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
571        );
572    }
573
574    #[test]
575    fn redact_aws_presigned_query_values_in_debug() {
576        let log_safe_url = DisplaySafeUrl::parse(
577            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
578        )
579        .unwrap();
580
581        let debug = format!("{log_safe_url:?}");
582        assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
583        assert!(!debug.contains("credential"));
584        assert!(!debug.contains("signature"));
585    }
586
587    #[test]
588    fn does_not_redact_unknown_query_values() {
589        let log_safe_url =
590            DisplaySafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
591
592        assert_eq!(
593            log_safe_url.to_string(),
594            "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
595        );
596    }
597
598    #[test]
599    fn does_not_add_authority_to_urls_without_authority() {
600        let log_safe_url = DisplaySafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
601
602        assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
603    }
604
605    #[test]
606    fn redacts_query_values_in_urls_without_authority() {
607        let log_safe_url =
608            DisplaySafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
609
610        assert_eq!(
611            log_safe_url.to_string(),
612            "c:/home/ferris/projects/foo?X-Amz-Signature=****"
613        );
614    }
615
616    #[test]
617    fn redacts_query_values_in_cannot_be_a_base_urls() {
618        let log_safe_url =
619            DisplaySafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
620
621        assert!(log_safe_url.cannot_be_a_base());
622        assert_eq!(
623            log_safe_url.to_string(),
624            "mailto:ferris@example.com?X-Amz-Signature=****"
625        );
626    }
627
628    #[test]
629    fn url_join() {
630        let url_str = "https://token@example.com/abc/";
631        let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
632        let foo_url = log_safe_url.join("foo").unwrap();
633        assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
634    }
635
636    #[test]
637    fn log_safe_url_ref() {
638        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
639        let url = DisplaySafeUrl::parse(url_str).unwrap();
640        let log_safe_url = DisplaySafeUrl::ref_cast(&url);
641        assert_eq!(log_safe_url.username(), "user");
642        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
643        assert_eq!(
644            log_safe_url.to_string(),
645            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
646        );
647    }
648
649    #[test]
650    fn parse_url_ambiguous() {
651        for url in &[
652            "https://user/name:password@domain/a/b/c",
653            "https://user\\name:password@domain/a/b/c",
654            "https://user#name:password@domain/a/b/c",
655            "https://user.com/name:password@domain/a/b/c",
656        ] {
657            let err = DisplaySafeUrl::parse(url).unwrap_err();
658            match err {
659                DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
660                    assert!(redacted.starts_with("https:***@domain/a/b/c"));
661                }
662                DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
663            }
664        }
665    }
666
667    #[test]
668    fn parse_url_not_ambiguous() {
669        for url in &[
670            // https://github.com/astral-sh/uv/issues/16756
671            "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
672            // https://github.com/astral-sh/uv/issues/17214
673            // Git proxy URLs with nested schemes should not trigger the ambiguity check
674            "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
675            "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
676            "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
677            // https://github.com/astral-sh/uv/issues/19887
678            // Windows `git+file://` URLs pair a drive-letter `:` with an `@` revision, but use a
679            // file transport and so carry no credentials.
680            "git+file:///C:/Users/ferris/repo.git@v1.0",
681            "git+file:///C:/Users/ferris/repo.git@10c049896212932ad5f7b19456d90bc604eeca53",
682            "hg+file:///C:/Users/ferris/repo@default",
683        ] {
684            DisplaySafeUrl::parse(url).unwrap();
685        }
686    }
687
688    #[test]
689    fn credential_like_pattern() {
690        assert!(!has_credential_like_pattern(
691            "/https://github.com/user/repo.git@branch"
692        ));
693        assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
694
695        assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
696        assert!(has_credential_like_pattern(":password@domain"));
697    }
698}