1use ref_cast::RefCast;
2use serde::{Deserialize, Serialize};
3use std::borrow::Cow;
4use std::fmt::{Debug, Display};
5use std::ops::{Deref, DerefMut};
6use std::str::FromStr;
7use thiserror::Error;
8use url::Url;
9
10const SENSITIVE_QUERY_PARAMETERS: &[&str] = &[
11 "X-Amz-Credential",
12 "X-Amz-Security-Token",
13 "X-Amz-Signature",
14];
15
16#[derive(Error, Debug, Clone, PartialEq, Eq)]
17pub enum DisplaySafeUrlError {
18 #[error(transparent)]
20 Url(#[from] url::ParseError),
21
22 #[error("ambiguous user/pass authority in URL (not percent-encoded?): {0}")]
25 AmbiguousAuthority(String),
26}
27
28#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize, RefCast)]
62#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
63#[cfg_attr(feature = "schemars", schemars(transparent))]
64#[repr(transparent)]
65pub struct DisplaySafeUrl(Url);
66
67fn has_credential_like_pattern(s: &str) -> bool {
73 let mut remaining = s;
74 while let Some(colon_pos) = remaining.find(':') {
75 let after_colon = &remaining[colon_pos + 1..];
76 if after_colon.starts_with("//") {
78 remaining = after_colon;
79 continue;
80 }
81 if after_colon.contains('@') {
83 return true;
84 }
85 remaining = after_colon;
86 }
87 false
88}
89
90impl DisplaySafeUrl {
91 #[inline]
92 pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
93 let url = Url::parse(input)?;
94
95 Self::reject_ambiguous_credentials(input, &url)?;
96
97 Ok(Self(url))
98 }
99
100 fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), DisplaySafeUrlError> {
113 let scheme = url.scheme();
119 if scheme == "file" || scheme.ends_with("+file") {
120 return Ok(());
121 }
122
123 if url.password().is_some() {
124 return Ok(());
125 }
126
127 if !has_credential_like_pattern(url.path())
129 && !url.fragment().is_some_and(has_credential_like_pattern)
130 {
131 return Ok(());
132 }
133
134 let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
136 if cfg!(debug_assertions) {
137 unreachable!(
138 "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
139 );
140 }
141 return Ok(());
142 };
143
144 let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
148 Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path))
149 }
150
151 pub fn from_url(url: Url) -> Self {
157 Self(url)
158 }
159
160 #[inline]
162 pub fn ref_cast(url: &Url) -> &Self {
163 RefCast::ref_cast(url)
164 }
165
166 #[inline]
168 pub fn join(&self, input: &str) -> Result<Self, DisplaySafeUrlError> {
169 Ok(Self(self.0.join(input)?))
170 }
171
172 #[inline]
174 pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
175 where
176 S: serde::Serializer,
177 {
178 self.0.serialize_internal(serializer)
179 }
180
181 #[inline]
183 pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
184 where
185 D: serde::Deserializer<'de>,
186 {
187 Ok(Self(Url::deserialize_internal(deserializer)?))
188 }
189
190 #[expect(clippy::result_unit_err)]
191 pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
192 Ok(Self(Url::from_file_path(path)?))
193 }
194
195 #[inline]
198 pub fn remove_credentials(&mut self) {
199 if is_ssh_git_username(&self.0) {
202 return;
203 }
204 let _ = self.0.set_username("");
205 let _ = self.0.set_password(None);
206 }
207
208 pub fn without_credentials(&self) -> Cow<'_, Url> {
210 if self.0.password().is_none() && self.0.username() == "" {
211 return Cow::Borrowed(&self.0);
212 }
213
214 if is_ssh_git_username(&self.0) {
217 return Cow::Borrowed(&self.0);
218 }
219
220 let mut url = self.0.clone();
221 let _ = url.set_username("");
222 let _ = url.set_password(None);
223 Cow::Owned(url)
224 }
225
226 #[inline]
228 pub fn displayable_with_credentials(&self) -> impl Display {
229 &self.0
230 }
231
232 pub fn redact_in(&self, message: &str) -> String {
237 message.replace(self.0.as_str(), &self.to_string())
238 }
239}
240
241impl Deref for DisplaySafeUrl {
242 type Target = Url;
243
244 fn deref(&self) -> &Self::Target {
245 &self.0
246 }
247}
248
249impl DerefMut for DisplaySafeUrl {
250 fn deref_mut(&mut self) -> &mut Self::Target {
251 &mut self.0
252 }
253}
254
255impl Display for DisplaySafeUrl {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 display_with_redacted_credentials(&self.0, f)
258 }
259}
260
261impl Debug for DisplaySafeUrl {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 let url = &self.0;
264 let (username, password) = if is_ssh_git_username(url) {
267 (url.username(), None)
268 } else if url.username() != "" && url.password().is_some() {
269 (url.username(), Some("****"))
270 } else if url.username() != "" {
271 ("****", None)
272 } else if url.password().is_some() {
273 ("", Some("****"))
274 } else {
275 ("", None)
276 };
277
278 f.debug_struct("DisplaySafeUrl")
279 .field("scheme", &url.scheme())
280 .field("cannot_be_a_base", &url.cannot_be_a_base())
281 .field("username", &username)
282 .field("password", &password)
283 .field("host", &url.host())
284 .field("port", &url.port())
285 .field("path", &url.path())
286 .field(
287 "query",
288 &url.query()
289 .map(|query| redacted_query(query, url.query_pairs())),
290 )
291 .field("fragment", &url.fragment())
292 .finish()
293 }
294}
295
296impl From<DisplaySafeUrl> for Url {
297 fn from(url: DisplaySafeUrl) -> Self {
298 url.0
299 }
300}
301
302impl From<Url> for DisplaySafeUrl {
303 fn from(url: Url) -> Self {
304 Self(url)
305 }
306}
307
308impl FromStr for DisplaySafeUrl {
309 type Err = DisplaySafeUrlError;
310
311 fn from_str(input: &str) -> Result<Self, Self::Err> {
312 Self::parse(input)
313 }
314}
315
316fn is_ssh_git_username(url: &Url) -> bool {
317 matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
318 && url.username() == "git"
319 && url.password().is_none()
320}
321
322fn is_sensitive_query_parameter(key: &str) -> bool {
323 SENSITIVE_QUERY_PARAMETERS
324 .iter()
325 .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
326}
327
328fn redacted_query<'a>(
329 query: &'a str,
330 query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
331) -> Cow<'a, str> {
332 let mut redacted = false;
333 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
334 for (key, value) in query_pairs {
335 if is_sensitive_query_parameter(&key) {
336 serializer.append_pair(&key, "****");
337 redacted = true;
338 } else {
339 serializer.append_pair(&key, &value);
340 }
341 }
342
343 if redacted {
344 Cow::Owned(serializer.finish())
345 } else {
346 Cow::Borrowed(query)
347 }
348}
349
350fn display_with_redacted_credentials(
351 url: &Url,
352 f: &mut std::fmt::Formatter<'_>,
353) -> std::fmt::Result {
354 write!(f, "{}:", url.scheme())?;
355
356 if url.has_authority() {
357 write!(f, "//")?;
358
359 if url.username() != "" && url.password().is_some() {
360 write!(f, "{}", url.username())?;
361 write!(f, ":****@")?;
362 } else if url.username() != "" && is_ssh_git_username(url) {
363 write!(f, "{}@", url.username())?;
364 } else if url.username() != "" {
365 write!(f, "****@")?;
366 } else if url.password().is_some() {
367 write!(f, ":****@")?;
368 }
369
370 write!(f, "{}", url.host_str().unwrap_or(""))?;
371
372 if let Some(port) = url.port() {
373 write!(f, ":{port}")?;
374 }
375 }
376
377 write!(f, "{}", url.path())?;
378 if let Some(query) = url.query() {
379 write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
380 }
381 if let Some(fragment) = url.fragment() {
382 write!(f, "#{fragment}")?;
383 }
384
385 Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn from_url_no_credentials() {
394 let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
395 let log_safe_url =
396 DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
397 assert_eq!(log_safe_url.username(), "");
398 assert!(log_safe_url.password().is_none());
399 assert_eq!(log_safe_url.to_string(), url_str);
400 }
401
402 #[test]
403 fn from_url_username_and_password() {
404 let log_safe_url =
405 DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
406 .unwrap();
407 assert_eq!(log_safe_url.username(), "user");
408 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
409 assert_eq!(
410 log_safe_url.to_string(),
411 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
412 );
413 }
414
415 #[test]
416 fn from_url_just_password() {
417 let log_safe_url =
418 DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
419 assert_eq!(log_safe_url.username(), "");
420 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
421 assert_eq!(
422 log_safe_url.to_string(),
423 "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
424 );
425 }
426
427 #[test]
428 fn from_url_just_username() {
429 let log_safe_url =
430 DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
431 assert_eq!(log_safe_url.username(), "user");
432 assert!(log_safe_url.password().is_none());
433 assert_eq!(
434 log_safe_url.to_string(),
435 "https://****@pypi-proxy.fly.dev/basic-auth/simple"
436 );
437 }
438
439 #[test]
440 fn from_url_git_username() {
441 let ssh_str = "ssh://git@github.com/org/repo";
442 let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
443 assert_eq!(ssh_url.username(), "git");
444 assert!(ssh_url.password().is_none());
445 assert_eq!(ssh_url.to_string(), ssh_str);
446 let git_ssh_str = "git+ssh://git@github.com/org/repo";
448 let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
449 assert_eq!(git_ssh_url.username(), "git");
450 assert!(git_ssh_url.password().is_none());
451 assert_eq!(git_ssh_url.to_string(), git_ssh_str);
452 }
453
454 #[test]
455 fn parse_url_string() {
456 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
457 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
458 assert_eq!(log_safe_url.username(), "user");
459 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
460 assert_eq!(
461 log_safe_url.to_string(),
462 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
463 );
464 }
465
466 #[test]
467 fn remove_credentials() {
468 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
469 let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
470 log_safe_url.remove_credentials();
471 assert_eq!(log_safe_url.username(), "");
472 assert!(log_safe_url.password().is_none());
473 assert_eq!(
474 log_safe_url.to_string(),
475 "https://pypi-proxy.fly.dev/basic-auth/simple"
476 );
477 }
478
479 #[test]
480 fn preserve_ssh_git_username_on_remove_credentials() {
481 let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
482 let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
483 ssh_url.remove_credentials();
484 assert_eq!(ssh_url.username(), "git");
485 assert!(ssh_url.password().is_none());
486 assert_eq!(ssh_url.to_string(), ssh_str);
487 let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
489 let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
490 git_shh_url.remove_credentials();
491 assert_eq!(git_shh_url.username(), "git");
492 assert!(git_shh_url.password().is_none());
493 assert_eq!(git_shh_url.to_string(), git_ssh_str);
494 }
495
496 #[test]
497 fn displayable_with_credentials() {
498 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
499 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
500 assert_eq!(
501 log_safe_url.displayable_with_credentials().to_string(),
502 url_str
503 );
504 }
505
506 #[test]
507 fn redact_url_in_message() {
508 let url = DisplaySafeUrl::parse("https://user:pass@example.com/org/repo.git").unwrap();
509 let message = format!(
510 "process didn't exit successfully: `git fetch '{}'`\n--- stderr\nfatal: Authentication failed for '{}'",
511 url.as_str(),
512 url.as_str()
513 );
514
515 assert_eq!(
516 url.redact_in(&message),
517 "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'"
518 );
519 }
520
521 #[test]
522 fn redact_presigned_url_in_message() {
523 let url = DisplaySafeUrl::parse(
524 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&X-Amz-Credential=credential&X-Amz-Security-Token=token&safe=value",
525 )
526 .unwrap();
527 let message = format!("failed to fetch '{}'", url.as_str());
528
529 assert_eq!(
530 url.redact_in(&message),
531 "failed to fetch 'https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&X-Amz-Credential=****&X-Amz-Security-Token=****&safe=value'"
532 );
533 }
534
535 #[test]
536 fn redact_aws_presigned_query_values() {
537 let log_safe_url = DisplaySafeUrl::parse(
538 "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",
539 )
540 .unwrap();
541
542 assert_eq!(
543 log_safe_url.to_string(),
544 "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=****"
545 );
546 }
547
548 #[test]
549 fn redact_aws_presigned_query_values_case_insensitive() {
550 let log_safe_url = DisplaySafeUrl::parse(
551 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
552 )
553 .unwrap();
554
555 assert_eq!(
556 log_safe_url.to_string(),
557 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
558 );
559 }
560
561 #[test]
562 fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
563 let log_safe_url = DisplaySafeUrl::parse(
564 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
565 )
566 .unwrap();
567
568 assert_eq!(
569 log_safe_url.to_string(),
570 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
571 );
572 }
573
574 #[test]
575 fn redact_aws_presigned_query_values_in_debug() {
576 let log_safe_url = DisplaySafeUrl::parse(
577 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
578 )
579 .unwrap();
580
581 let debug = format!("{log_safe_url:?}");
582 assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
583 assert!(!debug.contains("credential"));
584 assert!(!debug.contains("signature"));
585 }
586
587 #[test]
588 fn does_not_redact_unknown_query_values() {
589 let log_safe_url =
590 DisplaySafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
591
592 assert_eq!(
593 log_safe_url.to_string(),
594 "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
595 );
596 }
597
598 #[test]
599 fn does_not_add_authority_to_urls_without_authority() {
600 let log_safe_url = DisplaySafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
601
602 assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
603 }
604
605 #[test]
606 fn redacts_query_values_in_urls_without_authority() {
607 let log_safe_url =
608 DisplaySafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
609
610 assert_eq!(
611 log_safe_url.to_string(),
612 "c:/home/ferris/projects/foo?X-Amz-Signature=****"
613 );
614 }
615
616 #[test]
617 fn redacts_query_values_in_cannot_be_a_base_urls() {
618 let log_safe_url =
619 DisplaySafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
620
621 assert!(log_safe_url.cannot_be_a_base());
622 assert_eq!(
623 log_safe_url.to_string(),
624 "mailto:ferris@example.com?X-Amz-Signature=****"
625 );
626 }
627
628 #[test]
629 fn url_join() {
630 let url_str = "https://token@example.com/abc/";
631 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
632 let foo_url = log_safe_url.join("foo").unwrap();
633 assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
634 }
635
636 #[test]
637 fn log_safe_url_ref() {
638 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
639 let url = DisplaySafeUrl::parse(url_str).unwrap();
640 let log_safe_url = DisplaySafeUrl::ref_cast(&url);
641 assert_eq!(log_safe_url.username(), "user");
642 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
643 assert_eq!(
644 log_safe_url.to_string(),
645 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
646 );
647 }
648
649 #[test]
650 fn parse_url_ambiguous() {
651 for url in &[
652 "https://user/name:password@domain/a/b/c",
653 "https://user\\name:password@domain/a/b/c",
654 "https://user#name:password@domain/a/b/c",
655 "https://user.com/name:password@domain/a/b/c",
656 ] {
657 let err = DisplaySafeUrl::parse(url).unwrap_err();
658 match err {
659 DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
660 assert!(redacted.starts_with("https:***@domain/a/b/c"));
661 }
662 DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
663 }
664 }
665 }
666
667 #[test]
668 fn parse_url_not_ambiguous() {
669 for url in &[
670 "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
672 "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
675 "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
676 "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
677 "git+file:///C:/Users/ferris/repo.git@v1.0",
681 "git+file:///C:/Users/ferris/repo.git@10c049896212932ad5f7b19456d90bc604eeca53",
682 "hg+file:///C:/Users/ferris/repo@default",
683 ] {
684 DisplaySafeUrl::parse(url).unwrap();
685 }
686 }
687
688 #[test]
689 fn credential_like_pattern() {
690 assert!(!has_credential_like_pattern(
691 "/https://github.com/user/repo.git@branch"
692 ));
693 assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
694
695 assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
696 assert!(has_credential_like_pattern(":password@domain"));
697 }
698}