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