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