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
61impl DisplaySafeUrl {
62 #[inline]
63 pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
64 let url = Url::parse(input)?;
65
66 Self::reject_ambiguous_credentials(input, &url)?;
67
68 Ok(Self(url))
69 }
70
71 fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), DisplaySafeUrlError> {
84 if url.scheme() == "file" {
88 return Ok(());
89 }
90
91 if url.password().is_some() {
92 return Ok(());
93 }
94
95 if !url
97 .path()
98 .find(':')
99 .is_some_and(|pos| url.path()[pos..].contains('@'))
100 && !url
101 .fragment()
102 .map(|fragment| {
103 fragment
104 .find(':')
105 .is_some_and(|pos| fragment[pos..].contains('@'))
106 })
107 .unwrap_or(false)
108 {
109 return Ok(());
110 }
111
112 let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
114 if cfg!(debug_assertions) {
115 unreachable!(
116 "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
117 );
118 }
119 return Ok(());
120 };
121
122 let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
126 Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path))
127 }
128
129 pub fn from_url(url: Url) -> Self {
135 Self(url)
136 }
137
138 #[inline]
140 pub fn ref_cast(url: &Url) -> &Self {
141 RefCast::ref_cast(url)
142 }
143
144 #[inline]
146 pub fn join(&self, input: &str) -> Result<Self, DisplaySafeUrlError> {
147 Ok(Self(self.0.join(input)?))
148 }
149
150 #[inline]
152 pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
153 where
154 S: serde::Serializer,
155 {
156 self.0.serialize_internal(serializer)
157 }
158
159 #[inline]
161 pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
162 where
163 D: serde::Deserializer<'de>,
164 {
165 Ok(Self(Url::deserialize_internal(deserializer)?))
166 }
167
168 #[allow(clippy::result_unit_err)]
169 pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
170 Ok(Self(Url::from_file_path(path)?))
171 }
172
173 #[inline]
176 pub fn remove_credentials(&mut self) {
177 if is_ssh_git_username(&self.0) {
180 return;
181 }
182 let _ = self.0.set_username("");
183 let _ = self.0.set_password(None);
184 }
185
186 pub fn without_credentials(&self) -> Cow<'_, Url> {
188 if self.0.password().is_none() && self.0.username() == "" {
189 return Cow::Borrowed(&self.0);
190 }
191
192 if is_ssh_git_username(&self.0) {
195 return Cow::Borrowed(&self.0);
196 }
197
198 let mut url = self.0.clone();
199 let _ = url.set_username("");
200 let _ = url.set_password(None);
201 Cow::Owned(url)
202 }
203
204 #[inline]
206 pub fn displayable_with_credentials(&self) -> impl Display {
207 &self.0
208 }
209}
210
211impl Deref for DisplaySafeUrl {
212 type Target = Url;
213
214 fn deref(&self) -> &Self::Target {
215 &self.0
216 }
217}
218
219impl DerefMut for DisplaySafeUrl {
220 fn deref_mut(&mut self) -> &mut Self::Target {
221 &mut self.0
222 }
223}
224
225impl Display for DisplaySafeUrl {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 display_with_redacted_credentials(&self.0, f)
228 }
229}
230
231impl Debug for DisplaySafeUrl {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 let url = &self.0;
234 let (username, password) = if is_ssh_git_username(url) {
237 (url.username(), None)
238 } else if url.username() != "" && url.password().is_some() {
239 (url.username(), Some("****"))
240 } else if url.username() != "" {
241 ("****", None)
242 } else if url.password().is_some() {
243 ("", Some("****"))
244 } else {
245 ("", None)
246 };
247
248 f.debug_struct("DisplaySafeUrl")
249 .field("scheme", &url.scheme())
250 .field("cannot_be_a_base", &url.cannot_be_a_base())
251 .field("username", &username)
252 .field("password", &password)
253 .field("host", &url.host())
254 .field("port", &url.port())
255 .field("path", &url.path())
256 .field("query", &url.query())
257 .field("fragment", &url.fragment())
258 .finish()
259 }
260}
261
262impl From<DisplaySafeUrl> for Url {
263 fn from(url: DisplaySafeUrl) -> Self {
264 url.0
265 }
266}
267
268impl FromStr for DisplaySafeUrl {
269 type Err = DisplaySafeUrlError;
270
271 fn from_str(input: &str) -> Result<Self, Self::Err> {
272 Self::parse(input)
273 }
274}
275
276fn is_ssh_git_username(url: &Url) -> bool {
277 matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
278 && url.username() == "git"
279 && url.password().is_none()
280}
281
282fn display_with_redacted_credentials(
283 url: &Url,
284 f: &mut std::fmt::Formatter<'_>,
285) -> std::fmt::Result {
286 if url.password().is_none() && url.username() == "" {
287 return write!(f, "{url}");
288 }
289
290 if is_ssh_git_username(url) {
293 return write!(f, "{url}");
294 }
295
296 write!(f, "{}://", url.scheme())?;
297
298 if url.username() != "" && url.password().is_some() {
299 write!(f, "{}", url.username())?;
300 write!(f, ":****@")?;
301 } else if url.username() != "" {
302 write!(f, "****@")?;
303 } else if url.password().is_some() {
304 write!(f, ":****@")?;
305 }
306
307 write!(f, "{}", url.host_str().unwrap_or(""))?;
308
309 if let Some(port) = url.port() {
310 write!(f, ":{port}")?;
311 }
312
313 write!(f, "{}", url.path())?;
314 if let Some(query) = url.query() {
315 write!(f, "?{query}")?;
316 }
317 if let Some(fragment) = url.fragment() {
318 write!(f, "#{fragment}")?;
319 }
320
321 Ok(())
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn from_url_no_credentials() {
330 let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
331 let log_safe_url =
332 DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
333 assert_eq!(log_safe_url.username(), "");
334 assert!(log_safe_url.password().is_none());
335 assert_eq!(log_safe_url.to_string(), url_str);
336 }
337
338 #[test]
339 fn from_url_username_and_password() {
340 let log_safe_url =
341 DisplaySafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple")
342 .unwrap();
343 assert_eq!(log_safe_url.username(), "user");
344 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
345 assert_eq!(
346 log_safe_url.to_string(),
347 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
348 );
349 }
350
351 #[test]
352 fn from_url_just_password() {
353 let log_safe_url =
354 DisplaySafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
355 assert_eq!(log_safe_url.username(), "");
356 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
357 assert_eq!(
358 log_safe_url.to_string(),
359 "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
360 );
361 }
362
363 #[test]
364 fn from_url_just_username() {
365 let log_safe_url =
366 DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
367 assert_eq!(log_safe_url.username(), "user");
368 assert!(log_safe_url.password().is_none());
369 assert_eq!(
370 log_safe_url.to_string(),
371 "https://****@pypi-proxy.fly.dev/basic-auth/simple"
372 );
373 }
374
375 #[test]
376 fn from_url_git_username() {
377 let ssh_str = "ssh://git@github.com/org/repo";
378 let ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
379 assert_eq!(ssh_url.username(), "git");
380 assert!(ssh_url.password().is_none());
381 assert_eq!(ssh_url.to_string(), ssh_str);
382 let git_ssh_str = "git+ssh://git@github.com/org/repo";
384 let git_ssh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
385 assert_eq!(git_ssh_url.username(), "git");
386 assert!(git_ssh_url.password().is_none());
387 assert_eq!(git_ssh_url.to_string(), git_ssh_str);
388 }
389
390 #[test]
391 fn parse_url_string() {
392 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
393 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
394 assert_eq!(log_safe_url.username(), "user");
395 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
396 assert_eq!(
397 log_safe_url.to_string(),
398 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
399 );
400 }
401
402 #[test]
403 fn remove_credentials() {
404 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
405 let mut log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
406 log_safe_url.remove_credentials();
407 assert_eq!(log_safe_url.username(), "");
408 assert!(log_safe_url.password().is_none());
409 assert_eq!(
410 log_safe_url.to_string(),
411 "https://pypi-proxy.fly.dev/basic-auth/simple"
412 );
413 }
414
415 #[test]
416 fn preserve_ssh_git_username_on_remove_credentials() {
417 let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
418 let mut ssh_url = DisplaySafeUrl::parse(ssh_str).unwrap();
419 ssh_url.remove_credentials();
420 assert_eq!(ssh_url.username(), "git");
421 assert!(ssh_url.password().is_none());
422 assert_eq!(ssh_url.to_string(), ssh_str);
423 let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
425 let mut git_shh_url = DisplaySafeUrl::parse(git_ssh_str).unwrap();
426 git_shh_url.remove_credentials();
427 assert_eq!(git_shh_url.username(), "git");
428 assert!(git_shh_url.password().is_none());
429 assert_eq!(git_shh_url.to_string(), git_ssh_str);
430 }
431
432 #[test]
433 fn displayable_with_credentials() {
434 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
435 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
436 assert_eq!(
437 log_safe_url.displayable_with_credentials().to_string(),
438 url_str
439 );
440 }
441
442 #[test]
443 fn url_join() {
444 let url_str = "https://token@example.com/abc/";
445 let log_safe_url = DisplaySafeUrl::parse(url_str).unwrap();
446 let foo_url = log_safe_url.join("foo").unwrap();
447 assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
448 }
449
450 #[test]
451 fn log_safe_url_ref() {
452 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
453 let url = DisplaySafeUrl::parse(url_str).unwrap();
454 let log_safe_url = DisplaySafeUrl::ref_cast(&url);
455 assert_eq!(log_safe_url.username(), "user");
456 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
457 assert_eq!(
458 log_safe_url.to_string(),
459 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
460 );
461 }
462
463 #[test]
464 fn parse_url_ambiguous() {
465 for url in &[
466 "https://user/name:password@domain/a/b/c",
467 "https://user\\name:password@domain/a/b/c",
468 "https://user#name:password@domain/a/b/c",
469 "https://user.com/name:password@domain/a/b/c",
470 ] {
471 let err = DisplaySafeUrl::parse(url).unwrap_err();
472 match err {
473 DisplaySafeUrlError::AmbiguousAuthority(redacted) => {
474 assert!(redacted.starts_with("https:***@domain/a/b/c"));
475 }
476 DisplaySafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
477 }
478 }
479 }
480
481 #[test]
482 fn parse_url_not_ambiguous() {
483 #[allow(clippy::single_element_loop)]
484 for url in &[
485 "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
487 ] {
488 DisplaySafeUrl::parse(url).unwrap();
489 }
490 }
491}