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