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