Skip to main content

uv_redacted/
lib.rs

1#![expect(
2    clippy::disallowed_types,
3    reason = "`SafeUrl` wraps `url::Url` and exposes conversions for external APIs"
4)]
5
6use ref_cast::RefCast;
7use serde::Deserialize;
8use std::borrow::Cow;
9use std::fmt::{Debug, Display};
10use std::marker::PhantomData;
11use std::path::PathBuf;
12use std::str::FromStr;
13use thiserror::Error;
14use url::Url;
15
16pub mod policy;
17
18use policy::{InternalSerializationPolicy, Raw, SerializationPolicy, Unserializable};
19
20const SENSITIVE_QUERY_PARAMETERS: &[&str] = &[
21    "sig",
22    "X-Amz-Credential",
23    "X-Amz-Security-Token",
24    "X-Amz-Signature",
25    "sig",
26];
27
28#[derive(Error, Debug, Clone, PartialEq, Eq)]
29pub enum SafeUrlError {
30    /// Failed to parse a URL.
31    #[error(transparent)]
32    Url(#[from] url::ParseError),
33
34    /// We parsed a URL, but couldn't disambiguate its authority
35    /// component.
36    #[error("ambiguous user/pass authority in URL (not percent-encoded?): {0}")]
37    AmbiguousAuthority(String),
38}
39
40/// A URL with redacted display output and a type-level serialization policy.
41///
42/// The complete parsed URL, including credentials, remains available in memory.
43/// [`Display`] and [`Debug`] redact credentials and recognized sensitive query
44/// parameters for every policy. Policies affect serialization, not parsing or
45/// the URL's contents.
46///
47/// A reference cannot implicitly coerce to `&Url`:
48///
49/// ```compile_fail
50/// use uv_redacted::SafeUrl;
51///
52/// let url = SafeUrl::parse("https://example.com")?;
53/// let _: &url::Url = &url;
54/// # Ok::<(), Box<dyn std::error::Error>>(())
55/// ```
56///
57/// A mutable reference cannot implicitly coerce to `&mut Url`:
58///
59/// ```compile_fail
60/// use uv_redacted::SafeUrl;
61///
62/// let mut url = SafeUrl::parse("https://example.com")?;
63/// let _: &mut url::Url = &mut url;
64/// # Ok::<(), Box<dyn std::error::Error>>(())
65/// ```
66///
67/// A [`SafeUrl`] with the default [`Unserializable`] policy does not implement
68/// [`serde::Serialize`].
69/// Choose [`policy::Stripped`] or [`policy::Raw`] explicitly
70/// when a field needs to be serialized:
71///
72/// ```
73/// use uv_redacted::{SafeUrl, policy::Stripped};
74///
75/// #[derive(serde::Serialize)]
76/// struct Origin {
77///     url: SafeUrl<Stripped>,
78/// }
79///
80/// let url = SafeUrl::parse("https://user:password@example.com/file.whl?sig=signature")?;
81/// let origin = Origin { url: url.with_policy(Stripped) };
82/// assert_eq!(origin.url.password(), Some("password"));
83/// assert_eq!(serde_json::to_string(&origin)?,
84///     r#"{"url":"https://example.com/file.whl?sig=signature"}"#);
85/// # Ok::<(), Box<dyn std::error::Error>>(())
86/// ```
87///
88/// Fields with the default policy cannot derive [`serde::Serialize`] without a custom serializer:
89///
90/// ```compile_fail
91/// use uv_redacted::SafeUrl;
92///
93/// #[derive(serde::Serialize)]
94/// struct Origin {
95///     url: SafeUrl,
96/// }
97/// ```
98///
99/// `serialize` method calls also require a serializing policy:
100///
101/// ```compile_fail
102/// use serde::Serialize;
103/// use uv_redacted::SafeUrl;
104///
105/// let url = SafeUrl::parse("https://user:password@example.com")?;
106/// url.serialize(serde_json::value::Serializer)?;
107/// # Ok::<(), Box<dyn std::error::Error>>(())
108/// ```
109///
110/// Returning to the default policy removes the ordinary [`serde::Serialize`] implementation:
111///
112/// ```compile_fail
113/// use uv_redacted::{SafeUrl, policy::{Unserializable, Raw}};
114///
115/// let url = SafeUrl::parse("https://example.com")?
116///     .with_policy(Raw)
117///     .with_policy(Unserializable);
118/// serde_json::to_string(&url)?;
119/// # Ok::<(), Box<dyn std::error::Error>>(())
120/// ```
121#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Hash, RefCast)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123#[cfg_attr(
124    feature = "schemars",
125    schemars(transparent, bound = "", description = "A URL.")
126)]
127#[repr(transparent)]
128pub struct SafeUrl<P: SerializationPolicy = Unserializable>(Url, PhantomData<P>);
129
130/// A guard for editing a [`SafeUrl`]'s path segments.
131///
132/// The underlying guard contains the complete URL, so this wrapper does not expose its debug output.
133pub struct PathSegmentsMut<'a>(url::PathSegmentsMut<'a>);
134
135impl PathSegmentsMut<'_> {
136    /// Remove all path segments, leaving the leading slash.
137    pub fn clear(&mut self) -> &mut Self {
138        self.0.clear();
139        self
140    }
141
142    /// Remove the last path segment if it is empty.
143    pub fn pop_if_empty(&mut self) -> &mut Self {
144        self.0.pop_if_empty();
145        self
146    }
147
148    /// Remove the last path segment.
149    pub fn pop(&mut self) -> &mut Self {
150        self.0.pop();
151        self
152    }
153
154    /// Append a path segment, percent-encoding it as needed.
155    pub fn push(&mut self, segment: &str) -> &mut Self {
156        self.0.push(segment);
157        self
158    }
159
160    /// Append path segments, percent-encoding them as needed.
161    pub fn extend<I>(&mut self, segments: I) -> &mut Self
162    where
163        I: IntoIterator,
164        I::Item: AsRef<str>,
165    {
166        self.0.extend(segments);
167        self
168    }
169}
170
171/// A guard for appending query pairs to a [`SafeUrl`].
172pub struct QueryPairsMut<'a>(url::form_urlencoded::Serializer<'a, url::UrlQuery<'a>>);
173
174impl QueryPairsMut<'_> {
175    /// Append a name/value pair using form URL encoding.
176    pub fn append_pair(&mut self, name: &str, value: &str) -> &mut Self {
177        self.0.append_pair(name, value);
178        self
179    }
180}
181
182/// Check if a path or fragment contains a credential-like pattern (`:` followed by `@`).
183///
184/// This skips colons that are followed by `//`, as those indicate URL schemes (e.g., `https://`)
185/// rather than credentials. This is important for handling nested URLs like proxy URLs:
186/// `git+https://proxy.com/https://github.com/user/repo.git@branch`.
187fn has_credential_like_pattern(s: &str) -> bool {
188    let mut remaining = s;
189    while let Some(colon_pos) = remaining.find(':') {
190        let after_colon = &remaining[colon_pos + 1..];
191        // If the colon is followed by "//", consider it a URL scheme.
192        if after_colon.starts_with("//") {
193            remaining = after_colon;
194            continue;
195        }
196        // Check if there's an @ after this colon.
197        if after_colon.contains('@') {
198            return true;
199        }
200        remaining = after_colon;
201    }
202    false
203}
204
205impl SafeUrl {
206    #[inline]
207    pub fn parse(input: &str) -> Result<Self, SafeUrlError> {
208        let url = Self::parse_relaxed(input)?;
209
210        Self::reject_ambiguous_credentials(input, &url.0)?;
211
212        Ok(url)
213    }
214
215    /// Parse a URL using [`Url::parse`] without the ambiguous-credential check.
216    ///
217    /// Unlike [`Self::parse`], this accepts ambiguous inputs whose path or fragment
218    /// may contain credentials. Those parts are not redacted when formatted.
219    #[inline]
220    pub fn parse_relaxed(input: &str) -> Result<Self, url::ParseError> {
221        Url::parse(input).map(Self::from_url)
222    }
223
224    /// Reject some ambiguous cases, e.g., `https://user/name:password@domain/a/b/c`
225    ///
226    /// In this case the user *probably* meant to have a username of "user/name", but both RFC
227    /// 3986 and WHATWG URL expect the userinfo (RFC 3986) or authority (WHATWG) to not contain a
228    /// non-percent-encoded slash or other special character.
229    ///
230    /// This ends up being moderately annoying to detect, since the above gets parsed into a
231    /// "valid" WHATWG URL where the host is `used` and the pathname is
232    /// `/name:password@domain/a/b/c` rather than causing a parse error.
233    ///
234    /// To detect it, we use a heuristic: if the password component is missing but the path or
235    /// fragment contain a `:` followed by a `@`, then we assume the URL is ambiguous.
236    fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), SafeUrlError> {
237        // `git://`, `http://`, and `https://` URLs may carry credentials, while `file://` URLs
238        // on Windows may contain both sigils, but it's always safe, e.g.
239        // `file://C:/Users/ferris/project@home/workspace`. The same holds for VCS URLs that use a
240        // file transport, such as `git+file://C:/Users/ferris/repo.git@v1.0`, which likewise carry
241        // no network credentials but can pair a drive-letter `:` with an `@` revision.
242        let scheme = url.scheme();
243        if scheme == "file" || scheme.ends_with("+file") {
244            return Ok(());
245        }
246
247        if url.password().is_some() {
248            return Ok(());
249        }
250
251        // Check for the suspicious pattern.
252        if !has_credential_like_pattern(url.path())
253            && !url.fragment().is_some_and(has_credential_like_pattern)
254        {
255            return Ok(());
256        }
257
258        // If the previous check passed, we should always expect to find these in the given URL.
259        let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
260            if cfg!(debug_assertions) {
261                unreachable!(
262                    "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
263                );
264            }
265            return Ok(());
266        };
267
268        // Our ambiguous URL probably has credentials in it, so we don't want to blast it out in
269        // the error message. We somewhat aggressively replace everything between the scheme's
270        // ':' and the lastmost `@` with `***`.
271        let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
272        Err(SafeUrlError::AmbiguousAuthority(redacted_path))
273    }
274
275    /// Create a new [`SafeUrl`] from a [`Url`].
276    ///
277    /// Unlike [`Self::parse`], this doesn't perform any ambiguity checks.
278    /// That means that it's primarily useful for contexts where a human can't easily accidentally
279    /// introduce an ambiguous URL, such as URLs being read from a request.
280    pub fn from_url(url: Url) -> Self {
281        Self(url, PhantomData)
282    }
283
284    /// Borrow a [`Url`] with the default [`Unserializable`] policy using ref-cast.
285    #[inline]
286    pub fn ref_cast(url: &Url) -> &Self {
287        RefCast::ref_cast(url)
288    }
289
290    #[expect(clippy::result_unit_err)]
291    pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
292        Ok(Self(Url::from_file_path(path)?, PhantomData))
293    }
294
295    /// Convert an absolute directory path to a file URL with a trailing slash.
296    #[expect(clippy::result_unit_err)]
297    pub fn from_directory_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
298        Ok(Self(Url::from_directory_path(path)?, PhantomData))
299    }
300}
301
302impl<P: SerializationPolicy> SafeUrl<P> {
303    /// Change the serialization policy without modifying the parsed URL.
304    #[must_use]
305    pub fn with_policy<Q: SerializationPolicy>(self, _policy: Q) -> SafeUrl<Q> {
306        SafeUrl(self.0, PhantomData)
307    }
308
309    /// Borrow the parsed URL with a different serialization policy.
310    #[must_use]
311    fn with_policy_ref<Q: SerializationPolicy>(&self, _policy: Q) -> &SafeUrl<Q> {
312        RefCast::ref_cast(&self.0)
313    }
314
315    /// Borrow the URL with the default [`Unserializable`] policy.
316    pub fn as_url(&self) -> &SafeUrl {
317        self.with_policy_ref(Unserializable)
318    }
319
320    /// Return the URL with the default [`Unserializable`] policy.
321    pub fn into_url(self) -> SafeUrl {
322        self.with_policy(Unserializable)
323    }
324
325    /// Return the complete parsed URL for APIs that require [`Url`].
326    ///
327    /// The returned URL has no display redaction or serialization policy.
328    /// Use this method to pass an owned [`Url`] to an API that requires it.
329    /// Conversion using [`From`] is not supported:
330    ///
331    /// ```compile_fail
332    /// use uv_redacted::SafeUrl;
333    ///
334    /// let url = SafeUrl::parse("https://example.com")?;
335    /// let _: url::Url = url.into();
336    /// # Ok::<(), Box<dyn std::error::Error>>(())
337    /// ```
338    pub fn into_unsafe_url(self) -> Url {
339        self.0
340    }
341
342    /// Return the complete URL string, including credentials.
343    pub fn as_str(&self) -> &str {
344        self.0.as_str()
345    }
346
347    /// Return the scheme, without the trailing colon.
348    pub fn scheme(&self) -> &str {
349        self.0.scheme()
350    }
351
352    /// Return the authority, including any credentials and port.
353    pub fn authority(&self) -> &str {
354        self.0.authority()
355    }
356
357    /// Return whether the URL has an opaque path instead of path segments.
358    pub fn cannot_be_a_base(&self) -> bool {
359        self.0.cannot_be_a_base()
360    }
361
362    /// Return the percent-encoded username.
363    pub fn username(&self) -> &str {
364        self.0.username()
365    }
366
367    /// Return the percent-encoded password, if present.
368    pub fn password(&self) -> Option<&str> {
369        self.0.password()
370    }
371
372    /// Return whether the URL has a host.
373    pub fn has_host(&self) -> bool {
374        self.0.has_host()
375    }
376
377    /// Return the host as a string, if present.
378    pub fn host_str(&self) -> Option<&str> {
379        self.0.host_str()
380    }
381
382    /// Return the parsed host, if present.
383    pub fn host(&self) -> Option<url::Host<&str>> {
384        self.0.host()
385    }
386
387    /// Return the explicit non-default port, if present.
388    pub fn port(&self) -> Option<u16> {
389        self.0.port()
390    }
391
392    /// Return the explicit port or the scheme's known default.
393    pub fn port_or_known_default(&self) -> Option<u16> {
394        self.0.port_or_known_default()
395    }
396
397    /// Return the percent-encoded path.
398    pub fn path(&self) -> &str {
399        self.0.path()
400    }
401
402    /// Return the percent-encoded path segments, unless the path is opaque.
403    pub fn path_segments(&self) -> Option<std::str::Split<'_, char>> {
404        self.0.path_segments()
405    }
406
407    /// Return the decoded query name/value pairs.
408    pub fn query_pairs(&self) -> url::form_urlencoded::Parse<'_> {
409        self.0.query_pairs()
410    }
411
412    /// Return the fragment, without the leading hash, if present.
413    pub fn fragment(&self) -> Option<&str> {
414        self.0.fragment()
415    }
416
417    /// Set or remove the fragment, using [`Url::set_fragment`] encoding rules.
418    pub fn set_fragment(&mut self, fragment: Option<&str>) {
419        self.0.set_fragment(fragment);
420    }
421
422    /// Set or remove the query, using [`Url::set_query`] encoding rules.
423    pub fn set_query(&mut self, query: Option<&str>) {
424        self.0.set_query(query);
425    }
426
427    /// Set the path, using [`Url::set_path`] encoding rules.
428    pub fn set_path(&mut self, path: &str) {
429        self.0.set_path(path);
430    }
431
432    /// Set or remove the password, if the URL supports credentials.
433    #[expect(clippy::result_unit_err)]
434    pub fn set_password(&mut self, password: Option<&str>) -> Result<(), ()> {
435        self.0.set_password(password)
436    }
437
438    /// Set the username, if the URL supports credentials.
439    #[expect(clippy::result_unit_err)]
440    pub fn set_username(&mut self, username: &str) -> Result<(), ()> {
441        self.0.set_username(username)
442    }
443
444    /// Convert a file URL to a filesystem path.
445    #[expect(clippy::result_unit_err)]
446    pub fn to_file_path(&self) -> Result<PathBuf, ()> {
447        self.0.to_file_path()
448    }
449
450    /// Edit the path segments without exposing the underlying URL.
451    #[expect(clippy::result_unit_err)]
452    pub fn path_segments_mut(&mut self) -> Result<PathSegmentsMut<'_>, ()> {
453        self.0.path_segments_mut().map(PathSegmentsMut)
454    }
455
456    /// Append query pairs with form URL encoding.
457    ///
458    /// The guard restores the fragment when dropped. It cannot return a raw URL:
459    ///
460    /// ```compile_fail
461    /// use uv_redacted::SafeUrl;
462    ///
463    /// let mut url = SafeUrl::parse("https://example.com")?;
464    /// let _: &mut url::Url = url.query_pairs_mut().finish();
465    /// # Ok::<(), Box<dyn std::error::Error>>(())
466    /// ```
467    pub fn query_pairs_mut(&mut self) -> QueryPairsMut<'_> {
468        QueryPairsMut(self.0.query_pairs_mut())
469    }
470
471    /// Parse a string as an URL, with this URL as the base URL.
472    #[inline]
473    pub fn join(&self, input: &str) -> Result<Self, SafeUrlError> {
474        Ok(Self(self.0.join(input)?, PhantomData))
475    }
476
477    /// Remove the credentials from a URL, allowing the generic `git` username (without a password)
478    /// in SSH URLs, as in, `ssh://git@github.com/...`.
479    #[inline]
480    pub fn remove_credentials(&mut self) {
481        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
482        // username.
483        if is_ssh_git_username(&self.0) {
484            return;
485        }
486        let _ = self.0.set_username("");
487        let _ = self.0.set_password(None);
488    }
489
490    /// Return the URL with sensitive userinfo removed, retaining its serialization policy.
491    ///
492    /// The complete query is retained; display and debug formatting redact recognized credentials.
493    /// Removing userinfo does not make an [`Unserializable`] URL serializable:
494    ///
495    /// ```compile_fail
496    /// use uv_redacted::SafeUrl;
497    ///
498    /// let url = SafeUrl::parse("https://user:password@example.com/file.whl")?;
499    /// serde_json::to_string(&url.without_credentials())?;
500    /// # Ok::<(), Box<dyn std::error::Error>>(())
501    /// ```
502    pub fn without_credentials(&self) -> Cow<'_, Self> {
503        if self.0.password().is_none() && self.0.username() == "" {
504            return Cow::Borrowed(self);
505        }
506
507        // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
508        // username.
509        if is_ssh_git_username(&self.0) {
510            return Cow::Borrowed(self);
511        }
512
513        let mut url = self.clone();
514        url.remove_credentials();
515        Cow::Owned(url)
516    }
517
518    /// Returns [`Display`] implementation that doesn't mask credentials.
519    #[inline]
520    pub fn displayable_with_credentials(&self) -> impl Display {
521        &self.0
522    }
523
524    /// Redact all occurrences of this URL in a message.
525    ///
526    /// This is useful for errors from external tools, which may include the credentialed URL in
527    /// their command or output instead of using the URL's [`Display`] implementation.
528    pub fn redact_in(&self, message: &str) -> String {
529        message.replace(self.0.as_str(), &self.to_string())
530    }
531
532    /// Serialize the complete URL using the internal representation of the `Url` struct.
533    ///
534    /// A field using this serializer must declare the [`Raw`] policy:
535    ///
536    /// ```compile_fail
537    /// use uv_redacted::{SafeUrl, policy::Raw};
538    ///
539    /// #[derive(serde::Serialize)]
540    /// struct CachedUrl {
541    ///     #[serde(serialize_with = "SafeUrl::<Raw>::serialize_internal")]
542    ///     url: SafeUrl,
543    /// }
544    /// ```
545    ///
546    /// `serialize_internal` method calls require [`Raw`] as well:
547    ///
548    /// ```compile_fail
549    /// use uv_redacted::SafeUrl;
550    ///
551    /// let url = SafeUrl::parse("https://user:password@example.com")?;
552    /// url.serialize_internal(serde_json::value::Serializer)?;
553    /// # Ok::<(), Box<dyn std::error::Error>>(())
554    /// ```
555    ///
556    /// ```compile_fail
557    /// use uv_redacted::{SafeUrl, policy::Stripped};
558    ///
559    /// let url = SafeUrl::parse("https://user:password@example.com")?
560    ///     .with_policy(Stripped);
561    /// url.serialize_internal(serde_json::value::Serializer)?;
562    /// # Ok::<(), Box<dyn std::error::Error>>(())
563    /// ```
564    #[inline]
565    pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
566    where
567        P: InternalSerializationPolicy,
568        S: serde::Serializer,
569    {
570        self.0.serialize_internal(serializer)
571    }
572}
573
574impl SafeUrl<Raw> {
575    /// Deserialize the internal representation of the `Url` struct.
576    #[inline]
577    pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
578    where
579        D: serde::Deserializer<'de>,
580    {
581        Ok(Self(Url::deserialize_internal(deserializer)?, PhantomData))
582    }
583}
584
585impl<P: SerializationPolicy> Display for SafeUrl<P> {
586    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
587        display_with_redacted_credentials(&self.0, f)
588    }
589}
590
591impl<P: SerializationPolicy> Debug for SafeUrl<P> {
592    #[inline(never)]
593    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594        let url = &self.0;
595        let (username, password) = redacted_credentials(url);
596
597        f.debug_struct("SafeUrl")
598            .field("scheme", &url.scheme())
599            .field("cannot_be_a_base", &url.cannot_be_a_base())
600            .field("username", &username)
601            .field("password", &password)
602            .field("host", &url.host())
603            .field("port", &url.port())
604            .field("path", &url.path())
605            .field(
606                "query",
607                &url.query()
608                    .map(|query| redacted_query(query, url.query_pairs())),
609            )
610            .field("fragment", &url.fragment())
611            .finish()
612    }
613}
614
615impl From<Url> for SafeUrl {
616    fn from(url: Url) -> Self {
617        Self(url, PhantomData)
618    }
619}
620
621impl FromStr for SafeUrl {
622    type Err = SafeUrlError;
623
624    fn from_str(input: &str) -> Result<Self, Self::Err> {
625        Self::parse(input)
626    }
627}
628
629impl<'de, P: SerializationPolicy> Deserialize<'de> for SafeUrl<P> {
630    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
631    where
632        D: serde::Deserializer<'de>,
633    {
634        // Policies do not select parsing rules. Use `SafeUrl::parse` to check human input.
635        Url::deserialize(deserializer).map(|url| Self(url, PhantomData))
636    }
637}
638
639fn is_ssh_git_username(url: &Url) -> bool {
640    matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
641        && url.username() == "git"
642        && url.password().is_none()
643}
644
645/// Returns the URL's username and password with sensitive values redacted for display.
646fn redacted_credentials(url: &Url) -> (&str, Option<&str>) {
647    match (url.username(), url.password()) {
648        (username, Some(_)) => (username, Some("****")),
649        ("", None) => ("", None),
650        // The generic Git username is not sensitive.
651        (username, None) if is_ssh_git_username(url) => (username, None),
652        // Other standalone usernames may be tokens.
653        (_, None) => ("****", None),
654    }
655}
656
657fn is_sensitive_query_parameter(key: &str) -> bool {
658    SENSITIVE_QUERY_PARAMETERS
659        .iter()
660        .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
661}
662
663fn redacted_query<'a>(
664    query: &'a str,
665    query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
666) -> Cow<'a, str> {
667    let mut redacted = false;
668    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
669    for (key, value) in query_pairs {
670        if is_sensitive_query_parameter(&key) {
671            serializer.append_pair(&key, "****");
672            redacted = true;
673        } else {
674            serializer.append_pair(&key, &value);
675        }
676    }
677
678    if redacted {
679        Cow::Owned(serializer.finish())
680    } else {
681        Cow::Borrowed(query)
682    }
683}
684
685fn display_with_redacted_credentials(
686    url: &Url,
687    f: &mut std::fmt::Formatter<'_>,
688) -> std::fmt::Result {
689    write!(f, "{}:", url.scheme())?;
690
691    if url.has_authority() {
692        write!(f, "//")?;
693
694        match redacted_credentials(url) {
695            ("", None) => {}
696            (username, Some(password)) => write!(f, "{username}:{password}@")?,
697            (username, None) => write!(f, "{username}@")?,
698        }
699
700        write!(f, "{}", url.host_str().unwrap_or(""))?;
701
702        if let Some(port) = url.port() {
703            write!(f, ":{port}")?;
704        }
705    }
706
707    write!(f, "{}", url.path())?;
708    if let Some(query) = url.query() {
709        write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
710    }
711    if let Some(fragment) = url.fragment() {
712        write!(f, "#{fragment}")?;
713    }
714
715    Ok(())
716}
717
718#[cfg(test)]
719mod tests {
720    use indoc::{formatdoc, indoc};
721    use insta::assert_debug_snapshot;
722
723    use super::*;
724
725    #[test]
726    fn parse_relaxed() -> Result<(), url::ParseError> {
727        let input = "https://user/name:password@example.com/project";
728        let url = SafeUrl::parse_relaxed(input)?;
729        assert_eq!(url.host_str(), Some("user"));
730        assert_eq!(url.path(), "/name:password@example.com/project");
731        insta::assert_snapshot!(SafeUrl::parse(input).unwrap_err(), @"ambiguous user/pass authority in URL (not percent-encoded?): https:***@example.com/project");
732        assert_eq!(
733            SafeUrl::parse_relaxed("https://").unwrap_err(),
734            url::ParseError::EmptyHost
735        );
736        Ok(())
737    }
738
739    #[test]
740    fn from_url_no_credentials() {
741        let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
742        let log_safe_url = SafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
743        assert_eq!(log_safe_url.username(), "");
744        assert!(log_safe_url.password().is_none());
745        assert_eq!(log_safe_url.to_string(), url_str);
746    }
747
748    #[test]
749    fn from_url_username_and_password() {
750        let log_safe_url =
751            SafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
752        assert_eq!(log_safe_url.username(), "user");
753        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
754        assert_eq!(
755            log_safe_url.to_string(),
756            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
757        );
758    }
759
760    #[test]
761    fn from_url_just_password() {
762        let log_safe_url =
763            SafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
764        assert_eq!(log_safe_url.username(), "");
765        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
766        assert_eq!(
767            log_safe_url.to_string(),
768            "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
769        );
770    }
771
772    #[test]
773    fn from_url_just_username() {
774        let log_safe_url =
775            SafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
776        assert_eq!(log_safe_url.username(), "user");
777        assert!(log_safe_url.password().is_none());
778        assert_eq!(
779            log_safe_url.to_string(),
780            "https://****@pypi-proxy.fly.dev/basic-auth/simple"
781        );
782    }
783
784    #[test]
785    fn from_url_git_username() {
786        let ssh_str = "ssh://git@github.com/org/repo";
787        let ssh_url = SafeUrl::parse(ssh_str).unwrap();
788        assert_eq!(ssh_url.username(), "git");
789        assert!(ssh_url.password().is_none());
790        assert_eq!(ssh_url.to_string(), ssh_str);
791        // Test again for the `git+ssh` scheme
792        let git_ssh_str = "git+ssh://git@github.com/org/repo";
793        let git_ssh_url = SafeUrl::parse(git_ssh_str).unwrap();
794        assert_eq!(git_ssh_url.username(), "git");
795        assert!(git_ssh_url.password().is_none());
796        assert_eq!(git_ssh_url.to_string(), git_ssh_str);
797    }
798
799    #[test]
800    fn parse_url_string() {
801        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
802        let log_safe_url = SafeUrl::parse(url_str).unwrap();
803        assert_eq!(log_safe_url.username(), "user");
804        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
805        assert_eq!(
806            log_safe_url.to_string(),
807            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
808        );
809    }
810
811    #[test]
812    fn remove_credentials() {
813        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
814        let mut log_safe_url = SafeUrl::parse(url_str).unwrap();
815        log_safe_url.remove_credentials();
816        assert_eq!(log_safe_url.username(), "");
817        assert!(log_safe_url.password().is_none());
818        assert_eq!(
819            log_safe_url.to_string(),
820            "https://pypi-proxy.fly.dev/basic-auth/simple"
821        );
822    }
823
824    #[test]
825    fn preserve_ssh_git_username_on_remove_credentials() {
826        let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
827        let mut ssh_url = SafeUrl::parse(ssh_str).unwrap();
828        ssh_url.remove_credentials();
829        assert_eq!(ssh_url.username(), "git");
830        assert!(ssh_url.password().is_none());
831        assert_eq!(ssh_url.to_string(), ssh_str);
832        // Test again for `git+ssh` scheme
833        let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
834        let mut git_shh_url = SafeUrl::parse(git_ssh_str).unwrap();
835        git_shh_url.remove_credentials();
836        assert_eq!(git_shh_url.username(), "git");
837        assert!(git_shh_url.password().is_none());
838        assert_eq!(git_shh_url.to_string(), git_ssh_str);
839    }
840
841    #[test]
842    fn displayable_with_credentials() {
843        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
844        let log_safe_url = SafeUrl::parse(url_str).unwrap();
845        assert_eq!(
846            log_safe_url.displayable_with_credentials().to_string(),
847            url_str
848        );
849    }
850
851    #[test]
852    fn redact_url_in_message() {
853        let url = SafeUrl::parse("https://user:pass@example.com/org/repo.git").unwrap();
854        let message = formatdoc! {"
855            process didn't exit successfully: `git fetch '{}'`
856            --- stderr
857            fatal: Authentication failed for '{}'
858        ",
859            url.as_str(),
860            url.as_str()
861        };
862
863        assert_eq!(
864            url.redact_in(&message),
865            indoc! {"
866                process didn't exit successfully: `git fetch 'https://user:****@example.com/org/repo.git'`
867                --- stderr
868                fatal: Authentication failed for 'https://user:****@example.com/org/repo.git'
869            "}
870        );
871    }
872
873    #[test]
874    fn redact_presigned_url_in_message() {
875        let url = SafeUrl::parse(
876            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&X-Amz-Credential=credential&X-Amz-Security-Token=token&safe=value",
877        )
878        .unwrap();
879        let message = format!("failed to fetch '{}'", url.as_str());
880
881        assert_eq!(
882            url.redact_in(&message),
883            "failed to fetch 'https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&X-Amz-Credential=****&X-Amz-Security-Token=****&safe=value'"
884        );
885    }
886
887    #[test]
888    fn redact_aws_presigned_query_values() {
889        let log_safe_url = SafeUrl::parse(
890            "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",
891        )
892        .unwrap();
893
894        assert_eq!(
895            log_safe_url.to_string(),
896            "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=****"
897        );
898    }
899
900    #[test]
901    fn redact_azure_shared_access_signature() -> Result<(), SafeUrlError> {
902        let url = SafeUrl::parse(
903            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r",
904        )?;
905        assert_eq!(
906            url.to_string(),
907            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r"
908        );
909        assert_eq!(
910            url.redact_in(&format!("failed to fetch '{}'", url.as_str())),
911            "failed to fetch 'https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r'"
912        );
913        // Formatting must not alter the signature used in actual requests.
914        assert_eq!(
915            url.as_str(),
916            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r"
917        );
918        Ok(())
919    }
920
921    #[test]
922    fn redact_aws_presigned_query_values_case_insensitive() {
923        let log_safe_url = SafeUrl::parse(
924            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
925        )
926        .unwrap();
927
928        assert_eq!(
929            log_safe_url.to_string(),
930            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
931        );
932    }
933
934    #[test]
935    fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
936        let log_safe_url = SafeUrl::parse(
937            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
938        )
939        .unwrap();
940
941        assert_eq!(
942            log_safe_url.to_string(),
943            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
944        );
945    }
946
947    #[test]
948    fn redact_aws_presigned_query_values_in_debug() {
949        let log_safe_url = SafeUrl::parse(
950            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
951        )
952        .unwrap();
953
954        let debug = format!("{log_safe_url:?}");
955        assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
956        assert!(!debug.contains("credential"));
957        assert!(!debug.contains("signature"));
958    }
959
960    #[test]
961    fn redact_azure_sas_query_signature() {
962        let urls = [
963            "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=signature&sp=r",
964            "https://account.blob.core.windows.net/container/dist.whl?SIG=signature&safe=value",
965        ]
966        .map(|url| SafeUrl::parse(url).unwrap().to_string());
967
968        assert_debug_snapshot!(urls, @r#"
969        [
970            "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=****&sp=r",
971            "https://account.blob.core.windows.net/container/dist.whl?SIG=****&safe=value",
972        ]
973        "#);
974    }
975
976    #[test]
977    fn does_not_redact_unknown_query_values() {
978        let log_safe_url =
979            SafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
980
981        assert_eq!(
982            log_safe_url.to_string(),
983            "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
984        );
985    }
986
987    #[test]
988    fn does_not_add_authority_to_urls_without_authority() {
989        let log_safe_url = SafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
990
991        assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
992    }
993
994    #[test]
995    fn redacts_query_values_in_urls_without_authority() {
996        let log_safe_url =
997            SafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
998
999        assert_eq!(
1000            log_safe_url.to_string(),
1001            "c:/home/ferris/projects/foo?X-Amz-Signature=****"
1002        );
1003    }
1004
1005    #[test]
1006    fn redacts_query_values_in_cannot_be_a_base_urls() {
1007        let log_safe_url =
1008            SafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
1009
1010        assert!(log_safe_url.cannot_be_a_base());
1011        assert_eq!(
1012            log_safe_url.to_string(),
1013            "mailto:ferris@example.com?X-Amz-Signature=****"
1014        );
1015    }
1016
1017    #[test]
1018    fn url_join() {
1019        let url_str = "https://token@example.com/abc/";
1020        let log_safe_url = SafeUrl::parse(url_str).unwrap();
1021        let foo_url = log_safe_url.join("foo").unwrap();
1022        assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
1023    }
1024
1025    #[test]
1026    fn log_safe_url_ref() {
1027        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
1028        let url = SafeUrl::parse(url_str).unwrap();
1029        let log_safe_url = SafeUrl::ref_cast(&url.0);
1030        assert_eq!(log_safe_url.username(), "user");
1031        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
1032        assert_eq!(
1033            log_safe_url.to_string(),
1034            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
1035        );
1036    }
1037
1038    #[test]
1039    fn parse_url_ambiguous() {
1040        for url in &[
1041            "https://user/name:password@domain/a/b/c",
1042            "https://user\\name:password@domain/a/b/c",
1043            "https://user#name:password@domain/a/b/c",
1044            "https://user.com/name:password@domain/a/b/c",
1045        ] {
1046            let err = SafeUrl::parse(url).unwrap_err();
1047            match err {
1048                SafeUrlError::AmbiguousAuthority(redacted) => {
1049                    assert!(redacted.starts_with("https:***@domain/a/b/c"));
1050                }
1051                SafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
1052            }
1053        }
1054    }
1055
1056    #[test]
1057    fn parse_url_not_ambiguous() {
1058        for url in &[
1059            // https://github.com/astral-sh/uv/issues/16756
1060            "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
1061            // https://github.com/astral-sh/uv/issues/17214
1062            // Git proxy URLs with nested schemes should not trigger the ambiguity check
1063            "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
1064            "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
1065            "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
1066            // https://github.com/astral-sh/uv/issues/19887
1067            // Windows `git+file://` URLs pair a drive-letter `:` with an `@` revision, but use a
1068            // file transport and so carry no credentials.
1069            "git+file:///C:/Users/ferris/repo.git@v1.0",
1070            "git+file:///C:/Users/ferris/repo.git@10c049896212932ad5f7b19456d90bc604eeca53",
1071            "hg+file:///C:/Users/ferris/repo@default",
1072        ] {
1073            SafeUrl::parse(url).unwrap();
1074        }
1075    }
1076
1077    #[test]
1078    fn credential_like_pattern() {
1079        assert!(!has_credential_like_pattern(
1080            "/https://github.com/user/repo.git@branch"
1081        ));
1082        assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
1083
1084        assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
1085        assert!(has_credential_like_pattern(":password@domain"));
1086    }
1087}