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
10#[derive(Error, Debug, Clone, PartialEq, Eq)]
11pub enum DisplaySafeUrlError {
12 #[error(transparent)]
14 Url(#[from] url::ParseError),
15
16 #[error("ambiguous user/pass authority in URL (not percent-encoded?): {0}")]
19 AmbiguousAuthority(String),
20}
21
22#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize, RefCast)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57#[cfg_attr(feature = "schemars", schemars(transparent))]
58#[repr(transparent)]
59pub struct DisplaySafeUrl(Url);
60
61fn has_credential_like_pattern(s: &str) -> bool {
67 let mut remaining = s;
68 while let Some(colon_pos) = remaining.find(':') {
69 let after_colon = &remaining[colon_pos + 1..];
70 if after_colon.starts_with("//") {
72 remaining = after_colon;
73 continue;
74 }
75 if after_colon.contains('@') {
77 return true;
78 }
79 remaining = after_colon;
80 }
81 false
82}
83
84impl DisplaySafeUrl {
85 #[inline]
86 pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
87 let url = Url::parse(input)?;
88
89 Self::reject_ambiguous_credentials(input, &url)?;
90
91 Ok(Self(url))
92 }
93
94 fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), DisplaySafeUrlError> {
107 if url.scheme() == "file" {
111 return Ok(());
112 }
113
114 if url.password().is_some() {
115 return Ok(());
116 }
117
118 if !has_credential_like_pattern(url.path())
120 && !url
121 .fragment()
122 .map(has_credential_like_pattern)
123 .unwrap_or(false)
124 {
125 return Ok(());
126 }
127
128 let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
130 if cfg!(debug_assertions) {
131 unreachable!(
132 "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
133 );
134 }
135 return Ok(());
136 };
137
138 let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
142 Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path))
143 }
144
145 pub fn from_url(url: Url) -> Self {
151 Self(url)
152 }
153
154 #[inline]
156 pub fn ref_cast(url: &Url) -> &Self {
157 RefCast::ref_cast(url)
158 }
159
160 #[inline]
162 pub fn join(&self, input: &str) -> Result<Self, DisplaySafeUrlError> {
163 Ok(Self(self.0.join(input)?))
164 }
165
166 #[inline]
168 pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
169 where
170 S: serde::Serializer,
171 {
172 self.0.serialize_internal(serializer)
173 }
174
175 #[inline]
177 pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
178 where
179 D: serde::Deserializer<'de>,
180 {
181 Ok(Self(Url::deserialize_internal(deserializer)?))
182 }
183
184 #[allow(clippy::result_unit_err)]
185 pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
186 Ok(Self(Url::from_file_path(path)?))
187 }
188
189 #[inline]
192 pub fn remove_credentials(&mut self) {
193 if is_ssh_git_username(&self.0) {
196 return;
197 }
198 let _ = self.0.set_username("");
199 let _ = self.0.set_password(None);
200 }
201
202 pub fn without_credentials(&self) -> Cow<'_, Url> {
204 if self.0.password().is_none() && self.0.username() == "" {
205 return Cow::Borrowed(&self.0);
206 }
207
208 if is_ssh_git_username(&self.0) {
211 return Cow::Borrowed(&self.0);
212 }
213
214 let mut url = self.0.clone();
215 let _ = url.set_username("");
216 let _ = url.set_password(None);
217 Cow::Owned(url)
218 }
219
220 #[inline]
222 pub fn displayable_with_credentials(&self) -> impl Display {
223 &self.0
224 }
225}
226
227impl Deref for DisplaySafeUrl {
228 type Target = Url;
229
230 fn deref(&self) -> &Self::Target {
231 &self.0
232 }
233}
234
235impl DerefMut for DisplaySafeUrl {
236 fn deref_mut(&mut self) -> &mut Self::Target {
237 &mut self.0
238 }
239}
240
241impl Display for DisplaySafeUrl {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 display_with_redacted_credentials(&self.0, f)
244 }
245}
246
247impl Debug for DisplaySafeUrl {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 let url = &self.0;
250 let (username, password) = if is_ssh_git_username(url) {
253 (url.username(), None)
254 } else if url.username() != "" && url.password().is_some() {
255 (url.username(), Some("****"))
256 } else if url.username() != "" {
257 ("****", None)
258 } else if url.password().is_some() {
259 ("", Some("****"))
260 } else {
261 ("", None)
262 };
263
264 f.debug_struct("DisplaySafeUrl")
265 .field("scheme", &url.scheme())
266 .field("cannot_be_a_base", &url.cannot_be_a_base())
267 .field("username", &username)
268 .field("password", &password)
269 .field("host", &url.host())
270 .field("port", &url.port())
271 .field("path", &url.path())
272 .field("query", &url.query())
273 .field("fragment", &url.fragment())
274 .finish()
275 }
276}
277
278impl From<DisplaySafeUrl> for Url {
279 fn from(url: DisplaySafeUrl) -> Self {
280 url.0
281 }
282}
283
284impl From<Url> for DisplaySafeUrl {
285 fn from(url: Url) -> Self {
286 Self(url)
287 }
288}
289
290impl FromStr for DisplaySafeUrl {
291 type Err = DisplaySafeUrlError;
292
293 fn from_str(input: &str) -> Result<Self, Self::Err> {
294 Self::parse(input)
295 }
296}
297
298fn is_ssh_git_username(url: &Url) -> bool {
299 matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
300 && url.username() == "git"
301 && url.password().is_none()
302}
303
304fn display_with_redacted_credentials(
305 url: &Url,
306 f: &mut std::fmt::Formatter<'_>,
307) -> std::fmt::Result {
308 if url.password().is_none() && url.username() == "" {
309 return write!(f, "{url}");
310 }
311
312 if is_ssh_git_username(url) {
315 return write!(f, "{url}");
316 }
317
318 write!(f, "{}://", url.scheme())?;
319
320 if url.username() != "" && url.password().is_some() {
321 write!(f, "{}", url.username())?;
322 write!(f, ":****@")?;
323 } else if url.username() != "" {
324 write!(f, "****@")?;
325 } else if url.password().is_some() {
326 write!(f, ":****@")?;
327 }
328
329 write!(f, "{}", url.host_str().unwrap_or(""))?;
330
331 if let Some(port) = url.port() {
332 write!(f, ":{port}")?;
333 }
334
335 write!(f, "{}", url.path())?;
336 if let Some(query) = url.query() {
337 write!(f, "?{query}")?;
338 }
339 if let Some(fragment) = url.fragment() {
340 write!(f, "#{fragment}")?;
341 }
342
343 Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn from_url_no_credentials() {
352 let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
353 let log_safe_url =
354 DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
355 assert_eq!(log_safe_url.username(), "");
356 assert!(log_safe_url.password().is_none());
357 assert_eq!(log_safe_url.to_string(), url_str);
358 }
359
360 #[test]
361 fn from_url_username_and_password() {
362 let log_safe_url =
363 DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
364 .unwrap();
365 assert_eq!(log_safe_url.username(), "user");
366 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
367 assert_eq!(
368 log_safe_url.to_string(),
369 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
370 );
371 }
372
373 #[test]
374 fn from_url_just_password() {
375 let log_safe_url =
376 DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
377 assert_eq!(log_safe_url.username(), "");
378 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
379 assert_eq!(
380 log_safe_url.to_string(),
381 "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
382 );
383 }
384
385 #[test]
386 fn from_url_just_username() {
387 let log_safe_url =
388 DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
389 assert_eq!(log_safe_url.username(), "user");
390 assert!(log_safe_url.password().is_none());
391 assert_eq!(
392 log_safe_url.to_string(),
393 "https://****@pypi-proxy.fly.dev/basic-auth/simple"
394 );
395 }
396
397 #[test]
398 fn from_url_git_username() {
399 let ssh_str = "ssh://git@github.com/org/repo";
400 let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
401 assert_eq!(ssh_url.username(), "git");
402 assert!(ssh_url.password().is_none());
403 assert_eq!(ssh_url.to_string(), ssh_str);
404 let git_ssh_str = "git+ssh://git@github.com/org/repo";
406 let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
407 assert_eq!(git_ssh_url.username(), "git");
408 assert!(git_ssh_url.password().is_none());
409 assert_eq!(git_ssh_url.to_string(), git_ssh_str);
410 }
411
412 #[test]
413 fn parse_url_string() {
414 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
415 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
416 assert_eq!(log_safe_url.username(), "user");
417 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
418 assert_eq!(
419 log_safe_url.to_string(),
420 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
421 );
422 }
423
424 #[test]
425 fn remove_credentials() {
426 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
427 let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
428 log_safe_url.remove_credentials();
429 assert_eq!(log_safe_url.username(), "");
430 assert!(log_safe_url.password().is_none());
431 assert_eq!(
432 log_safe_url.to_string(),
433 "https://pypi-proxy.fly.dev/basic-auth/simple"
434 );
435 }
436
437 #[test]
438 fn preserve_ssh_git_username_on_remove_credentials() {
439 let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
440 let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
441 ssh_url.remove_credentials();
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@pypi-proxy.fly.dev/basic-auth/simple";
447 let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
448 git_shh_url.remove_credentials();
449 assert_eq!(git_shh_url.username(), "git");
450 assert!(git_shh_url.password().is_none());
451 assert_eq!(git_shh_url.to_string(), git_ssh_str);
452 }
453
454 #[test]
455 fn displayable_with_credentials() {
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!(
459 log_safe_url.displayable_with_credentials().to_string(),
460 url_str
461 );
462 }
463
464 #[test]
465 fn url_join() {
466 let url_str = "https://token@example.com/abc/";
467 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
468 let foo_url = log_safe_url.join("foo").unwrap();
469 assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
470 }
471
472 #[test]
473 fn log_safe_url_ref() {
474 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
475 let url = DisplaySafeUrl::parse(url_str).unwrap();
476 let log_safe_url = DisplaySafeUrl::ref_cast(&url);
477 assert_eq!(log_safe_url.username(), "user");
478 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
479 assert_eq!(
480 log_safe_url.to_string(),
481 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
482 );
483 }
484
485 #[test]
486 fn parse_url_ambiguous() {
487 for url in &[
488 "https://user/name:password@domain/a/b/c",
489 "https://user\\name:password@domain/a/b/c",
490 "https://user#name:password@domain/a/b/c",
491 "https://user.com/name:password@domain/a/b/c",
492 ] {
493 let err = DisplaySafeUrl::parse(url).unwrap_err();
494 match err {
495 DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
496 assert!(redacted.starts_with("https:***@domain/a/b/c"));
497 }
498 DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
499 }
500 }
501 }
502
503 #[test]
504 fn parse_url_not_ambiguous() {
505 for url in &[
506 "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
508 "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
511 "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
512 "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
513 ] {
514 DisplaySafeUrl::parse(url).unwrap();
515 }
516 }
517
518 #[test]
519 fn credential_like_pattern() {
520 assert!(!has_credential_like_pattern(
521 "/https://github.com/user/repo.git@branch"
522 ));
523 assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
524
525 assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
526 assert!(has_credential_like_pattern(":password@domain"));
527 }
528}