apollo_redaction/strategy/
url.rs1use crate::RedactionStrategy;
2use std::fmt;
3use url::Url;
4
5#[derive(Default, Clone)]
27pub struct UrlPasswordRedactor;
28
29impl<T: AsRef<str>> RedactionStrategy<T> for UrlPasswordRedactor {
30 fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 redact_url_password(value.as_ref(), f)
32 }
33}
34
35fn redact_url_password(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let Ok(mut parsed) = Url::parse(input) else {
37 return write!(f, "[REDACTED: invalid URL]");
38 };
39
40 if parsed.password().is_some() {
41 let _ = parsed.set_password(Some("***"));
44 }
45 write!(f, "{}", parsed)
46}
47
48#[derive(Default, Clone)]
65pub struct UrlHostRedactor;
66
67impl<T: AsRef<str>> RedactionStrategy<T> for UrlHostRedactor {
68 fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 redact_url(value.as_ref(), f)
70 }
71}
72
73fn redact_url(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 let Ok(parsed) = Url::parse(input) else {
75 return write!(f, "[REDACTED: invalid URL]");
76 };
77
78 write!(f, "****")?;
79
80 let path = parsed.path();
82 if path != "/" {
83 write!(f, "{}", path)?;
84 }
85
86 if parsed.query().is_some() {
88 write!(f, "?")?;
89 let mut first = true;
90 for (key, _value) in parsed.query_pairs() {
91 if !first {
92 write!(f, "&")?;
93 }
94 first = false;
95 write!(f, "{}=****", key)?;
96 }
97 }
98
99 if parsed.fragment().is_some() {
101 write!(f, "#****")?;
102 }
103
104 Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::Redacted;
111
112 fn url(input: &str) -> String {
113 Redacted::<_, UrlHostRedactor>::new(input).to_string()
114 }
115
116 #[test]
117 fn test_https_with_path() {
118 assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
119 }
120
121 #[test]
122 fn test_no_path() {
123 assert_eq!(url("https://example.com"), "****");
124 }
125
126 #[test]
127 fn test_with_credentials() {
128 assert_eq!(url("postgres://user:pass@host/db"), "****/db");
129 }
130
131 #[test]
132 fn test_invalid_url() {
133 assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
134 }
135
136 #[test]
137 fn test_with_query_string() {
138 assert_eq!(
139 url("https://example.com/api?key=secret"),
140 "****/api?key=****"
141 );
142 }
143
144 #[test]
145 fn test_with_multiple_query_params() {
146 assert_eq!(
147 url("https://example.com/api?key=secret&token=abc123"),
148 "****/api?key=****&token=****"
149 );
150 }
151
152 #[test]
153 fn test_with_query_flag_no_value() {
154 assert_eq!(
155 url("https://example.com/api?debug&key=secret"),
156 "****/api?debug=****&key=****"
157 );
158 }
159
160 #[test]
161 fn test_with_fragment() {
162 assert_eq!(url("https://example.com/page#section"), "****/page#****");
163 }
164
165 #[test]
166 fn test_with_query_and_fragment() {
167 assert_eq!(
168 url("https://example.com/api?key=secret#section"),
169 "****/api?key=****#****"
170 );
171 }
172
173 #[test]
174 fn test_query_without_path() {
175 assert_eq!(url("https://example.com?key=secret"), "****?key=****");
176 }
177
178 #[test]
179 fn test_fragment_without_path() {
180 assert_eq!(url("https://example.com#section"), "****#****");
181 }
182
183 fn mask(input: &str) -> String {
184 Redacted::<_, UrlPasswordRedactor>::new(input).to_string()
185 }
186
187 #[test]
188 fn password_is_replaced_with_stars() {
189 assert_eq!(
190 mask("http://alice:s3cr3t@proxy.corp.example.com:3128/"),
191 "http://alice:***@proxy.corp.example.com:3128/"
192 );
193 }
194
195 #[test]
196 fn url_without_explicit_path_is_normalised_by_url_crate() {
197 assert_eq!(
199 mask("http://alice:s3cr3t@proxy.corp.example.com:3128"),
200 "http://alice:***@proxy.corp.example.com:3128/"
201 );
202 }
203
204 #[test]
205 fn postgres_url_password_is_replaced() {
206 assert_eq!(
207 mask("postgres://user:hunter2@db.internal/app"),
208 "postgres://user:***@db.internal/app"
209 );
210 }
211
212 #[test]
213 fn url_without_password_is_normalised() {
214 assert_eq!(
215 mask("http://proxy.example.com:3128"),
216 "http://proxy.example.com:3128/"
217 );
218 }
219
220 #[test]
221 fn url_with_username_but_no_password_is_normalised() {
222 assert_eq!(
223 mask("http://alice@proxy.example.com"),
224 "http://alice@proxy.example.com/"
225 );
226 }
227
228 #[test]
229 fn empty_password_is_normalised_away() {
230 assert_eq!(
233 mask("postgres://user:@db.internal/app"),
234 "postgres://user@db.internal/app"
235 );
236 }
237
238 #[test]
239 fn invalid_url_produces_redacted_placeholder() {
240 assert_eq!(mask("not a url"), "[REDACTED: invalid URL]");
241 }
242
243 #[test]
244 fn works_with_url_type() {
245 let url: Url = "postgres://user:hunter2@db.internal/app".parse().unwrap();
246 let redacted = Redacted::<_, UrlPasswordRedactor>::new(url);
247 assert_eq!(redacted.to_string(), "postgres://user:***@db.internal/app");
248 }
249}