sensitive_url/
lib.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
3use std::fmt;
4use std::str::FromStr;
5use url::Url;
6
7/// Errors that can occur when creating or parsing a `SensitiveUrl`.
8#[derive(Debug)]
9pub enum Error {
10    /// The URL cannot be used as a base URL.
11    InvalidUrl(String),
12    /// Failed to parse the URL string.
13    ParseError(url::ParseError),
14    /// Failed to redact sensitive information from the URL.
15    RedactError(String),
16}
17
18impl fmt::Display for Error {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Error::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
22            Error::ParseError(e) => write!(f, "Parse error: {}", e),
23            Error::RedactError(msg) => write!(f, "Redact error: {}", msg),
24        }
25    }
26}
27
28impl std::error::Error for Error {
29    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
30        match self {
31            Error::ParseError(e) => Some(e),
32            _ => None,
33        }
34    }
35}
36
37/// A URL wrapper that redacts sensitive information in `Display` and `Debug` output.
38///
39/// This type stores both the full URL (with credentials, paths, and query parameters)
40/// and a redacted version (containing only the scheme, host, and port). The redacted
41/// version is used when displaying or debugging to prevent accidental leakage of
42/// credentials in logs.
43///
44/// Note that `SensitiveUrl` specifically does NOT implement `Deref`, meaning you cannot call
45/// `Url` methods like `.password()` or `.scheme()` directly on `SensitiveUrl`. You must first
46/// explicitly call `.expose_full()`.
47///
48/// # Examples
49///
50/// ```
51/// use sensitive_url::SensitiveUrl;
52///
53/// let url = SensitiveUrl::parse("https://user:pass@example.com/api?token=secret").unwrap();
54///
55/// // Display shows only the redacted version:
56/// assert_eq!(url.to_string(), "https://example.com/");
57///
58/// // But you can still access the full URL when needed:
59/// let full = url.expose_full();
60/// assert_eq!(full.to_string(), "https://user:pass@example.com/api?token=secret");
61/// assert_eq!(full.password(), Some("pass"));
62/// ```
63#[derive(Clone, PartialEq, Eq, Hash)]
64pub struct SensitiveUrl {
65    full: Url,
66    redacted: String,
67}
68
69impl fmt::Display for SensitiveUrl {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        self.redacted.fmt(f)
72    }
73}
74
75impl fmt::Debug for SensitiveUrl {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.debug_struct("SensitiveUrl")
78            .field("redacted", &self.redacted)
79            // Maintains traditional `Debug` format but hides the 'full' field.
80            .finish_non_exhaustive()
81    }
82}
83
84#[cfg(feature = "serde")]
85impl Serialize for SensitiveUrl {
86    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
87    where
88        S: Serializer,
89    {
90        serializer.serialize_str(self.full.as_ref())
91    }
92}
93
94#[cfg(feature = "serde")]
95impl<'de> Deserialize<'de> for SensitiveUrl {
96    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
97    where
98        D: Deserializer<'de>,
99    {
100        let s: String = Deserialize::deserialize(deserializer)?;
101        SensitiveUrl::parse(&s)
102            .map_err(|e| de::Error::custom(format!("Failed to deserialize sensitive URL {:?}", e)))
103    }
104}
105
106impl FromStr for SensitiveUrl {
107    type Err = Error;
108
109    fn from_str(s: &str) -> Result<Self, Self::Err> {
110        Self::parse(s)
111    }
112}
113
114impl SensitiveUrl {
115    /// Attempts to parse a `&str` into a `SensitiveUrl`.
116    pub fn parse(url: &str) -> Result<Self, Error> {
117        let surl = Url::parse(url).map_err(Error::ParseError)?;
118        SensitiveUrl::new(surl)
119    }
120
121    /// Creates a `SensitiveUrl` from an existing `Url`.
122    pub fn new(full: Url) -> Result<Self, Error> {
123        let mut redacted = full.clone();
124        redacted
125            .path_segments_mut()
126            .map_err(|_| Error::InvalidUrl("URL cannot be a base.".to_string()))?
127            .clear();
128        redacted.set_query(None);
129
130        if redacted.has_authority() {
131            redacted
132                .set_username("")
133                .map_err(|_| Error::RedactError("Unable to redact username.".to_string()))?;
134            redacted
135                .set_password(None)
136                .map_err(|_| Error::RedactError("Unable to redact password.".to_string()))?;
137        }
138
139        Ok(Self {
140            full,
141            redacted: redacted.to_string(),
142        })
143    }
144
145    /// Returns a reference to the full, unredacted URL.
146    pub fn expose_full(&self) -> &Url {
147        &self.full
148    }
149
150    /// Returns the redacted URL as a `&str`.
151    pub fn redacted(&self) -> &str {
152        &self.redacted
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn redact_remote_url() {
162        let full = "https://user:pass@example.com/example?somequery";
163        let surl = SensitiveUrl::parse(full).unwrap();
164        assert_eq!(surl.to_string(), "https://example.com/");
165        assert_eq!(surl.expose_full().to_string(), full);
166    }
167
168    #[test]
169    fn redact_localhost_url() {
170        let full = "http://user:pass@localhost:5052/";
171        let surl = SensitiveUrl::parse(full).unwrap();
172        assert_eq!(surl.to_string(), "http://localhost:5052/");
173        assert_eq!(surl.expose_full().to_string(), full);
174    }
175
176    #[test]
177    fn test_no_credentials() {
178        let full = "https://example.com/path";
179        let surl = SensitiveUrl::parse(full).unwrap();
180        assert_eq!(surl.to_string(), "https://example.com/");
181        assert_eq!(surl.expose_full().to_string(), full);
182    }
183
184    #[test]
185    fn test_display() {
186        let full = "https://user:pass@example.com/api?token=secret";
187        let surl = SensitiveUrl::parse(full).unwrap();
188
189        let display = surl.to_string();
190        assert_eq!(display, "https://example.com/");
191    }
192
193    #[test]
194    fn test_debug() {
195        let full = "https://user:pass@example.com/api?token=secret";
196        let surl = SensitiveUrl::parse(full).unwrap();
197
198        let debug = format!("{:?}", surl);
199
200        assert_eq!(
201            debug,
202            "SensitiveUrl { redacted: \"https://example.com/\", .. }"
203        );
204    }
205
206    #[cfg(feature = "serde")]
207    mod serde_tests {
208        use super::*;
209
210        #[test]
211        fn test_serialize() {
212            let full = "https://user:pass@example.com/api?token=secret";
213            let surl = SensitiveUrl::parse(full).unwrap();
214
215            let json = serde_json::to_string(&surl).unwrap();
216            assert_eq!(json, format!("\"{}\"", full));
217        }
218
219        #[test]
220        fn test_deserialize() {
221            let full = "https://user:pass@example.com/api?token=secret";
222            let json = format!("\"{}\"", full);
223
224            let surl: SensitiveUrl = serde_json::from_str(&json).unwrap();
225            assert_eq!(surl.expose_full().as_str(), full);
226        }
227
228        #[test]
229        fn test_roundtrip() {
230            let full = "https://user:pass@example.com/api?token=secret";
231            let original = SensitiveUrl::parse(full).unwrap();
232
233            let json = serde_json::to_string(&original).unwrap();
234            let deserialized: SensitiveUrl = serde_json::from_str(&json).unwrap();
235
236            assert_eq!(deserialized.expose_full(), original.expose_full());
237        }
238    }
239}