1use url::Url;
14
15const REDACTED: &str = "***";
16
17fn ends_url_token(c: char) -> bool {
22 c == ' ' || c.is_control() || matches!(c, '"' | '<' | '>')
23}
24
25fn scrub_urls(text: &str) -> String {
31 let mut out = String::with_capacity(text.len());
32 let mut pos = 0;
33
34 while let Some(rel) = text[pos..].find("://") {
35 let start = pos + rel + 3;
37 out.push_str(&text[pos..start]);
38
39 let end = text[start..]
40 .find(ends_url_token)
41 .map(|k| start + k)
42 .unwrap_or(text.len());
43 let token = &text[start..end];
44 let authority = token.split(['/', '?', '#']).next().unwrap_or(token);
45 let host = authority.rsplit('@').next().unwrap_or(authority);
47
48 out.push_str(host);
49 if host.len() != token.len() {
50 out.push('/');
51 out.push_str(REDACTED);
52 }
53 pos = end;
54 }
55
56 out.push_str(&text[pos..]);
57 out
58}
59
60const URL_DEBUG_PREFIX: &str = "Url {";
61
62fn scrub_url_debug(text: &str) -> String {
66 let mut out = String::with_capacity(text.len());
67 let mut pos = 0;
68
69 while let Some(rel) = text[pos..].find(URL_DEBUG_PREFIX) {
70 let start = pos + rel;
71 let body_start = start + URL_DEBUG_PREFIX.len();
72 if text[..start]
74 .chars()
75 .next_back()
76 .is_some_and(|c| c.is_alphanumeric() || c == '_')
77 {
78 out.push_str(&text[pos..body_start]);
79 pos = body_start;
80 continue;
81 }
82 out.push_str(&text[pos..body_start]);
83 let mut in_quotes = false;
87 let end = text[body_start..]
88 .char_indices()
89 .find_map(|(i, c)| match c {
90 '"' => {
91 in_quotes = !in_quotes;
92 None
93 },
94 '}' if !in_quotes => Some(i),
95 _ => None,
96 });
97 let Some(k) = end else {
98 pos = text.len();
100 break;
101 };
102 out.push_str(" *** ");
103 pos = body_start + k;
104 }
105
106 out.push_str(&text[pos..]);
107 out
108}
109
110pub fn scrub(text: &str) -> String {
111 scrub_url_debug(&scrub_urls(text))
112}
113
114pub fn redact_url(url: &Url) -> String {
116 let scheme = url.scheme();
117 let Some(host) = url.host() else {
118 return format!("{scheme}:{REDACTED}");
119 };
120 let port = match url.port() {
121 Some(port) => format!(":{port}"),
122 None => String::new(),
123 };
124 let removed = !url.username().is_empty()
125 || url.password().is_some()
126 || !matches!(url.path(), "" | "/")
127 || url.query().is_some()
128 || url.fragment().is_some();
129 let tail = match removed {
130 true => format!("/{REDACTED}"),
131 false => String::new(),
132 };
133 format!("{scheme}://{host}{port}{tail}")
134}
135
136pub fn redact_urls<'a>(urls: impl IntoIterator<Item = &'a Url>) -> Vec<String> {
137 urls.into_iter().map(redact_url).collect()
138}
139
140#[cfg(test)]
141mod test {
142 use super::*;
143
144 #[test]
145 fn test_scrub() {
146 let cases: &[(&str, &str)] = &[
147 (
149 r#"Transport(Custom(reqwest::Error { kind: Request, url: "https://rpc.example.com/v1/FAKEKEY", source: hyper::Error(IncompleteMessage) }))"#,
150 r#"Transport(Custom(reqwest::Error { kind: Request, url: "https://rpc.example.com/***", source: hyper::Error(IncompleteMessage) }))"#,
151 ),
152 (
154 "error sending request for url (https://host/v1/FAKEKEY)",
155 "error sending request for url (https://host/***",
156 ),
157 ("https://host/v2/FAKEKEY", "https://host/***"),
158 ("https://host/rpc?apikey=FAKEKEY", "https://host/***"),
159 ("https://host/p#frag", "https://host/***"),
160 ("http://host:8545/k", "http://host:8545/***"),
161 ("wss://host/v2/FAKEKEY", "wss://host/***"),
162 ("https://[::1]:8545/FAKEKEY", "https://[::1]:8545/***"),
163 ("https://user:pass@host/x", "https://host/***"),
164 ("https://host/a)b,FAKEKEY suffix", "https://host/*** suffix"),
166 ("https://host/a|FAKEKEY", "https://host/***"),
167 ("https://host/rpc?t={FAKEKEY}", "https://host/***"),
168 (
169 "a https://h1/k1 b https://h2/k2 c",
170 "a https://h1/*** b https://h2/*** c",
171 ),
172 (
173 "日本 https://host/FAKEKEY 中文",
174 "日本 https://host/*** 中文",
175 ),
176 ("http://localhost:8545", "http://localhost:8545"),
177 ("no url here", "no url here"),
178 ("://", "://"),
179 ("", ""),
180 ];
181 for (input, expected) in cases {
182 assert_eq!(&scrub(input), expected, "input: {input:?}");
183 let once = scrub(input);
184 assert_eq!(scrub(&once), once, "not idempotent: {input:?}");
185 }
186 }
187
188 #[test]
191 fn test_scrub_url_debug() {
192 let raw = format!(
193 "urls: [{:?}]",
194 Url::parse("https://rpc.example.com/v3/FAKEKEY").unwrap()
195 );
196 assert!(raw.contains("FAKEKEY"), "{raw}");
197 assert!(!raw.contains("://"), "{raw}");
198
199 let scrubbed = scrub(&raw);
200 assert_eq!(scrubbed, "urls: [Url { *** }]");
201 assert_eq!(scrub(&scrubbed), scrubbed);
202 }
203
204 #[test]
206 fn test_scrub_url_debug_brace_in_query_or_fragment() {
207 for raw in [
208 "https://rpc.example.com/v1?filter={a}&apikey=FAKEKEY",
209 "https://rpc.example.com/v1#{a}FAKEKEY",
210 ] {
211 let dbg = format!("{:?}", Url::parse(raw).unwrap());
212 assert_eq!(scrub_url_debug(&dbg), "Url { *** }", "input: {raw}");
213 }
214 }
215
216 #[test]
218 fn test_scrub_url_debug_requires_type_boundary() {
219 let text = r#"BaseUrl { host: "keep.me" }"#;
220 assert_eq!(scrub_url_debug(text), text);
221
222 let cut = r#"urls: [Url { scheme: "https", path: "/v3/FAKEKEY""#;
224 assert!(!scrub_url_debug(cut).contains("FAKEKEY"));
225 }
226
227 #[test]
228 fn test_redact_url() {
229 let cases: &[(&str, &str)] = &[
230 (
231 "https://host.example.com/v1/FAKEKEY",
232 "https://host.example.com/***",
233 ),
234 ("http://host:8545/k", "http://host:8545/***"),
235 ("https://user:pass@host/x", "https://host/***"),
236 ("https://host/rpc?apikey=FAKEKEY", "https://host/***"),
237 ("https://[::1]:8545/FAKEKEY", "https://[::1]:8545/***"),
238 ("http://localhost:8545", "http://localhost:8545"),
239 ("mailto:foo@example.com", "mailto:***"),
240 ];
241 for (input, expected) in cases {
242 let url = Url::parse(input).unwrap();
243 assert_eq!(&redact_url(&url), expected, "input: {input:?}");
244 }
245 }
246}