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