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