1#![expect(
2 clippy::disallowed_types,
3 reason = "`SafeUrl` wraps `url::Url` and exposes conversions for external APIs"
4)]
5
6use ref_cast::RefCast;
7use serde::Deserialize;
8use std::borrow::Cow;
9use std::fmt::{Debug, Display};
10use std::marker::PhantomData;
11use std::path::PathBuf;
12use std::str::FromStr;
13use thiserror::Error;
14use url::Url;
15
16pub mod policy;
17
18use policy::{InternalSerializationPolicy, Raw, SerializationPolicy, Unserializable};
19
20const SENSITIVE_QUERY_PARAMETERS: &[&str] = &[
21 "sig",
22 "X-Amz-Credential",
23 "X-Amz-Security-Token",
24 "X-Amz-Signature",
25 "sig",
26];
27
28#[derive(Error, Debug, Clone, PartialEq, Eq)]
29pub enum SafeUrlError {
30 #[error(transparent)]
32 Url(#[from] url::ParseError),
33
34 #[error("ambiguous user/pass authority in URL (not percent-encoded?): {0}")]
37 AmbiguousAuthority(String),
38}
39
40#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Hash, RefCast)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123#[cfg_attr(
124 feature = "schemars",
125 schemars(transparent, bound = "", description = "A URL.")
126)]
127#[repr(transparent)]
128pub struct SafeUrl<P: SerializationPolicy = Unserializable>(Url, PhantomData<P>);
129
130pub struct PathSegmentsMut<'a>(url::PathSegmentsMut<'a>);
134
135impl PathSegmentsMut<'_> {
136 pub fn clear(&mut self) -> &mut Self {
138 self.0.clear();
139 self
140 }
141
142 pub fn pop_if_empty(&mut self) -> &mut Self {
144 self.0.pop_if_empty();
145 self
146 }
147
148 pub fn pop(&mut self) -> &mut Self {
150 self.0.pop();
151 self
152 }
153
154 pub fn push(&mut self, segment: &str) -> &mut Self {
156 self.0.push(segment);
157 self
158 }
159
160 pub fn extend<I>(&mut self, segments: I) -> &mut Self
162 where
163 I: IntoIterator,
164 I::Item: AsRef<str>,
165 {
166 self.0.extend(segments);
167 self
168 }
169}
170
171pub struct QueryPairsMut<'a>(url::form_urlencoded::Serializer<'a, url::UrlQuery<'a>>);
173
174impl QueryPairsMut<'_> {
175 pub fn append_pair(&mut self, name: &str, value: &str) -> &mut Self {
177 self.0.append_pair(name, value);
178 self
179 }
180}
181
182fn has_credential_like_pattern(s: &str) -> bool {
188 let mut remaining = s;
189 while let Some(colon_pos) = remaining.find(':') {
190 let after_colon = &remaining[colon_pos + 1..];
191 if after_colon.starts_with("//") {
193 remaining = after_colon;
194 continue;
195 }
196 if after_colon.contains('@') {
198 return true;
199 }
200 remaining = after_colon;
201 }
202 false
203}
204
205impl SafeUrl {
206 #[inline]
207 pub fn parse(input: &str) -> Result<Self, SafeUrlError> {
208 let url = Self::parse_relaxed(input)?;
209
210 Self::reject_ambiguous_credentials(input, &url.0)?;
211
212 Ok(url)
213 }
214
215 #[inline]
220 pub fn parse_relaxed(input: &str) -> Result<Self, url::ParseError> {
221 Url::parse(input).map(Self::from_url)
222 }
223
224 fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), SafeUrlError> {
237 let scheme = url.scheme();
243 if scheme == "file" || scheme.ends_with("+file") {
244 return Ok(());
245 }
246
247 if url.password().is_some() {
248 return Ok(());
249 }
250
251 if !has_credential_like_pattern(url.path())
253 && !url.fragment().is_some_and(has_credential_like_pattern)
254 {
255 return Ok(());
256 }
257
258 let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else {
260 if cfg!(debug_assertions) {
261 unreachable!(
262 "`:` or `@` sign missing in URL that was confirmed to contain them: {input}"
263 );
264 }
265 return Ok(());
266 };
267
268 let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]);
272 Err(SafeUrlError::AmbiguousAuthority(redacted_path))
273 }
274
275 pub fn from_url(url: Url) -> Self {
281 Self(url, PhantomData)
282 }
283
284 #[inline]
286 pub fn ref_cast(url: &Url) -> &Self {
287 RefCast::ref_cast(url)
288 }
289
290 #[expect(clippy::result_unit_err)]
291 pub fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
292 Ok(Self(Url::from_file_path(path)?, PhantomData))
293 }
294
295 #[expect(clippy::result_unit_err)]
297 pub fn from_directory_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ()> {
298 Ok(Self(Url::from_directory_path(path)?, PhantomData))
299 }
300}
301
302impl<P: SerializationPolicy> SafeUrl<P> {
303 #[must_use]
305 pub fn with_policy<Q: SerializationPolicy>(self, _policy: Q) -> SafeUrl<Q> {
306 SafeUrl(self.0, PhantomData)
307 }
308
309 #[must_use]
311 fn with_policy_ref<Q: SerializationPolicy>(&self, _policy: Q) -> &SafeUrl<Q> {
312 RefCast::ref_cast(&self.0)
313 }
314
315 pub fn as_url(&self) -> &SafeUrl {
317 self.with_policy_ref(Unserializable)
318 }
319
320 pub fn into_url(self) -> SafeUrl {
322 self.with_policy(Unserializable)
323 }
324
325 pub fn into_unsafe_url(self) -> Url {
339 self.0
340 }
341
342 pub fn as_str(&self) -> &str {
344 self.0.as_str()
345 }
346
347 pub fn scheme(&self) -> &str {
349 self.0.scheme()
350 }
351
352 pub fn authority(&self) -> &str {
354 self.0.authority()
355 }
356
357 pub fn cannot_be_a_base(&self) -> bool {
359 self.0.cannot_be_a_base()
360 }
361
362 pub fn username(&self) -> &str {
364 self.0.username()
365 }
366
367 pub fn password(&self) -> Option<&str> {
369 self.0.password()
370 }
371
372 pub fn has_host(&self) -> bool {
374 self.0.has_host()
375 }
376
377 pub fn host_str(&self) -> Option<&str> {
379 self.0.host_str()
380 }
381
382 pub fn host(&self) -> Option<url::Host<&str>> {
384 self.0.host()
385 }
386
387 pub fn port(&self) -> Option<u16> {
389 self.0.port()
390 }
391
392 pub fn port_or_known_default(&self) -> Option<u16> {
394 self.0.port_or_known_default()
395 }
396
397 pub fn path(&self) -> &str {
399 self.0.path()
400 }
401
402 pub fn path_segments(&self) -> Option<std::str::Split<'_, char>> {
404 self.0.path_segments()
405 }
406
407 pub fn query_pairs(&self) -> url::form_urlencoded::Parse<'_> {
409 self.0.query_pairs()
410 }
411
412 pub fn fragment(&self) -> Option<&str> {
414 self.0.fragment()
415 }
416
417 pub fn set_fragment(&mut self, fragment: Option<&str>) {
419 self.0.set_fragment(fragment);
420 }
421
422 pub fn set_query(&mut self, query: Option<&str>) {
424 self.0.set_query(query);
425 }
426
427 pub fn set_path(&mut self, path: &str) {
429 self.0.set_path(path);
430 }
431
432 #[expect(clippy::result_unit_err)]
434 pub fn set_password(&mut self, password: Option<&str>) -> Result<(), ()> {
435 self.0.set_password(password)
436 }
437
438 #[expect(clippy::result_unit_err)]
440 pub fn set_username(&mut self, username: &str) -> Result<(), ()> {
441 self.0.set_username(username)
442 }
443
444 #[expect(clippy::result_unit_err)]
446 pub fn to_file_path(&self) -> Result<PathBuf, ()> {
447 self.0.to_file_path()
448 }
449
450 #[expect(clippy::result_unit_err)]
452 pub fn path_segments_mut(&mut self) -> Result<PathSegmentsMut<'_>, ()> {
453 self.0.path_segments_mut().map(PathSegmentsMut)
454 }
455
456 pub fn query_pairs_mut(&mut self) -> QueryPairsMut<'_> {
468 QueryPairsMut(self.0.query_pairs_mut())
469 }
470
471 #[inline]
473 pub fn join(&self, input: &str) -> Result<Self, SafeUrlError> {
474 Ok(Self(self.0.join(input)?, PhantomData))
475 }
476
477 #[inline]
480 pub fn remove_credentials(&mut self) {
481 if is_ssh_git_username(&self.0) {
484 return;
485 }
486 let _ = self.0.set_username("");
487 let _ = self.0.set_password(None);
488 }
489
490 pub fn without_credentials(&self) -> Cow<'_, Self> {
503 if self.0.password().is_none() && self.0.username() == "" {
504 return Cow::Borrowed(self);
505 }
506
507 if is_ssh_git_username(&self.0) {
510 return Cow::Borrowed(self);
511 }
512
513 let mut url = self.clone();
514 url.remove_credentials();
515 Cow::Owned(url)
516 }
517
518 #[inline]
520 pub fn displayable_with_credentials(&self) -> impl Display {
521 &self.0
522 }
523
524 pub fn redact_in(&self, message: &str) -> String {
529 message.replace(self.0.as_str(), &self.to_string())
530 }
531
532 #[inline]
565 pub fn serialize_internal<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
566 where
567 P: InternalSerializationPolicy,
568 S: serde::Serializer,
569 {
570 self.0.serialize_internal(serializer)
571 }
572}
573
574impl SafeUrl<Raw> {
575 #[inline]
577 pub fn deserialize_internal<'de, D>(deserializer: D) -> Result<Self, D::Error>
578 where
579 D: serde::Deserializer<'de>,
580 {
581 Ok(Self(Url::deserialize_internal(deserializer)?, PhantomData))
582 }
583}
584
585impl<P: SerializationPolicy> Display for SafeUrl<P> {
586 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
587 display_with_redacted_credentials(&self.0, f)
588 }
589}
590
591impl<P: SerializationPolicy> Debug for SafeUrl<P> {
592 #[inline(never)]
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 let url = &self.0;
595 let (username, password) = redacted_credentials(url);
596
597 f.debug_struct("SafeUrl")
598 .field("scheme", &url.scheme())
599 .field("cannot_be_a_base", &url.cannot_be_a_base())
600 .field("username", &username)
601 .field("password", &password)
602 .field("host", &url.host())
603 .field("port", &url.port())
604 .field("path", &url.path())
605 .field(
606 "query",
607 &url.query()
608 .map(|query| redacted_query(query, url.query_pairs())),
609 )
610 .field("fragment", &url.fragment())
611 .finish()
612 }
613}
614
615impl From<Url> for SafeUrl {
616 fn from(url: Url) -> Self {
617 Self(url, PhantomData)
618 }
619}
620
621impl FromStr for SafeUrl {
622 type Err = SafeUrlError;
623
624 fn from_str(input: &str) -> Result<Self, Self::Err> {
625 Self::parse(input)
626 }
627}
628
629impl<'de, P: SerializationPolicy> Deserialize<'de> for SafeUrl<P> {
630 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
631 where
632 D: serde::Deserializer<'de>,
633 {
634 Url::deserialize(deserializer).map(|url| Self(url, PhantomData))
636 }
637}
638
639fn is_ssh_git_username(url: &Url) -> bool {
640 matches!(url.scheme(), "ssh" | "git+ssh" | "git+https")
641 && url.username() == "git"
642 && url.password().is_none()
643}
644
645fn redacted_credentials(url: &Url) -> (&str, Option<&str>) {
647 match (url.username(), url.password()) {
648 (username, Some(_)) => (username, Some("****")),
649 ("", None) => ("", None),
650 (username, None) if is_ssh_git_username(url) => (username, None),
652 (_, None) => ("****", None),
654 }
655}
656
657fn is_sensitive_query_parameter(key: &str) -> bool {
658 SENSITIVE_QUERY_PARAMETERS
659 .iter()
660 .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
661}
662
663fn redacted_query<'a>(
664 query: &'a str,
665 query_pairs: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)>,
666) -> Cow<'a, str> {
667 let mut redacted = false;
668 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
669 for (key, value) in query_pairs {
670 if is_sensitive_query_parameter(&key) {
671 serializer.append_pair(&key, "****");
672 redacted = true;
673 } else {
674 serializer.append_pair(&key, &value);
675 }
676 }
677
678 if redacted {
679 Cow::Owned(serializer.finish())
680 } else {
681 Cow::Borrowed(query)
682 }
683}
684
685fn display_with_redacted_credentials(
686 url: &Url,
687 f: &mut std::fmt::Formatter<'_>,
688) -> std::fmt::Result {
689 write!(f, "{}:", url.scheme())?;
690
691 if url.has_authority() {
692 write!(f, "//")?;
693
694 match redacted_credentials(url) {
695 ("", None) => {}
696 (username, Some(password)) => write!(f, "{username}:{password}@")?,
697 (username, None) => write!(f, "{username}@")?,
698 }
699
700 write!(f, "{}", url.host_str().unwrap_or(""))?;
701
702 if let Some(port) = url.port() {
703 write!(f, ":{port}")?;
704 }
705 }
706
707 write!(f, "{}", url.path())?;
708 if let Some(query) = url.query() {
709 write!(f, "?{}", redacted_query(query, url.query_pairs()))?;
710 }
711 if let Some(fragment) = url.fragment() {
712 write!(f, "#{fragment}")?;
713 }
714
715 Ok(())
716}
717
718#[cfg(test)]
719mod tests {
720 use insta::assert_debug_snapshot;
721
722 use super::*;
723
724 #[test]
725 fn parse_relaxed() -> Result<(), url::ParseError> {
726 let input = "https://user/name:password@example.com/project";
727 let url = SafeUrl::parse_relaxed(input)?;
728 assert_eq!(url.host_str(), Some("user"));
729 assert_eq!(url.path(), "/name:password@example.com/project");
730 insta::assert_snapshot!(SafeUrl::parse(input).unwrap_err(), @"ambiguous user/pass authority in URL (not percent-encoded?): https:***@example.com/project");
731 assert_eq!(
732 SafeUrl::parse_relaxed("https://").unwrap_err(),
733 url::ParseError::EmptyHost
734 );
735 Ok(())
736 }
737
738 #[test]
739 fn from_url_no_credentials() {
740 let url_str = "https://pypi-proxy.fly.dev/basic-auth/simple";
741 let log_safe_url = SafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap();
742 assert_eq!(log_safe_url.username(), "");
743 assert!(log_safe_url.password().is_none());
744 assert_eq!(log_safe_url.to_string(), url_str);
745 }
746
747 #[test]
748 fn from_url_username_and_password() {
749 let log_safe_url =
750 SafeUrl::parse("https://user:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
751 assert_eq!(log_safe_url.username(), "user");
752 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
753 assert_eq!(
754 log_safe_url.to_string(),
755 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
756 );
757 }
758
759 #[test]
760 fn from_url_just_password() {
761 let log_safe_url =
762 SafeUrl::parse("https://:pass@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
763 assert_eq!(log_safe_url.username(), "");
764 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
765 assert_eq!(
766 log_safe_url.to_string(),
767 "https://:****@pypi-proxy.fly.dev/basic-auth/simple"
768 );
769 }
770
771 #[test]
772 fn from_url_just_username() {
773 let log_safe_url =
774 SafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap();
775 assert_eq!(log_safe_url.username(), "user");
776 assert!(log_safe_url.password().is_none());
777 assert_eq!(
778 log_safe_url.to_string(),
779 "https://****@pypi-proxy.fly.dev/basic-auth/simple"
780 );
781 }
782
783 #[test]
784 fn from_url_git_username() {
785 let ssh_str = "ssh://git@github.com/org/repo";
786 let ssh_url = SafeUrl::parse(ssh_str).unwrap();
787 assert_eq!(ssh_url.username(), "git");
788 assert!(ssh_url.password().is_none());
789 assert_eq!(ssh_url.to_string(), ssh_str);
790 let git_ssh_str = "git+ssh://git@github.com/org/repo";
792 let git_ssh_url = SafeUrl::parse(git_ssh_str).unwrap();
793 assert_eq!(git_ssh_url.username(), "git");
794 assert!(git_ssh_url.password().is_none());
795 assert_eq!(git_ssh_url.to_string(), git_ssh_str);
796 }
797
798 #[test]
799 fn parse_url_string() {
800 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
801 let log_safe_url = SafeUrl::parse(url_str).unwrap();
802 assert_eq!(log_safe_url.username(), "user");
803 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
804 assert_eq!(
805 log_safe_url.to_string(),
806 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
807 );
808 }
809
810 #[test]
811 fn remove_credentials() {
812 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
813 let mut log_safe_url = SafeUrl::parse(url_str).unwrap();
814 log_safe_url.remove_credentials();
815 assert_eq!(log_safe_url.username(), "");
816 assert!(log_safe_url.password().is_none());
817 assert_eq!(
818 log_safe_url.to_string(),
819 "https://pypi-proxy.fly.dev/basic-auth/simple"
820 );
821 }
822
823 #[test]
824 fn preserve_ssh_git_username_on_remove_credentials() {
825 let ssh_str = "ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
826 let mut ssh_url = SafeUrl::parse(ssh_str).unwrap();
827 ssh_url.remove_credentials();
828 assert_eq!(ssh_url.username(), "git");
829 assert!(ssh_url.password().is_none());
830 assert_eq!(ssh_url.to_string(), ssh_str);
831 let git_ssh_str = "git+ssh://git@pypi-proxy.fly.dev/basic-auth/simple";
833 let mut git_shh_url = SafeUrl::parse(git_ssh_str).unwrap();
834 git_shh_url.remove_credentials();
835 assert_eq!(git_shh_url.username(), "git");
836 assert!(git_shh_url.password().is_none());
837 assert_eq!(git_shh_url.to_string(), git_ssh_str);
838 }
839
840 #[test]
841 fn displayable_with_credentials() {
842 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
843 let log_safe_url = SafeUrl::parse(url_str).unwrap();
844 assert_eq!(
845 log_safe_url.displayable_with_credentials().to_string(),
846 url_str
847 );
848 }
849
850 #[test]
851 fn redact_url_in_message() {
852 let url = SafeUrl::parse("https://user:pass@example.com/org/repo.git").unwrap();
853 let message = format!(
854 "process didn't exit successfully: `git fetch '{}'`\n--- stderr\nfatal: Authentication failed for '{}'",
855 url.as_str(),
856 url.as_str()
857 );
858
859 assert_eq!(
860 url.redact_in(&message),
861 "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'"
862 );
863 }
864
865 #[test]
866 fn redact_presigned_url_in_message() {
867 let url = SafeUrl::parse(
868 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&X-Amz-Credential=credential&X-Amz-Security-Token=token&safe=value",
869 )
870 .unwrap();
871 let message = format!("failed to fetch '{}'", url.as_str());
872
873 assert_eq!(
874 url.redact_in(&message),
875 "failed to fetch 'https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&X-Amz-Credential=****&X-Amz-Security-Token=****&safe=value'"
876 );
877 }
878
879 #[test]
880 fn redact_aws_presigned_query_values() {
881 let log_safe_url = SafeUrl::parse(
882 "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",
883 )
884 .unwrap();
885
886 assert_eq!(
887 log_safe_url.to_string(),
888 "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=****"
889 );
890 }
891
892 #[test]
893 fn redact_azure_shared_access_signature() -> Result<(), SafeUrlError> {
894 let url = SafeUrl::parse(
895 "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r",
896 )?;
897 assert_eq!(
898 url.to_string(),
899 "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r"
900 );
901 assert_eq!(
902 url.redact_in(&format!("failed to fetch '{}'", url.as_str())),
903 "failed to fetch 'https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=****&sp=r'"
904 );
905 assert_eq!(
907 url.as_str(),
908 "https://example.blob.core.windows.net/dist.whl?sv=2026-01-01&sig=signature&sp=r"
909 );
910 Ok(())
911 }
912
913 #[test]
914 fn redact_aws_presigned_query_values_case_insensitive() {
915 let log_safe_url = SafeUrl::parse(
916 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token",
917 )
918 .unwrap();
919
920 assert_eq!(
921 log_safe_url.to_string(),
922 "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
923 );
924 }
925
926 #[test]
927 fn redact_aws_presigned_query_values_with_percent_encoded_keys() {
928 let log_safe_url = SafeUrl::parse(
929 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz%2DSignature=signature&safe=value",
930 )
931 .unwrap();
932
933 assert_eq!(
934 log_safe_url.to_string(),
935 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Signature=****&safe=value"
936 );
937 }
938
939 #[test]
940 fn redact_aws_presigned_query_values_in_debug() {
941 let log_safe_url = SafeUrl::parse(
942 "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature",
943 )
944 .unwrap();
945
946 let debug = format!("{log_safe_url:?}");
947 assert!(debug.contains(r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"#));
948 assert!(!debug.contains("credential"));
949 assert!(!debug.contains("signature"));
950 }
951
952 #[test]
953 fn redact_azure_sas_query_signature() {
954 let urls = [
955 "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=signature&sp=r",
956 "https://account.blob.core.windows.net/container/dist.whl?SIG=signature&safe=value",
957 ]
958 .map(|url| SafeUrl::parse(url).unwrap().to_string());
959
960 assert_debug_snapshot!(urls, @r#"
961 [
962 "https://account.blob.core.windows.net/container/dist.whl?sv=2024-11-04&sr=b&sig=****&sp=r",
963 "https://account.blob.core.windows.net/container/dist.whl?SIG=****&safe=value",
964 ]
965 "#);
966 }
967
968 #[test]
969 fn does_not_redact_unknown_query_values() {
970 let log_safe_url =
971 SafeUrl::parse("https://bucket.s3.amazonaws.com/dist.whl?token=secret").unwrap();
972
973 assert_eq!(
974 log_safe_url.to_string(),
975 "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
976 );
977 }
978
979 #[test]
980 fn does_not_add_authority_to_urls_without_authority() {
981 let log_safe_url = SafeUrl::parse("c:/home/ferris/projects/foo").unwrap();
982
983 assert_eq!(log_safe_url.to_string(), "c:/home/ferris/projects/foo");
984 }
985
986 #[test]
987 fn redacts_query_values_in_urls_without_authority() {
988 let log_safe_url =
989 SafeUrl::parse("c:/home/ferris/projects/foo?X-Amz-Signature=signature").unwrap();
990
991 assert_eq!(
992 log_safe_url.to_string(),
993 "c:/home/ferris/projects/foo?X-Amz-Signature=****"
994 );
995 }
996
997 #[test]
998 fn redacts_query_values_in_cannot_be_a_base_urls() {
999 let log_safe_url =
1000 SafeUrl::parse("mailto:ferris@example.com?X-Amz-Signature=signature").unwrap();
1001
1002 assert!(log_safe_url.cannot_be_a_base());
1003 assert_eq!(
1004 log_safe_url.to_string(),
1005 "mailto:ferris@example.com?X-Amz-Signature=****"
1006 );
1007 }
1008
1009 #[test]
1010 fn url_join() {
1011 let url_str = "https://token@example.com/abc/";
1012 let log_safe_url = SafeUrl::parse(url_str).unwrap();
1013 let foo_url = log_safe_url.join("foo").unwrap();
1014 assert_eq!(foo_url.to_string(), "https://****@example.com/abc/foo");
1015 }
1016
1017 #[test]
1018 fn log_safe_url_ref() {
1019 let url_str = "https://user:pass@pypi-proxy.fly.dev/basic-auth/simple";
1020 let url = SafeUrl::parse(url_str).unwrap();
1021 let log_safe_url = SafeUrl::ref_cast(&url.0);
1022 assert_eq!(log_safe_url.username(), "user");
1023 assert!(log_safe_url.password().is_some_and(|p| p == "pass"));
1024 assert_eq!(
1025 log_safe_url.to_string(),
1026 "https://user:****@pypi-proxy.fly.dev/basic-auth/simple"
1027 );
1028 }
1029
1030 #[test]
1031 fn parse_url_ambiguous() {
1032 for url in &[
1033 "https://user/name:password@domain/a/b/c",
1034 "https://user\\name:password@domain/a/b/c",
1035 "https://user#name:password@domain/a/b/c",
1036 "https://user.com/name:password@domain/a/b/c",
1037 ] {
1038 let err = SafeUrl::parse(url).unwrap_err();
1039 match err {
1040 SafeUrlError::AmbiguousAuthority(redacted) => {
1041 assert!(redacted.starts_with("https:***@domain/a/b/c"));
1042 }
1043 SafeUrlError::Url(_) => panic!("expected AmbiguousAuthority error"),
1044 }
1045 }
1046 }
1047
1048 #[test]
1049 fn parse_url_not_ambiguous() {
1050 for url in &[
1051 "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace",
1053 "git+https://githubproxy.cc/https://github.com/user/repo.git@branch",
1056 "git+https://proxy.example.com/https://github.com/org/project@v1.0.0",
1057 "git+https://proxy.example.com/https://github.com/org/project@refs/heads/main",
1058 "git+file:///C:/Users/ferris/repo.git@v1.0",
1062 "git+file:///C:/Users/ferris/repo.git@10c049896212932ad5f7b19456d90bc604eeca53",
1063 "hg+file:///C:/Users/ferris/repo@default",
1064 ] {
1065 SafeUrl::parse(url).unwrap();
1066 }
1067 }
1068
1069 #[test]
1070 fn credential_like_pattern() {
1071 assert!(!has_credential_like_pattern(
1072 "/https://github.com/user/repo.git@branch"
1073 ));
1074 assert!(!has_credential_like_pattern("/http://example.com/path@ref"));
1075
1076 assert!(has_credential_like_pattern("/name:password@domain/a/b/c"));
1077 assert!(has_credential_like_pattern(":password@domain"));
1078 }
1079}