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