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 if url.scheme() == "file" {
117 return Ok(());
118 }
119
120 if url.password().is_some() {
121 return Ok(());
122 }
123
124 if !has_credential_like_pattern(url.path())
126 && !url
127 .fragment()
128 .map(has_credential_like_pattern)
129 .unwrap_or(false)
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
233impl Deref for DisplaySafeUrl {
234 type Target = Url;
235
236 fn deref(&self) -> &Self::Target {
237 &self.0
238 }
239}
240
241impl DerefMut for DisplaySafeUrl {
242 fn deref_mut(&mut self) -> &mut Self::Target {
243 &mut self.0
244 }
245}
246
247impl Display for DisplaySafeUrl {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 display_with_redacted_credentials(&self.0, f)
250 }
251}
252
253impl Debug for DisplaySafeUrl {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 let url = &self.0;
256 let (username, password) = if is_ssh_git_username(url) {
259 (url.username(), None)
260 } else if url.username() != "" && url.password().is_some() {
261 (url.username(), Some("****"))
262 } else if url.username() != "" {
263 ("****", None)
264 } else if url.password().is_some() {
265 ("", Some("****"))
266 } else {
267 ("", None)
268 };
269
270 f.debug_struct("DisplaySafeUrl")
271 .field("scheme", &url.scheme())
272 .field("cannot_be_a_base", &url.cannot_be_a_base())
273 .field("username", &username)
274 .field("password", &password)
275 .field("host", &url.host())
276 .field("port", &url.port())
277 .field("path", &url.path())
278 .field(
279 "query",
280 &url.query()
281 .map(|query| redacted_query(query, url.query_pairs())),
282 )
283 .field("fragment", &url.fragment())
284 .finish()
285 }
286}
287
288impl From<DisplaySafeUrl> for Url {
289 fn from(url: DisplaySafeUrl) -> Self {
290 url.0
291 }
292}
293
294impl From<Url> for DisplaySafeUrl {
295 fn from(url: Url) -> Self {
296 Self(url)
297 }
298}
299
300impl FromStr for DisplaySafeUrl {
301 type Err = DisplaySafeUrlError;
302
303 fn from_str(input: &str) -> Result<Self, Self::Err> {
304 Self::parse(input)
305 }
306}
307
308fn is_ssh_git_username(url: &Url) -> bool {
309 matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
310 && url.username() == "git"
311 && url.password().is_none()
312}
313
314fn is_sensitive_query_parameter(key: &str) -> bool {
315 SENSITIVE_QUERY_PARAMETERS
316 .iter()
317 .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
318}
319
320fn redacted_query<'a>(
321 query: &'a str,
322 query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
323) -> Cow<'a, str> {
324 let mut redacted = false;
325 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
326 for (key, value) in query_pairs {
327 if is_sensitive_query_parameter(&key) {
328 serializer.append_pair(&key, "****");
329 redacted = true;
330 } else {
331 serializer.append_pair(&key, &value);
332 }
333 }
334
335 if redacted {
336 Cow::Owned(serializer.finish())
337 } else {
338 Cow::Borrowed(query)
339 }
340}
341
342fn display_with_redacted_credentials(
343 url: &Url,
344 f: &mut std::fmt::Formatter<'_>,
345) -> std::fmt::Result {
346 write!(f, "{}:", url.scheme())?;
347
348 if url.has_authority() {
349 write!(f, "//")?;
350
351 if url.username() != "" && url.password().is_some() {
352 write!(f, "{}", url.username())?;
353 write!(f, ":****@")?;
354 } else if url.username() != "" && is_ssh_git_username(url) {
355 write!(f, "{}@", url.username())?;
356 } else if url.username() != "" {
357 write!(f, "****@")?;
358 } else if url.password().is_some() {
359 write!(f, ":****@")?;
360 }
361
362 write!(f, "{}", url.host_str().unwrap_or(""))?;
363
364 if let Some(port) = url.port() {
365 write!(f, ":{port}")?;
366 }
367 }
368
369 write!(f, "{}", url.path())?;
370 if let Some(query) = url.query() {
371 write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
372 }
373 if let Some(fragment) = url.fragment() {
374 write!(f, "#{fragment}")?;
375 }
376
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn from_url_no_credentials() {
386 let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
387 let log_safe_url =
388 DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
389 assert_eq!(log_safe_url.username(), "");
390 assert!(log_safe_url.password().is_none());
391 assert_eq!(log_safe_url.to_string(), url_str);
392 }
393
394 #[test]
395 fn from_url_username_and_password() {
396 let log_safe_url =
397 DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
398 .unwrap();
399 assert_eq!(log_safe_url.username(), "user");
400 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
401 assert_eq!(
402 log_safe_url.to_string(),
403 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
404 );
405 }
406
407 #[test]
408 fn from_url_just_password() {
409 let log_safe_url =
410 DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
411 assert_eq!(log_safe_url.username(), "");
412 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
413 assert_eq!(
414 log_safe_url.to_string(),
415 "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
416 );
417 }
418
419 #[test]
420 fn from_url_just_username() {
421 let log_safe_url =
422 DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
423 assert_eq!(log_safe_url.username(), "user");
424 assert!(log_safe_url.password().is_none());
425 assert_eq!(
426 log_safe_url.to_string(),
427 "https://****@pypi-proxy.fly.dev/basic-auth/simple"
428 );
429 }
430
431 #[test]
432 fn from_url_git_username() {
433 let ssh_str = "ssh://git@github.com/org/repo";
434 let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
435 assert_eq!(ssh_url.username(), "git");
436 assert!(ssh_url.password().is_none());
437 assert_eq!(ssh_url.to_string(), ssh_str);
438 let git_ssh_str = "git+ssh://git@github.com/org/repo";
440 let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
441 assert_eq!(git_ssh_url.username(), "git");
442 assert!(git_ssh_url.password().is_none());
443 assert_eq!(git_ssh_url.to_string(), git_ssh_str);
444 }
445
446 #[test]
447 fn parse_url_string() {
448 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
449 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
450 assert_eq!(log_safe_url.username(), "user");
451 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
452 assert_eq!(
453 log_safe_url.to_string(),
454 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
455 );
456 }
457
458 #[test]
459 fn remove_credentials() {
460 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
461 let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
462 log_safe_url.remove_credentials();
463 assert_eq!(log_safe_url.username(), "");
464 assert!(log_safe_url.password().is_none());
465 assert_eq!(
466 log_safe_url.to_string(),
467 "https://pypi-proxy.fly.dev/basic-auth/simple"
468 );
469 }
470
471 #[test]
472 fn preserve_ssh_git_username_on_remove_credentials() {
473 let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
474 let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
475 ssh_url.remove_credentials();
476 assert_eq!(ssh_url.username(), "git");
477 assert!(ssh_url.password().is_none());
478 assert_eq!(ssh_url.to_string(), ssh_str);
479 let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
481 let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
482 git_shh_url.remove_credentials();
483 assert_eq!(git_shh_url.username(), "git");
484 assert!(git_shh_url.password().is_none());
485 assert_eq!(git_shh_url.to_string(), git_ssh_str);
486 }
487
488 #[test]
489 fn displayable_with_credentials() {
490 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
491 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
492 assert_eq!(
493 log_safe_url.displayable_with_credentials().to_string(),
494 url_str
495 );
496 }
497
498 #[test]
499 fn redact_aws_presigned_query_values() {
500 let log_safe_url = DisplaySafeUrl::parse(
501 "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",
502 )
503 .unwrap();
504
505 assert_eq!(
506 log_safe_url.to_string(),
507 "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=****"
508 );
509 }
510
511 #[test]
512 fn redact_aws_presigned_query_values_case_insensitive() {
513 let log_safe_url = DisplaySafeUrl::parse(
514 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
515 )
516 .unwrap();
517
518 assert_eq!(
519 log_safe_url.to_string(),
520 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
521 );
522 }
523
524 #[test]
525 fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
526 let log_safe_url = DisplaySafeUrl::parse(
527 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
528 )
529 .unwrap();
530
531 assert_eq!(
532 log_safe_url.to_string(),
533 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
534 );
535 }
536
537 #[test]
538 fn redact_aws_presigned_query_values_in_debug() {
539 let log_safe_url = DisplaySafeUrl::parse(
540 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
541 )
542 .unwrap();
543
544 let debug = format!("{log_safe_url:?}");
545 assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
546 assert!(!debug.contains("credential"));
547 assert!(!debug.contains("signature"));
548 }
549
550 #[test]
551 fn does_not_redact_unknown_query_values() {
552 let log_safe_url =
553 DisplaySafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
554
555 assert_eq!(
556 log_safe_url.to_string(),
557 "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
558 );
559 }
560
561 #[test]
562 fn does_not_add_authority_to_urls_without_authority() {
563 let log_safe_url = DisplaySafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
564
565 assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
566 }
567
568 #[test]
569 fn redacts_query_values_in_urls_without_authority() {
570 let log_safe_url =
571 DisplaySafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
572
573 assert_eq!(
574 log_safe_url.to_string(),
575 "c:/home/ferris/projects/foo?X-Amz-Signature=****"
576 );
577 }
578
579 #[test]
580 fn redacts_query_values_in_cannot_be_a_base_urls() {
581 let log_safe_url =
582 DisplaySafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
583
584 assert!(log_safe_url.cannot_be_a_base());
585 assert_eq!(
586 log_safe_url.to_string(),
587 "mailto:ferris@example.com?X-Amz-Signature=****"
588 );
589 }
590
591 #[test]
592 fn url_join() {
593 let url_str = "https://token@example.com/abc/";
594 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
595 let foo_url = log_safe_url.join("foo").unwrap();
596 assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
597 }
598
599 #[test]
600 fn log_safe_url_ref() {
601 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
602 let url = DisplaySafeUrl::parse(url_str).unwrap();
603 let log_safe_url = DisplaySafeUrl::ref_cast(&url);
604 assert_eq!(log_safe_url.username(), "user");
605 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
606 assert_eq!(
607 log_safe_url.to_string(),
608 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
609 );
610 }
611
612 #[test]
613 fn parse_url_ambiguous() {
614 for url in &[
615 "https://user/name:password@domain/a/b/c",
616 "https://user\\name:password@domain/a/b/c",
617 "https://user#name:password@domain/a/b/c",
618 "https://user.com/name:password@domain/a/b/c",
619 ] {
620 let err = DisplaySafeUrl::parse(url).unwrap_err();
621 match err {
622 DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
623 assert!(redacted.starts_with("https:***@domain/a/b/c"));
624 }
625 DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
626 }
627 }
628 }
629
630 #[test]
631 fn parse_url_not_ambiguous() {
632 for url in &[
633 "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
635 "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
638 "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
639 "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
640 ] {
641 DisplaySafeUrl::parse(url).unwrap();
642 }
643 }
644
645 #[test]
646 fn credential_like_pattern() {
647 assert!(!has_credential_like_pattern(
648 "/https://github.com/user/repo.git@branch"
649 ));
650 assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
651
652 assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
653 assert!(has_credential_like_pattern(":password@domain"));
654 }
655}