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 insta::assert_debug_snapshot;
721
722    use super::*;
723
724    #[test]
725    fn parse_relaxed() -> Result<(), url::ParseError> {
726        let input = "https://user/name:password@example.com/project";
727        let url = SafeUrl::parse_relaxed(input)?;
728        assert_eq!(url.host_str(), Some("user"));
729        assert_eq!(url.path(), "/name:password@example.com/project");
730        insta::assert_snapshot!(SafeUrl::parse(input).unwrap_err(), @"ambiguous user/pass authority in URL (not percent-encoded?): https:***@example.com/project");
731        assert_eq!(
732            SafeUrl::parse_relaxed("https://").unwrap_err(),
733            url::ParseError::EmptyHost
734        );
735        Ok(())
736    }
737
738    #[test]
739    fn from_url_no_credentials() {
740        let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
741        let log_safe_url = SafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
742        assert_eq!(log_safe_url.username(), "");
743        assert!(log_safe_url.password().is_none());
744        assert_eq!(log_safe_url.to_string(), url_str);
745    }
746
747    #[test]
748    fn from_url_username_and_password() {
749        let log_safe_url =
750            SafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
751        assert_eq!(log_safe_url.username(), "user");
752        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
753        assert_eq!(
754            log_safe_url.to_string(),
755            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
756        );
757    }
758
759    #[test]
760    fn from_url_just_password() {
761        let log_safe_url =
762            SafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
763        assert_eq!(log_safe_url.username(), "");
764        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
765        assert_eq!(
766            log_safe_url.to_string(),
767            "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
768        );
769    }
770
771    #[test]
772    fn from_url_just_username() {
773        let log_safe_url =
774            SafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
775        assert_eq!(log_safe_url.username(), "user");
776        assert!(log_safe_url.password().is_none());
777        assert_eq!(
778            log_safe_url.to_string(),
779            "https://****@pypi-proxy.fly.dev/basic-auth/simple"
780        );
781    }
782
783    #[test]
784    fn from_url_git_username() {
785        let ssh_str = "ssh://git@github.com/org/repo";
786        let ssh_url = SafeUrl::parse(ssh_str).unwrap();
787        assert_eq!(ssh_url.username(), "git");
788        assert!(ssh_url.password().is_none());
789        assert_eq!(ssh_url.to_string(), ssh_str);
790        // Test again for the `git+ssh` scheme
791        let git_ssh_str = "git+ssh://git@github.com/org/repo";
792        let git_ssh_url = SafeUrl::parse(git_ssh_str).unwrap();
793        assert_eq!(git_ssh_url.username(), "git");
794        assert!(git_ssh_url.password().is_none());
795        assert_eq!(git_ssh_url.to_string(), git_ssh_str);
796    }
797
798    #[test]
799    fn parse_url_string() {
800        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
801        let log_safe_url = SafeUrl::parse(url_str).unwrap();
802        assert_eq!(log_safe_url.username(), "user");
803        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
804        assert_eq!(
805            log_safe_url.to_string(),
806            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
807        );
808    }
809
810    #[test]
811    fn remove_credentials() {
812        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
813        let mut log_safe_url = SafeUrl::parse(url_str).unwrap();
814        log_safe_url.remove_credentials();
815        assert_eq!(log_safe_url.username(), "");
816        assert!(log_safe_url.password().is_none());
817        assert_eq!(
818            log_safe_url.to_string(),
819            "https://pypi-proxy.fly.dev/basic-auth/simple"
820        );
821    }
822
823    #[test]
824    fn preserve_ssh_git_username_on_remove_credentials() {
825        let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
826        let mut ssh_url = SafeUrl::parse(ssh_str).unwrap();
827        ssh_url.remove_credentials();
828        assert_eq!(ssh_url.username(), "git");
829        assert!(ssh_url.password().is_none());
830        assert_eq!(ssh_url.to_string(), ssh_str);
831        // Test again for `git+ssh` scheme
832        let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
833        let mut git_shh_url = SafeUrl::parse(git_ssh_str).unwrap();
834        git_shh_url.remove_credentials();
835        assert_eq!(git_shh_url.username(), "git");
836        assert!(git_shh_url.password().is_none());
837        assert_eq!(git_shh_url.to_string(), git_ssh_str);
838    }
839
840    #[test]
841    fn displayable_with_credentials() {
842        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
843        let log_safe_url = SafeUrl::parse(url_str).unwrap();
844        assert_eq!(
845            log_safe_url.displayable_with_credentials().to_string(),
846            url_str
847        );
848    }
849
850    #[test]
851    fn redact_url_in_message() {
852        let url = SafeUrl::parse("https://user:pass@example.com/org/repo.git").unwrap();
853        let message = format!(
854            "process didn't exit successfully: `git fetch '{}'`\n--- stderr\nfatal: Authentication failed for '{}'",
855            url.as_str(),
856            url.as_str()
857        );
858
859        assert_eq!(
860            url.redact_in(&message),
861            "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'"
862        );
863    }
864
865    #[test]
866    fn redact_presigned_url_in_message() {
867        let url = SafeUrl::parse(
868            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&X-Amz-Credential=credential&X-Amz-Security-Token=token&safe=value",
869        )
870        .unwrap();
871        let message = format!("failed to fetch '{}'", url.as_str());
872
873        assert_eq!(
874            url.redact_in(&message),
875            "failed to fetch 'https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&X-Amz-Credential=****&X-Amz-Security-Token=****&safe=value'"
876        );
877    }
878
879    #[test]
880    fn redact_aws_presigned_query_values() {
881        let log_safe_url = SafeUrl::parse(
882            "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",
883        )
884        .unwrap();
885
886        assert_eq!(
887            log_safe_url.to_string(),
888            "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=****"
889        );
890    }
891
892    #[test]
893    fn redact_azure_shared_access_signature() -> Result<(), SafeUrlError> {
894        let url = SafeUrl::parse(
895            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r",
896        )?;
897        assert_eq!(
898            url.to_string(),
899            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r"
900        );
901        assert_eq!(
902            url.redact_in(&format!("failed to fetch '{}'", url.as_str())),
903            "failed to fetch 'https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r'"
904        );
905        // Formatting must not alter the signature used in actual requests.
906        assert_eq!(
907            url.as_str(),
908            "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r"
909        );
910        Ok(())
911    }
912
913    #[test]
914    fn redact_aws_presigned_query_values_case_insensitive() {
915        let log_safe_url = SafeUrl::parse(
916            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
917        )
918        .unwrap();
919
920        assert_eq!(
921            log_safe_url.to_string(),
922            "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
923        );
924    }
925
926    #[test]
927    fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
928        let log_safe_url = SafeUrl::parse(
929            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
930        )
931        .unwrap();
932
933        assert_eq!(
934            log_safe_url.to_string(),
935            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
936        );
937    }
938
939    #[test]
940    fn redact_aws_presigned_query_values_in_debug() {
941        let log_safe_url = SafeUrl::parse(
942            "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
943        )
944        .unwrap();
945
946        let debug = format!("{log_safe_url:?}");
947        assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
948        assert!(!debug.contains("credential"));
949        assert!(!debug.contains("signature"));
950    }
951
952    #[test]
953    fn redact_azure_sas_query_signature() {
954        let urls = [
955            "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=signature&sp=r",
956            "https://account.blob.core.windows.net/container/dist.whl?SIG=signature&safe=value",
957        ]
958        .map(|url| SafeUrl::parse(url).unwrap().to_string());
959
960        assert_debug_snapshot!(urls, @r#"
961        [
962            "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=****&sp=r",
963            "https://account.blob.core.windows.net/container/dist.whl?SIG=****&safe=value",
964        ]
965        "#);
966    }
967
968    #[test]
969    fn does_not_redact_unknown_query_values() {
970        let log_safe_url =
971            SafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
972
973        assert_eq!(
974            log_safe_url.to_string(),
975            "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
976        );
977    }
978
979    #[test]
980    fn does_not_add_authority_to_urls_without_authority() {
981        let log_safe_url = SafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
982
983        assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
984    }
985
986    #[test]
987    fn redacts_query_values_in_urls_without_authority() {
988        let log_safe_url =
989            SafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
990
991        assert_eq!(
992            log_safe_url.to_string(),
993            "c:/home/ferris/projects/foo?X-Amz-Signature=****"
994        );
995    }
996
997    #[test]
998    fn redacts_query_values_in_cannot_be_a_base_urls() {
999        let log_safe_url =
1000            SafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
1001
1002        assert!(log_safe_url.cannot_be_a_base());
1003        assert_eq!(
1004            log_safe_url.to_string(),
1005            "mailto:ferris@example.com?X-Amz-Signature=****"
1006        );
1007    }
1008
1009    #[test]
1010    fn url_join() {
1011        let url_str = "https://token@example.com/abc/";
1012        let log_safe_url = SafeUrl::parse(url_str).unwrap();
1013        let foo_url = log_safe_url.join("foo").unwrap();
1014        assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
1015    }
1016
1017    #[test]
1018    fn log_safe_url_ref() {
1019        let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
1020        let url = SafeUrl::parse(url_str).unwrap();
1021        let log_safe_url = SafeUrl::ref_cast(&url.0);
1022        assert_eq!(log_safe_url.username(), "user");
1023        assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
1024        assert_eq!(
1025            log_safe_url.to_string(),
1026            "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
1027        );
1028    }
1029
1030    #[test]
1031    fn parse_url_ambiguous() {
1032        for url in &[
1033            "https://user/name:password@domain/a/b/c",
1034            "https://user\\name:password@domain/a/b/c",
1035            "https://user#name:password@domain/a/b/c",
1036            "https://user.com/name:password@domain/a/b/c",
1037        ] {
1038            let err = SafeUrl::parse(url).unwrap_err();
1039            match err {
1040                SafeUrlError::AmbiguousAuthority(redacted) => {
1041                    assert!(redacted.starts_with("https:***@domain/a/b/c"));
1042                }
1043                SafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
1044            }
1045        }
1046    }
1047
1048    #[test]
1049    fn parse_url_not_ambiguous() {
1050        for url in &[
1051            // https://github.com/astral-sh/uv/issues/16756
1052            "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
1053            // https://github.com/astral-sh/uv/issues/17214
1054            // Git proxy URLs with nested schemes should not trigger the ambiguity check
1055            "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
1056            "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
1057            "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
1058            // https://github.com/astral-sh/uv/issues/19887
1059            // Windows `git+file://` URLs pair a drive-letter `:` with an `@` revision, but use a
1060            // file transport and so carry no credentials.
1061            "git+file:///C:/Users/ferris/repo.git@v1.0",
1062            "git+file:///C:/Users/ferris/repo.git@10c049896212932ad5f7b19456d90bc604eeca53",
1063            "hg+file:///C:/Users/ferris/repo@default",
1064        ] {
1065            SafeUrl::parse(url).unwrap();
1066        }
1067    }
1068
1069    #[test]
1070    fn credential_like_pattern() {
1071        assert!(!has_credential_like_pattern(
1072            "/https://github.com/user/repo.git@branch"
1073        ));
1074        assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
1075
1076        assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
1077        assert!(has_credential_like_pattern(":password@domain"));
1078    }
1079}